Skip to content

Interceptors & Extensions

Padrone’s architecture is built on two complementary systems:

  • Extensions: Build-time composition — reusable bundles of commands, configuration, and interceptors applied via .extend(). Most of Padrone’s built-in features (help, version, REPL, color, signal handling, auto-output, stdin, interactive, suggestions) are implemented as extensions.
  • Interceptors: Runtime phase interception — middleware that wraps the command lifecycle (parse, validate, execute, etc.) with an onion model. Extensions typically register interceptors under the hood.

This extension-first architecture means the core is minimal — features are layered on via the same .extend() and .intercept() APIs you use for your own code.

When you call createPadrone('myapp'), built-in extensions are automatically applied:

ExtensionWhat it doesInterceptor Order
help--help/-h flag, help command, error-phase help display-1000
version--version/-v flag, version command-1000
repl--repl flag, repl command-1000
color--color/--no-color flag, theme override-1001
suggestions”Did you mean?” for unknown commands/options-500
signalSIGINT/SIGTERM handling, double-tap force-exit, AbortSignal propagation-2000
autoOutputAuto-print results (strings, promises, iterators)-1100
stdinPipe stdin into argument fields (text, lines, or stream)-1001
interactive--interactive/-i flag, auto-prompting for missing fields-999

Each can be disabled individually:

const program = createPadrone('myapp', {
builtins: { repl: false, color: false },
});

Additional opt-in extensions are available for advanced features:

ExtensionImportWhat it does
padroneEnv(schema)'padrone'Parse environment variables into args
padroneConfig(options)'padrone'Load args from config files
padroneProgress(config)'padrone'Auto-managed progress indicators
padroneLogger(options)'padrone'Structured logging with levels
padroneTiming()'padrone'Execution timing
padroneUpdateCheck(config)'padrone'Background version checking
padroneInk()'padrone/ink'React (Ink) rendering support
padroneMcp()'padrone/mcp'MCP server integration
padroneServe()'padrone/serve'REST server integration
padroneTracing(config)'padrone/tracing'OpenTelemetry tracing
padroneCompletion()'padrone/completion'Shell completion generation
padroneMan()'padrone/man'Man page generation

An extension is a function that receives a builder and returns a modified builder. Extensions can add commands, arguments, interceptors, and configuration — anything the builder supports.

Apply extensions with .extend():

import { createPadrone, padroneEnv, padroneConfig, padroneProgress } from 'padrone';
const program = createPadrone('myapp')
.extend(padroneEnv(envSchema))
.extend(padroneConfig({ files: 'app.config.json' }))
.command('deploy', (c) =>
c
.extend(padroneProgress('Deploying...'))
.action(async () => { /* ... */ })
);

Extensions compose naturally — chain multiple .extend() calls to layer functionality. Extensions applied at the program level affect all commands; extensions applied inside a .command() callback affect only that command.

A PadroneExtension is a function (builder) => builder:

import type { PadroneExtension } from 'padrone';
// Simple extension that adds a shared interceptor
const withLogging = (builder) =>
builder.intercept(defineInterceptor({ name: 'logger' }, () => ({
execute: (ctx, next) => {
console.log(`Running: ${ctx.command.name}`);
return next();
},
})));
// Extension that adds commands and configuration
const withAdmin = (builder) =>
builder
.command('admin', (c) =>
c
.command('status', (s) => s.action(() => 'healthy'))
.command('reset', (s) => s.configure({ mutation: true }).action(() => 'reset'))
);
const program = createPadrone('myapp')
.extend(withLogging)
.extend(withAdmin);

Most built-in extensions follow this pattern — register a command (if needed) and an interceptor:

function myFeature(options?: MyOptions) {
return (builder) =>
builder
.command('my-feature', (c) =>
c.configure({ hidden: true }).action(() => { /* ... */ })
)
.intercept(myFeatureInterceptor(options));
}

Interceptors let you intercept the command lifecycle using a middleware pattern. They wrap each phase with an onion model, giving you full control to modify inputs, short-circuit execution, add logging, or implement cross-cutting concerns.

The full lifecycle is: start → parse → route → validate → execute → shutdown (with error on failure).

Defining Interceptors with defineInterceptor()

Section titled “Defining Interceptors with defineInterceptor()”

The recommended way to create interceptors is with defineInterceptor(). It returns a factory function that creates fresh phase handlers per execution, enabling cross-phase state sharing via closures:

