Complex plugins often expose a tree of commands (e.g. aws dynamodb create, aws dynamodb delete), each with its own options and defaults. This guide documents the canonical pattern to handle precedence, validation, and testing cleanly.
.action().Use a single Zod schema for the plugin, with nested objects for each subcommand. This allows plugin.readConfig(cli) to provide typed defaults for the entire tree.
export const MyPluginConfigSchema = z.object({
// Shared/Global options
region: z.string().optional(),
// Per-subcommand defaults
create: z
.object({
version: z.string().optional(),
waiter: z
.object({
maxSeconds: z.union([z.number(), z.string()]).optional(),
})
.optional(),
})
.optional(),
});
Command registration modules should only:
opts to the Resolver.// src/cli/plugin/commands/create.ts
export function attachCreateCommand(cli: GetDotenvCliPublic, plugin: MyPlugin) {
const cmd = cli.command('create').description('Create resource');
cmd.action(async (_args, opts) => {
const cfg = plugin.readConfig(cli);
const ctx = cli.getCtx();
// Pass everything to a pure resolver
const input = resolveCreateInput(opts, cfg, {
...process.env,
...ctx.dotenv,
});
// Call service
await createService(input);
});
}
Centralize precedence rules and expansion in a pure function. This makes logic unit-testable without mocking Commander or the CLI host.
Signature: (flags, config, envRef) => ServiceInput
// src/cli/options/create.ts
export function resolveCreateInput(
flags: Record<string, unknown>,
config: MyPluginConfig,
envRef: ProcessEnv,
): CreateServiceInput {
// 1. Expand flags (Action-time expansion)
// Users may pass '${VAR}' in flags; expand them here.
const rawVersion = String(flags.version ?? '');
const versionFlag = dotenvExpand(rawVersion, envRef);
// 2. Precedence: Flag > Config > Default
// Note: Config strings are ALREADY expanded by the host. Do not re-expand.
const version = versionFlag || config.create?.version || 'v1';
return { version };
}
The host and plugin share responsibility for variable expansion:
dotenvExpand on values read from plugin.readConfig().dotenvExpand(value, { ...process.env, ...ctx.dotenv }) on flag values in your resolver.To show effective defaults in --help, attach dynamic options to the subcommand, not the root plugin mount.
const cmd = cli.command('create');
cmd.addOption(
plugin.createPluginDynamicOption(
cmd, // Scope help to this subcommand
'--version <string>',
(_bag, cfg) =>
`resource version${cfg.create?.version ? ` (default: ${cfg.create.version})` : ''}`,
),
);