import { defineInterceptor } from 'padrone';
const auditInterceptor = defineInterceptor({ name: 'audit', order: 10 }, () => {
// Closure state — fresh per execution, shared across phases
let startTime: number;
return {
start: (ctx, next) => {
startTime = Date.now();
return next();
},
execute: (ctx, next) => {
const result = next();
auditLog({ command: ctx.command.name, duration: Date.now() - startTime });
return result;
},
};
});

The first argument is metadata (name, order, optional id). The second argument is a factory function that returns phase handlers.

Metadata properties:

PropertyTypeDescription
namestringDisplay name for the interceptor
ordernumberExecution order — lower = outermost (default: 0)
idstringDeduplication key — when multiple interceptors share an id, the last one wins
disabledbooleanSkip this interceptor during execution

For simple cases, you can also pass an object directly to .intercept():

const logger: PadroneInterceptor = {
name: 'logger',
execute: (ctx, next) => {
console.log(`Running: ${ctx.command.name}`);
const result = next();
console.log(`Done: ${ctx.command.name}`);
return result;
},
};
program.intercept(logger);

Use .intercept() on programs or individual commands:

const program = createPadrone('myapp')
.intercept(logger) // Applies to all commands
.command('deploy', (c) =>
c
.intercept(deployGuard) // Only applies to 'deploy'
.arguments(schema)
.action(handler)
);

.intercept() is immutable — it returns a new builder with the interceptor added.

Interceptors can hook into seven phases — four core phases (parse, route, validate, execute) and three lifecycle phases (start, error, shutdown):

Runs before everything else, wrapping the entire pipeline. Only root-level interceptors run during start — subcommand interceptors are not invoked. Available in eval() and cli() only (not parse() or run()).

const startup = defineInterceptor({ name: 'startup' }, () => ({
start: (ctx, next) => {
console.log('Starting up...');
const result = next(); // Runs the full parse → validate → execute pipeline
console.log('Pipeline complete');
return result;
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandThe root command
inputstring | undefinedRaw CLI input string
signalAbortSignalCancellation signal (provided by the signal extension)
contextunknownUser-provided context from cli()/eval()
callerstringInvocation method ('cli', 'eval', 'repl', etc.)
runtimeResolvedPadroneRuntimeThe resolved runtime
programAnyPadroneProgramThe root program

Result: The full pipeline result (passed through from parse → validate → execute).

Runs when CLI input is being parsed into a command and raw arguments. Only root-level interceptors run during parsing — subcommand interceptors are not invoked.

const parseLogger = defineInterceptor({ name: 'parse-logger' }, () => ({
parse: (ctx, next) => {
console.log('Input:', ctx.input);
const result = next();
console.log('Parsed command:', result.command.name);
return result;
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandThe root command
inputstring | undefinedRaw CLI input string
signalAbortSignalCancellation signal
contextunknownUser-provided context from cli()/eval()
callerstringInvocation method

Result:

PropertyTypeDescription
commandPadroneCommandResolved command
rawArgsRecord<string, unknown>Parsed raw arguments
positionalArgsstring[]Positional argument values

Runs after the target command is resolved (post-parse), before validation. Both root and command-level interceptors participate. Use for per-command setup like authorization checks, resource loading, or logging.

const auth = defineInterceptor({ name: 'auth' }, () => ({
route: (ctx, next) => {
if (ctx.command.meta?.requiresAuth && !isAuthenticated()) {
throw new Error('Not authenticated');
}
return next();
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandResolved target command
rawArgsRecord<string, unknown>Parsed raw arguments
positionalArgsstring[]Positional argument values
signalAbortSignalCancellation signal
contextunknownUser-provided context
callerstringInvocation method

Result: void

Runs after routing, when raw arguments are being validated against the schema.

const defaults = defineInterceptor({ name: 'inject-defaults' }, () => ({
validate: (ctx, next) => {
// Inject values before validation
ctx.rawArgs.region ??= 'us-east-1';
return next();
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandResolved command
rawArgsRecord<string, unknown>Mutable raw arguments — modify before next()
positionalArgsstring[]Positional argument values
signalAbortSignalCancellation signal
contextunknownUser-provided context
callerstringInvocation method

Result:

PropertyTypeDescription
argsunknownValidated arguments
argsResultStandardSchemaV1.ResultFull validation result

Runs when the command’s action handler is being invoked.

const timer = defineInterceptor({ name: 'timer' }, () => ({
execute: (ctx, next) => {
const start = performance.now();
const result = next();
const duration = performance.now() - start;
console.log(`Completed in ${duration.toFixed(0)}ms`);
return result;
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandResolved command
argsunknownMutable validated arguments — modify before next()
signalAbortSignalCancellation signal
contextunknownUser-provided context
callerstringInvocation method

Result:

PropertyTypeDescription
resultunknownAction handler return value

Called when the pipeline throws an error. Error handlers can log, transform, or suppress errors. Only runs for eval() and cli(). Runs in two layers: command-level error handlers run first (for route/validate/execute failures), then root-level error handlers (for all failures including parse).

const errorReporter = defineInterceptor({ name: 'error-reporter' }, () => ({
error: (ctx, next) => {
// Log and pass through
reportToSentry(ctx.error);
return next();
},
}));
const errorRecovery = defineInterceptor({ name: 'error-recovery' }, () => ({
error: (ctx, next) => {
// Suppress the error and return a fallback result
if (ctx.error instanceof NetworkError) {
return { error: undefined, result: cachedValue };
}
// Transform the error
return { error: new AppError('Something went wrong', { cause: ctx.error }) };
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandThe resolved command (target command for command-level, root for root-level)
errorunknownThe error that was thrown
signalAbortSignalCancellation signal
contextunknownUser-provided context
callerstringInvocation method

Result:

PropertyTypeDescription
errorunknown | undefinedThe error to throw. Set to undefined to suppress.
resultunknownReplacement result when suppressing the error.

Calling next() passes to the next error handler. The innermost core returns { error } unchanged, which re-throws after shutdown runs. Command-level error handlers can suppress errors before they reach root-level handlers.

Always runs after the pipeline completes — whether it succeeded or failed. Use for cleanup like closing connections or flushing logs. Only runs for eval() and cli(). Runs in two layers: command-level shutdown handlers run first (for the route/validate/execute scope), then root-level shutdown handlers (for the full pipeline scope).

const cleanup = defineInterceptor({ name: 'cleanup' }, () => ({
shutdown: (ctx, next) => {
if (ctx.error) {
console.error('Failed:', ctx.error);
}
db.close();
return next();
},
}));

Context:

PropertyTypeDescription
commandPadroneCommandThe resolved command (target command for command-level, root for root-level)
errorunknown | undefinedThe error, if the pipeline failed
resultunknown | undefinedThe pipeline result, if it succeeded
signalAbortSignalCancellation signal
contextunknownUser-provided context
callerstringInvocation method

Interceptors compose as an onion — the first registered interceptor is the outermost wrapper:

program
.intercept(interceptorA) // Outermost — runs first on entry, last on exit
.intercept(interceptorB) // Inner
.intercept(interceptorC); // Innermost — runs last on entry, first on exit

Program-level interceptors always wrap subcommand interceptors:

Program interceptors (outermost) → Subcommand interceptors (inner) → Action handler (core)

Use the order property to control position. Lower values run as outermost wrappers:

const auth = defineInterceptor({ name: 'auth', order: -10 }, () => ({
execute: (ctx, next) => {
if (!isAuthenticated()) throw new Error('Not authenticated');
return next();
},
}));
const metrics = defineInterceptor({ name: 'metrics', order: 10 }, () => ({
execute: (ctx, next) => {
const result = next();
reportMetrics(ctx.command.name);
return result;
},
}));

Interceptors with the same order (default: 0) preserve their registration order.

Built-in extensions use negative orders to ensure they wrap user interceptors:

-2000 signal (outermost)
-1100 autoOutput
-1001 color, stdin
-1000 help, version, repl
-999 interactive
-500 suggestions
0 user interceptors (default)

When multiple interceptors share the same id, the last one wins. This lets you override built-in behavior:

// The built-in auto-output interceptor has id: 'padrone:auto-output'
// Override it to disable for a specific command:
builder.intercept(defineInterceptor({
name: 'no-auto-output',
id: 'padrone:auto-output',
disabled: true,
}, () => ({})));

Use the defineInterceptor factory’s closure to share state across phases within a single execution. Each execution gets a fresh factory call, so state is isolated:

const auditInterceptor = defineInterceptor({ name: 'audit' }, () => {
let startTime: number;
let parsedCommand: string;
return {
parse: (ctx, next) => {
startTime = Date.now();
const result = next();
parsedCommand = result.command.name;
return result;
},
execute: (ctx, next) => {
const result = next();
auditLog({ command: parsedCommand, duration: Date.now() - startTime });
return result;
},
};
});

Return early without calling next() to skip the rest of the chain:

const dryRun = defineInterceptor({ name: 'dry-run' }, () => ({
execute: (ctx, next) => {
if (ctx.args.dryRun) {
console.log('Dry run — skipping execution');
return { result: undefined };
}
return next();
},
}));

Pass overrides to next() to modify values for downstream interceptors:

const signalInterceptor = defineInterceptor({ name: 'signal' }, () => ({
start: (ctx, next) => {
const controller = new AbortController();
// Downstream phases see the new signal
return next({ signal: controller.signal });
},
}));

This is how the built-in signal extension propagates the AbortSignal — it creates an AbortController in the start phase and passes the signal downstream via next({ signal }).

Interceptors can declare what they add to the context using .provides() and what they require with .requires(). These are type-level only (no runtime effect) but enable typed ctx.context access:

const withDb = defineInterceptor({ name: 'with-db' })
.provides<{ db: Database }>()
.factory(() => ({
execute: (ctx, next) => {
const db = createDatabase();
return next({ context: { ...ctx.context, db } });
},
}));
// When this interceptor is registered, ctx.context.db is typed

The padroneProgress() extension uses this pattern — it declares .provides<{ progress: PadroneProgress }>() so ctx.context.progress is fully typed when the extension is applied.

Interceptors preserve sync/async behavior. If your interceptor and all inner interceptors are synchronous, the entire chain stays synchronous. Only return a Promise when you need async operations:

// Sync interceptor — chain stays sync
const syncInterceptor = defineInterceptor({ name: 'sync' }, () => ({
execute: (ctx, next) => {
console.log('before');
const result = next();
console.log('after');
return result;
},
}));
// Async interceptor — chain becomes async
const asyncInterceptor = defineInterceptor({ name: 'async' }, () => ({
execute: async (ctx, next) => {
await someAsyncWork();
return next();
},
}));
MethodStartParseRouteValidateExecuteErrorShutdown
eval() / cli()YesYesYesYesYesYesYes
parse()NoYesNoYesNoNoNo
run()NoNoNoNoYesNoNo

Understanding how built-in features are implemented helps illustrate the interceptor model:

Signal handling (padroneSignalHandling, order: -2000) — The outermost interceptor. In the start phase, creates an AbortController and subscribes to OS signals via runtime.onSignal(). Passes the signal to all downstream phases via next({ signal }). In the error phase, wraps errors with signal info. In shutdown, cleans up subscriptions. Implements double-tap SIGINT force-exit using closure state shared across phases.

Auto-output (padroneAutoOutput, order: -1100) — In the execute phase, intercepts the action result and writes it to runtime.output(). Handles promises (awaits), iterators (consumes and outputs each value), and plain values.

Help (padroneHelp, order: -1000) — Adds a help command and registers an interceptor with parse, execute, and error phases. The parse phase detects --help flags and reroutes to the help command. The error phase formats routing/validation errors with help text in CLI mode.

Config file loading (padroneConfig, order: -999) — Not included by default; must be explicitly applied via .extend(padroneConfig(...)). In the validate phase, loads the config file from the file system (or via a custom loadConfig function) and merges values into rawArgs before schema validation.

Interactive prompting (padroneInteractive, order: -999) — In the validate phase, prompts for missing field values via runtime.prompt() and injects responses into rawArgs before validation.

Suggestions (padroneSuggestions, order: -500) — In parse and validate error paths, enriches error messages with fuzzy-matched “Did you mean?” suggestions.

Disabling and Overriding Built-in Extensions

Section titled “Disabling and Overriding Built-in Extensions”
const program = createPadrone('myapp', {
builtins: {
help: false,
version: false,
repl: false,
color: false,
suggestions: false,
signal: false,
autoOutput: false,
stdin: false,
interactive: false,
},
});

Built-in interceptors use id fields like 'padrone:help', 'padrone:auto-output', etc. Register an interceptor with the same id to replace the built-in behavior:

// Custom help interceptor that replaces the built-in one
program.intercept(defineInterceptor({
name: 'custom-help',
id: 'padrone:help',
order: -1000,
}, () => ({
parse: (ctx, next) => {
// Custom help flag handling
return next();
},
})));