padrone 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +97 -1
  2. package/LICENSE +1 -1
  3. package/README.md +60 -30
  4. package/dist/args-DFEI7_G_.mjs +197 -0
  5. package/dist/args-DFEI7_G_.mjs.map +1 -0
  6. package/dist/chunk-y_GBKt04.mjs +5 -0
  7. package/dist/codegen/index.d.mts +305 -0
  8. package/dist/codegen/index.d.mts.map +1 -0
  9. package/dist/codegen/index.mjs +1358 -0
  10. package/dist/codegen/index.mjs.map +1 -0
  11. package/dist/completion.d.mts +64 -0
  12. package/dist/completion.d.mts.map +1 -0
  13. package/dist/completion.mjs +417 -0
  14. package/dist/completion.mjs.map +1 -0
  15. package/dist/docs/index.d.mts +34 -0
  16. package/dist/docs/index.d.mts.map +1 -0
  17. package/dist/docs/index.mjs +405 -0
  18. package/dist/docs/index.mjs.map +1 -0
  19. package/dist/formatter-XroimS3Q.d.mts +83 -0
  20. package/dist/formatter-XroimS3Q.d.mts.map +1 -0
  21. package/dist/help-CgGP7hQU.mjs +1229 -0
  22. package/dist/help-CgGP7hQU.mjs.map +1 -0
  23. package/dist/index.d.mts +120 -546
  24. package/dist/index.d.mts.map +1 -1
  25. package/dist/index.mjs +1220 -1204
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/test.d.mts +112 -0
  28. package/dist/test.d.mts.map +1 -0
  29. package/dist/test.mjs +138 -0
  30. package/dist/test.mjs.map +1 -0
  31. package/dist/types-BS7RP5Ls.d.mts +1059 -0
  32. package/dist/types-BS7RP5Ls.d.mts.map +1 -0
  33. package/dist/update-check-EbNDkzyV.mjs +146 -0
  34. package/dist/update-check-EbNDkzyV.mjs.map +1 -0
  35. package/package.json +61 -21
  36. package/src/args.ts +457 -0
  37. package/src/cli/completions.ts +29 -0
  38. package/src/cli/docs.ts +86 -0
  39. package/src/cli/doctor.ts +330 -0
  40. package/src/cli/index.ts +159 -0
  41. package/src/cli/init.ts +135 -0
  42. package/src/cli/link.ts +320 -0
  43. package/src/cli/wrap.ts +152 -0
  44. package/src/codegen/README.md +118 -0
  45. package/src/codegen/code-builder.ts +226 -0
  46. package/src/codegen/discovery.ts +232 -0
  47. package/src/codegen/file-emitter.ts +73 -0
  48. package/src/codegen/generators/barrel-file.ts +16 -0
  49. package/src/codegen/generators/command-file.ts +197 -0
  50. package/src/codegen/generators/command-tree.ts +124 -0
  51. package/src/codegen/index.ts +33 -0
  52. package/src/codegen/parsers/fish.ts +163 -0
  53. package/src/codegen/parsers/help.ts +378 -0
  54. package/src/codegen/parsers/merge.ts +158 -0
  55. package/src/codegen/parsers/zsh.ts +221 -0
  56. package/src/codegen/schema-to-code.ts +199 -0
  57. package/src/codegen/template.ts +69 -0
  58. package/src/codegen/types.ts +143 -0
  59. package/src/colorizer.ts +2 -2
  60. package/src/command-utils.ts +504 -0
  61. package/src/completion.ts +110 -97
  62. package/src/create.ts +1048 -308
  63. package/src/docs/index.ts +607 -0
  64. package/src/errors.ts +131 -0
  65. package/src/formatter.ts +195 -73
  66. package/src/help.ts +159 -58
  67. package/src/index.ts +12 -15
  68. package/src/interactive.ts +169 -0
  69. package/src/parse.ts +52 -21
  70. package/src/repl-loop.ts +317 -0
  71. package/src/runtime.ts +304 -0
  72. package/src/shell-utils.ts +83 -0
  73. package/src/test.ts +285 -0
  74. package/src/type-helpers.ts +10 -10
  75. package/src/type-utils.ts +124 -14
  76. package/src/types.ts +752 -154
  77. package/src/update-check.ts +244 -0
  78. package/src/wrap.ts +44 -40
  79. package/src/zod.d.ts +2 -2
  80. package/src/options.ts +0 -180
package/src/create.ts CHANGED
@@ -1,53 +1,74 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
2
  import type { Schema } from 'ai';
3
- import { generateCompletionOutput, type ShellType } from './completion.ts';
3
+ import { coerceArgs, detectUnknownArgs, extractSchemaMetadata, parsePositionalConfig, parseStdinConfig, preprocessArgs } from './args.ts';
4
+ import {
5
+ commandSymbol,
6
+ findCommandByName,
7
+ getCommandRuntime,
8
+ hasInteractiveConfig,
9
+ isAsyncBranded,
10
+ mergeCommands,
11
+ noop,
12
+ outputValue,
13
+ repathCommandTree,
14
+ runPluginChain,
15
+ suggestSimilar,
16
+ thenMaybe,
17
+ warnIfUnexpectedAsync,
18
+ wrapWithLifecycle,
19
+ } from './command-utils.ts';
20
+ import type { ShellType } from './completion.ts';
21
+ import { ConfigError, RoutingError, ValidationError } from './errors.ts';
4
22
  import { generateHelp } from './help.ts';
5
- import { extractSchemaMetadata, parsePositionalConfig, preprocessOptions } from './options.ts';
23
+ import { promptInteractiveFields } from './interactive.ts';
6
24
  import { getNestedValue, parseCliInputToParts, setNestedValue } from './parse.ts';
7
- import type { AnyPadroneCommand, AnyPadroneProgram, PadroneAPI, PadroneCommand, PadroneProgram } from './types.ts';
8
- import { findConfigFile, getVersion, loadConfigFile } from './utils.ts';
25
+ import { createReplIterator } from './repl-loop.ts';
26
+ import { resolveStdin } from './runtime.ts';
27
+ import type {
28
+ AnyPadroneCommand,
29
+ AnyPadroneProgram,
30
+ PadroneActionContext,
31
+ PadroneAPI,
32
+ PadroneCommand,
33
+ PadroneEvalPreferences,
34
+ PadronePlugin,
35
+ PadroneProgram,
36
+ PadroneReplPreferences,
37
+ PluginExecuteContext,
38
+ PluginExecuteResult,
39
+ PluginParseContext,
40
+ PluginParseResult,
41
+ PluginValidateContext,
42
+ PluginValidateResult,
43
+ } from './types.ts';
44
+ import { getVersion } from './utils.ts';
9
45
  import { createWrapHandler } from './wrap.ts';
10
46
 
11
- const commandSymbol = Symbol('padrone_command');
12
-
13
- const noop = <TRes>() => undefined as TRes;
47
+ export { asyncSchema, buildReplCompleter } from './command-utils.ts';
14
48
 
15
49
  export function createPadrone<TProgramName extends string>(name: TProgramName): PadroneProgram<TProgramName, '', ''> {
16
50
  return createPadroneBuilder({ name, path: '', commands: [] } as any) as unknown as PadroneProgram<TProgramName, '', ''>;
17
51
  }
18
52
 
19
53
  export function createPadroneBuilder<TBuilder extends PadroneProgram = PadroneProgram>(
20
- existingCommand: AnyPadroneCommand,
54
+ inputCommand: AnyPadroneCommand,
21
55
  ): TBuilder & { [commandSymbol]: AnyPadroneCommand } {
22
- function findCommandByName(name: string, commands?: AnyPadroneCommand[]): AnyPadroneCommand | undefined {
23
- if (!commands) return undefined;
24
-
25
- const foundByName = commands.find((cmd) => cmd.name === name);
26
- if (foundByName) return foundByName;
27
-
28
- // Check for aliases
29
- const foundByAlias = commands.find((cmd) => cmd.aliases?.includes(name));
30
- if (foundByAlias) return foundByAlias;
31
-
32
- for (const cmd of commands) {
33
- if (cmd.commands && name.startsWith(`${cmd.name} `)) {
34
- const subCommandName = name.slice(cmd.name.length + 1);
35
- const subCommand = findCommandByName(subCommandName, cmd.commands);
36
- if (subCommand) return subCommand;
37
- }
38
- // Check aliases for nested commands
39
- if (cmd.commands && cmd.aliases) {
40
- for (const alias of cmd.aliases) {
41
- if (name.startsWith(`${alias} `)) {
42
- const subCommandName = name.slice(alias.length + 1);
43
- const subCommand = findCommandByName(subCommandName, cmd.commands);
44
- if (subCommand) return subCommand;
45
- }
56
+ // Re-parent direct subcommands so getCommandRuntime walks to the current root,
57
+ // not a stale parent from before .runtime()/.configure()/etc.
58
+ const existingCommand =
59
+ inputCommand.commands?.length && inputCommand.commands.some((c) => c.parent && c.parent !== inputCommand)
60
+ ? {
61
+ ...inputCommand,
62
+ commands: inputCommand.commands.map((c) => (c.parent && c.parent !== inputCommand ? { ...c, parent: inputCommand } : c)),
46
63
  }
47
- }
48
- }
49
- return undefined;
50
- }
64
+ : inputCommand;
65
+
66
+ /** Creates the action context passed to command handlers. References `builder` which is defined later but only called at runtime. */
67
+ const createActionContext = (cmd: AnyPadroneCommand): PadroneActionContext => ({
68
+ runtime: getCommandRuntime(cmd),
69
+ command: cmd,
70
+ program: builder as any,
71
+ });
51
72
 
52
73
  const find: AnyPadroneProgram['find'] = (command) => {
53
74
  if (typeof command !== 'string') return findCommandByName(command.path, existingCommand.commands) as any;
@@ -55,11 +76,18 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
55
76
  };
56
77
 
57
78
  /**
58
- * Parses CLI input to find the command and extract raw options without validation.
79
+ * Parses CLI input to find the command and extract raw arguments without validation.
59
80
  */
60
81
  const parseCommand = (input: string | undefined) => {
61
- input ??= typeof process !== 'undefined' ? (process.argv.slice(2).join(' ') as any) : undefined;
62
- if (!input) return { command: existingCommand, rawOptions: {} as Record<string, unknown>, args: [] as string[] };
82
+ input ??= getCommandRuntime(existingCommand).argv().join(' ') || undefined;
83
+ if (!input) {
84
+ // No input: check for default '' command
85
+ const defaultCommand = findCommandByName('', existingCommand.commands);
86
+ if (defaultCommand) {
87
+ return { command: defaultCommand, rawArgs: {} as Record<string, unknown>, args: [] as string[], unmatchedTerms: [] as string[] };
88
+ }
89
+ return { command: existingCommand, rawArgs: {} as Record<string, unknown>, args: [] as string[], unmatchedTerms: [] as string[] };
90
+ }
63
91
 
64
92
  const parts = parseCliInputToParts(input);
65
93
 
@@ -67,6 +95,7 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
67
95
  const args = parts.filter((p) => p.type === 'arg').map((p) => p.value);
68
96
 
69
97
  let curCommand: AnyPadroneCommand | undefined = existingCommand;
98
+ let unmatchedTerms: string[] = [];
70
99
 
71
100
  // If the first term is the program name, skip it
72
101
  if (terms[0] === existingCommand.name) terms.shift();
@@ -78,26 +107,38 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
78
107
  if (found) {
79
108
  curCommand = found;
80
109
  } else {
81
- args.unshift(...terms.slice(i));
110
+ unmatchedTerms = terms.slice(i);
111
+ args.unshift(...unmatchedTerms);
82
112
  break;
83
113
  }
84
114
  }
85
115
 
86
- if (!curCommand) return { command: existingCommand, rawOptions: {} as Record<string, unknown>, args };
116
+ // If no unmatched terms remain, check for a default '' subcommand.
117
+ // This handles both the root level (no input) and nested commands (e.g., "advanced" with a '' subcommand).
118
+ if (unmatchedTerms.length === 0 && curCommand.commands?.length) {
119
+ const defaultCommand = findCommandByName('', curCommand.commands);
120
+ if (defaultCommand) {
121
+ curCommand = defaultCommand;
122
+ }
123
+ }
124
+
125
+ if (!curCommand) return { command: existingCommand, rawArgs: {} as Record<string, unknown>, args, unmatchedTerms };
87
126
 
88
- // Extract option metadata from the nested options object in meta
89
- const optionsMeta = curCommand.meta?.options;
90
- const schemaMetadata = curCommand.options ? extractSchemaMetadata(curCommand.options, optionsMeta) : { aliases: {} };
91
- const { aliases } = schemaMetadata;
127
+ // Extract argument metadata from the nested arguments object in meta
128
+ const argsMeta = curCommand.meta?.fields;
129
+ const schemaMetadata = curCommand.argsSchema
130
+ ? extractSchemaMetadata(curCommand.argsSchema, argsMeta, curCommand.meta?.autoAlias)
131
+ : { flags: {}, aliases: {} };
132
+ const { flags, aliases } = schemaMetadata;
92
133
 
93
- // Get array options from schema (arrays are always variadic)
94
- const arrayOptions = new Set<string>();
95
- if (curCommand.options) {
134
+ // Get array arguments from schema (arrays are always variadic)
135
+ const arrayArguments = new Set<string>();
136
+ if (curCommand.argsSchema) {
96
137
  try {
97
- const jsonSchema = curCommand.options['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
138
+ const jsonSchema = curCommand.argsSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
98
139
  if (jsonSchema.type === 'object' && jsonSchema.properties) {
99
140
  for (const [key, prop] of Object.entries(jsonSchema.properties as Record<string, any>)) {
100
- if (prop?.type === 'array') arrayOptions.add(key);
141
+ if (prop?.type === 'array') arrayArguments.add(key);
101
142
  }
102
143
  }
103
144
  } catch {
@@ -105,27 +146,33 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
105
146
  }
106
147
  }
107
148
 
108
- const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
109
- const rawOptions: Record<string, unknown> = {};
149
+ const argParts = parts.filter((p) => p.type === 'named' || p.type === 'alias');
150
+ const rawArgs: Record<string, unknown> = {};
110
151
 
111
- for (const opt of opts) {
112
- // For aliases, resolve to the full key name (aliases map single char to full key name)
113
- // opt.key is now a string[] - for aliases it's always single element like ['v']
114
- const key: string[] = opt.type === 'alias' && opt.key.length === 1 && aliases[opt.key[0]!] ? [aliases[opt.key[0]!]!] : opt.key;
152
+ for (const arg of argParts) {
153
+ // Resolve flags (single-char, from alias parts: -v) and aliases (multi-char, from named parts: --dry-run)
154
+ let key: string[];
155
+ if (arg.type === 'alias' && arg.key.length === 1 && flags[arg.key[0]!]) {
156
+ key = [flags[arg.key[0]!]!];
157
+ } else if (arg.type === 'named' && arg.key.length === 1 && aliases[arg.key[0]!]) {
158
+ key = [aliases[arg.key[0]!]!];
159
+ } else {
160
+ key = arg.key;
161
+ }
115
162
 
116
163
  const rootKey = key[0]!;
117
164
 
118
- // Handle negated boolean options (--no-verbose)
119
- if (opt.type === 'option' && opt.negated) {
120
- setNestedValue(rawOptions, key, false);
165
+ // Handle negated boolean arguments (--no-verbose)
166
+ if (arg.type === 'named' && arg.negated) {
167
+ setNestedValue(rawArgs, key, false);
121
168
  continue;
122
169
  }
123
170
 
124
- const value = opt.value ?? true;
171
+ const value = arg.value ?? true;
125
172
 
126
- // Handle array options - accumulate values into arrays (arrays are always variadic)
127
- if (arrayOptions.has(rootKey)) {
128
- const existing = getNestedValue(rawOptions, key);
173
+ // Handle array arguments - accumulate values into arrays (arrays are always variadic)
174
+ if (arrayArguments.has(rootKey)) {
175
+ const existing = getNestedValue(rawArgs, key);
129
176
  if (existing !== undefined) {
130
177
  if (Array.isArray(existing)) {
131
178
  if (Array.isArray(value)) {
@@ -135,129 +182,260 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
135
182
  }
136
183
  } else {
137
184
  if (Array.isArray(value)) {
138
- setNestedValue(rawOptions, key, [existing, ...value]);
185
+ setNestedValue(rawArgs, key, [existing, ...value]);
139
186
  } else {
140
- setNestedValue(rawOptions, key, [existing, value]);
187
+ setNestedValue(rawArgs, key, [existing, value]);
141
188
  }
142
189
  }
143
190
  } else {
144
- setNestedValue(rawOptions, key, Array.isArray(value) ? value : [value]);
191
+ setNestedValue(rawArgs, key, Array.isArray(value) ? value : [value]);
145
192
  }
146
193
  } else {
147
- setNestedValue(rawOptions, key, value);
194
+ setNestedValue(rawArgs, key, value);
148
195
  }
149
196
  }
150
197
 
151
- return { command: curCommand, rawOptions, args };
198
+ return { command: curCommand, rawArgs, args, unmatchedTerms };
152
199
  };
153
200
 
154
201
  /**
155
- * Validates raw options against the command's schema and applies preprocessing.
202
+ * Preprocesses raw arguments: applies env/config values and maps positional arguments.
203
+ * Also performs auto-coercion (string→number/boolean) and unknown arg detection.
156
204
  */
157
- const validateOptions = (
205
+ const buildCommandArgs = (
158
206
  command: AnyPadroneCommand,
159
- rawOptions: Record<string, unknown>,
207
+ rawArgs: Record<string, unknown>,
160
208
  args: string[],
161
- parseOptions?: { envData?: Record<string, unknown>; configData?: Record<string, unknown> },
162
- ) => {
163
- // Apply preprocessing (env and config bindings)
164
- const preprocessedOptions = preprocessOptions(rawOptions, {
165
- aliases: {}, // Already resolved aliases in parseCommand
166
- envData: parseOptions?.envData,
167
- configData: parseOptions?.configData,
209
+ context?: { stdinData?: Record<string, unknown>; envData?: Record<string, unknown>; configData?: Record<string, unknown> },
210
+ ): Record<string, unknown> => {
211
+ // Apply preprocessing (stdin, env, and config bindings)
212
+ let preprocessedArgs = preprocessArgs(rawArgs, {
213
+ flags: {}, // Already resolved in parseCommand
214
+ aliases: {}, // Already resolved in parseCommand
215
+ stdinData: context?.stdinData,
216
+ envData: context?.envData,
217
+ configData: context?.configData,
168
218
  });
169
219
 
170
220
  // Parse positional configuration
171
221
  const positionalConfig = command.meta?.positional ? parsePositionalConfig(command.meta.positional) : [];
172
222
 
173
- // Map positional arguments to their named options
223
+ // Map positional arguments to their named arguments
174
224
  if (positionalConfig.length > 0) {
175
225
  let argIndex = 0;
176
- for (const { name, variadic } of positionalConfig) {
226
+ for (let i = 0; i < positionalConfig.length; i++) {
227
+ const { name, variadic } = positionalConfig[i]!;
177
228
  if (argIndex >= args.length) break;
178
229
 
179
230
  if (variadic) {
180
231
  // Collect remaining args (but leave room for non-variadic args after)
181
- const remainingPositionals = positionalConfig.slice(positionalConfig.indexOf({ name, variadic }) + 1);
232
+ const remainingPositionals = positionalConfig.slice(i + 1);
182
233
  const nonVariadicAfter = remainingPositionals.filter((p) => !p.variadic).length;
183
234
  const variadicEnd = args.length - nonVariadicAfter;
184
- preprocessedOptions[name] = args.slice(argIndex, variadicEnd);
235
+ preprocessedArgs[name] = args.slice(argIndex, variadicEnd);
185
236
  argIndex = variadicEnd;
237
+ } else if (i === positionalConfig.length - 1 && args.length > argIndex + 1) {
238
+ // Last non-variadic positional: join all remaining tokens (e.g. `-- Hello world` → "Hello world")
239
+ preprocessedArgs[name] = args.slice(argIndex).join(' ');
240
+ argIndex = args.length;
186
241
  } else {
187
- preprocessedOptions[name] = args[argIndex];
242
+ preprocessedArgs[name] = args[argIndex];
188
243
  argIndex++;
189
244
  }
190
245
  }
191
246
  }
192
247
 
193
- const optionsParsed = command.options ? command.options['~standard'].validate(preprocessedOptions) : { value: preprocessedOptions };
248
+ // Auto-coerce CLI string values to match schema types (string→number, string→boolean)
249
+ if (command.argsSchema) {
250
+ preprocessedArgs = coerceArgs(preprocessedArgs, command.argsSchema);
251
+ }
252
+
253
+ return preprocessedArgs;
254
+ };
255
+
256
+ /**
257
+ * Detects unknown options in args that aren't defined in the schema.
258
+ * Returns unknown key info with suggestions, or empty array if schema is loose.
259
+ */
260
+ const checkUnknownArgs = (
261
+ command: AnyPadroneCommand,
262
+ preprocessedArgs: Record<string, unknown>,
263
+ ): { key: string; suggestion: string }[] => {
264
+ if (!command.argsSchema) return [];
265
+
266
+ const argsMeta = command.meta?.fields;
267
+ const { flags, aliases } = extractSchemaMetadata(command.argsSchema, argsMeta, command.meta?.autoAlias);
194
268
 
195
- if (optionsParsed instanceof Promise) {
196
- throw new Error('Async validation is not supported. Schema validate() must return a synchronous result.');
269
+ return detectUnknownArgs(preprocessedArgs, command.argsSchema, flags, aliases, suggestSimilar);
270
+ };
271
+
272
+ /**
273
+ * Validates preprocessed arguments against the command's schema.
274
+ * First checks for unknown args (strict by default), then runs schema validation.
275
+ * Returns sync or async result depending on the schema's validate method.
276
+ */
277
+ const validateCommandArgs = (command: AnyPadroneCommand, preprocessedArgs: Record<string, unknown>) => {
278
+ // Check for unknown args before schema validation (strict by default)
279
+ const unknownArgs = checkUnknownArgs(command, preprocessedArgs);
280
+ if (unknownArgs.length > 0) {
281
+ const issues: StandardSchemaV1.Issue[] = unknownArgs.map(({ key, suggestion }) => ({
282
+ path: [key],
283
+ message: suggestion ? `Unknown option: "${key}". ${suggestion}` : `Unknown option: "${key}"`,
284
+ }));
285
+ return { args: undefined, argsResult: { issues } as any };
197
286
  }
198
287
 
199
- // Return undefined for options when there's no schema and no meaningful options
200
- const hasOptions = command.options || Object.keys(preprocessedOptions).length > 0;
288
+ const argsParsed = command.argsSchema ? command.argsSchema['~standard'].validate(preprocessedArgs) : { value: preprocessedArgs };
201
289
 
202
- return {
203
- options: optionsParsed.issues ? undefined : hasOptions ? (optionsParsed.value as any) : undefined,
204
- optionsResult: optionsParsed as any,
205
- };
290
+ // Return undefined for args when there's no schema and no meaningful args
291
+ const hasArgs = command.argsSchema || Object.keys(preprocessedArgs).length > 0;
292
+
293
+ const buildResult = (parsed: StandardSchemaV1.Result<unknown>) => ({
294
+ args: parsed.issues ? undefined : hasArgs ? (parsed.value as any) : undefined,
295
+ argsResult: parsed as any,
296
+ });
297
+
298
+ return thenMaybe(argsParsed, buildResult);
299
+ };
300
+
301
+ /**
302
+ * Preprocesses and validates raw arguments against the command's schema.
303
+ * Returns sync or async result depending on the schema's validate method.
304
+ */
305
+ const validateArgs = (
306
+ command: AnyPadroneCommand,
307
+ rawArgs: Record<string, unknown>,
308
+ args: string[],
309
+ context?: { stdinData?: Record<string, unknown>; envData?: Record<string, unknown>; configData?: Record<string, unknown> },
310
+ ) => {
311
+ const preprocessedArgs = buildCommandArgs(command, rawArgs, args, context);
312
+ return validateCommandArgs(command, preprocessedArgs);
206
313
  };
207
314
 
208
- const parse: AnyPadroneProgram['parse'] = (input, parseOptions) => {
209
- const { command, rawOptions, args } = parseCommand(input);
315
+ const parse: AnyPadroneProgram['parse'] = (input) => {
316
+ const state: Record<string, unknown> = {};
210
317
 
211
- // Resolve env schema: command's own envSchema > inherited from parent/root
212
- const resolveEnvSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['envSchema'] => {
213
- if (cmd.envSchema !== undefined) return cmd.envSchema;
214
- if (cmd.parent) return resolveEnvSchema(cmd.parent);
215
- return undefined;
318
+ // Parse phase (with plugins)
319
+ const parseCtx: PluginParseContext = { input: input as string | undefined, command: existingCommand, state };
320
+ const coreParse = (): PluginParseResult => {
321
+ const { command, rawArgs, args } = parseCommand(parseCtx.input);
322
+ return { command, rawArgs, positionalArgs: args };
216
323
  };
217
- const envSchema = resolveEnvSchema(command);
218
-
219
- // Validate env vars against schema if provided
220
- let envData: Record<string, unknown> | undefined = parseOptions?.envData;
221
- if (envSchema && !envData) {
222
- const rawEnv = parseOptions?.env ?? (typeof process !== 'undefined' ? process.env : {});
223
- const envValidated = envSchema['~standard'].validate(rawEnv);
224
- if (envValidated instanceof Promise) {
225
- throw new Error('Async validation is not supported. Env schema validate() must return a synchronous result.');
226
- }
227
- // For env vars, we don't throw on validation errors - just use the transformed value if valid
228
- if (!envValidated.issues) {
229
- envData = envValidated.value as unknown as Record<string, unknown>;
230
- }
231
- }
232
324
 
233
- const { options, optionsResult } = validateOptions(command, rawOptions, args, {
234
- envData,
235
- configData: parseOptions?.configData,
236
- });
325
+ // Parse phase: root plugins only
326
+ const rootPlugins = existingCommand.plugins ?? [];
327
+ const parsedOrPromise = runPluginChain('parse', rootPlugins, parseCtx, coreParse);
237
328
 
238
- return {
239
- command: command as any,
240
- options,
241
- optionsResult,
329
+ const continueAfterParse = (parsed: PluginParseResult) => {
330
+ const { command } = parsed;
331
+
332
+ // Validate phase: collected from parent chain
333
+ const commandPlugins = collectPlugins(command);
334
+ const validateCtx: PluginValidateContext = {
335
+ command,
336
+ rawArgs: parsed.rawArgs,
337
+ positionalArgs: parsed.positionalArgs,
338
+ state,
339
+ };
340
+
341
+ const coreValidate = (): PluginValidateResult | Promise<PluginValidateResult> => {
342
+ // Resolve env schema: command's own envSchema > inherited from parent/root
343
+ const resolveEnvSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['envSchema'] => {
344
+ if (cmd.envSchema !== undefined) return cmd.envSchema;
345
+ if (cmd.parent) return resolveEnvSchema(cmd.parent);
346
+ return undefined;
347
+ };
348
+ const envSchema = resolveEnvSchema(command);
349
+
350
+ const readStdinForParse = (): Record<string, unknown> | Promise<Record<string, unknown>> => {
351
+ const stdinConfig = command.meta?.stdin;
352
+ if (!stdinConfig) return {};
353
+
354
+ const { field, as } = parseStdinConfig(stdinConfig);
355
+
356
+ // Skip if the field was already provided via CLI flags
357
+ if (field in validateCtx.rawArgs && validateCtx.rawArgs[field] !== undefined) return {};
358
+
359
+ const runtime = getCommandRuntime(existingCommand);
360
+ const stdin = resolveStdin(runtime as any);
361
+ if (!stdin) return {};
362
+
363
+ if (as === 'lines') {
364
+ return (async () => {
365
+ const lines: string[] = [];
366
+ for await (const line of stdin.lines()) {
367
+ lines.push(line);
368
+ }
369
+ return { [field]: lines };
370
+ })();
371
+ }
372
+ return stdin.text().then((text) => (text ? { [field]: text } : {}));
373
+ };
374
+
375
+ const finalize = (
376
+ envData: Record<string, unknown> | undefined,
377
+ stdinData: Record<string, unknown> | undefined,
378
+ ): PluginValidateResult | Promise<PluginValidateResult> => {
379
+ const validated = validateArgs(command, validateCtx.rawArgs, validateCtx.positionalArgs, { stdinData, envData });
380
+ return thenMaybe(validated, (v) => v as PluginValidateResult);
381
+ };
382
+
383
+ let envData: Record<string, unknown> | undefined;
384
+ const afterEnv = (envResult: Record<string, unknown> | undefined) => {
385
+ const stdinDataOrPromise = readStdinForParse();
386
+ return thenMaybe(stdinDataOrPromise, (stdinData) => {
387
+ const hasStdinData = Object.keys(stdinData).length > 0;
388
+ return finalize(envResult, hasStdinData ? stdinData : undefined);
389
+ });
390
+ };
391
+
392
+ if (envSchema) {
393
+ const runtime = getCommandRuntime(existingCommand);
394
+ const rawEnv = runtime.env();
395
+ const envValidated = envSchema['~standard'].validate(rawEnv);
396
+
397
+ return thenMaybe(envValidated, (result) => {
398
+ if (!result.issues) {
399
+ envData = result.value as unknown as Record<string, unknown>;
400
+ }
401
+ return afterEnv(envData);
402
+ });
403
+ }
404
+
405
+ return afterEnv(envData);
406
+ };
407
+
408
+ const validatedOrPromise = runPluginChain('validate', commandPlugins, validateCtx, coreValidate);
409
+
410
+ return warnIfUnexpectedAsync(
411
+ thenMaybe(validatedOrPromise, (v) => ({
412
+ command: command as any,
413
+ args: v.args,
414
+ argsResult: v.argsResult,
415
+ })),
416
+ command,
417
+ );
242
418
  };
419
+
420
+ return thenMaybe(parsedOrPromise, continueAfterParse) as any;
243
421
  };
244
422
 
245
- const stringify: AnyPadroneProgram['stringify'] = (command = '' as any, options) => {
423
+ const stringify: AnyPadroneProgram['stringify'] = (command = '' as any, args) => {
246
424
  const commandObj = typeof command === 'string' ? findCommandByName(command, existingCommand.commands) : (command as AnyPadroneCommand);
247
- if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
425
+ if (!commandObj) throw new RoutingError(`Command "${command ?? ''}" not found`);
248
426
 
249
427
  const parts: string[] = [];
250
428
 
251
429
  if (commandObj.path) parts.push(commandObj.path);
252
430
 
253
- // Get positional config to determine which options are positional
431
+ // Get positional config to determine which args are positional
254
432
  const positionalConfig = commandObj.meta?.positional ? parsePositionalConfig(commandObj.meta.positional) : [];
255
433
  const positionalNames = new Set(positionalConfig.map((p) => p.name));
256
434
 
257
435
  // Output positional arguments first in order
258
- if (options && typeof options === 'object') {
436
+ if (args && typeof args === 'object') {
259
437
  for (const { name, variadic } of positionalConfig) {
260
- const value = (options as Record<string, unknown>)[name];
438
+ const value = (args as Record<string, unknown>)[name];
261
439
  if (value === undefined) continue;
262
440
 
263
441
  if (variadic && Array.isArray(value)) {
@@ -281,7 +459,7 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
281
459
  if (value) parts.push(`--${key}`);
282
460
  else parts.push(`--no-${key}`);
283
461
  } else if (Array.isArray(value)) {
284
- // Handle variadic options - output each value separately
462
+ // Handle variadic arguments - output each value separately
285
463
  for (const v of value) {
286
464
  const vStr = String(v);
287
465
  if (vStr.includes(' ')) parts.push(`--${key}="${vStr}"`);
@@ -300,8 +478,8 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
300
478
  }
301
479
  };
302
480
 
303
- // Output remaining options (non-positional)
304
- for (const [key, value] of Object.entries(options)) {
481
+ // Output remaining arguments (non-positional)
482
+ for (const [key, value] of Object.entries(args)) {
305
483
  if (value === undefined || positionalNames.has(key)) continue;
306
484
  stringifyValue(key, value);
307
485
  }
@@ -322,31 +500,32 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
322
500
  ):
323
501
  | { type: 'help'; command?: AnyPadroneCommand; detail?: DetailLevel; format?: FormatLevel }
324
502
  | { type: 'version' }
325
- | { type: 'completion'; shell?: ShellType }
503
+ | { type: 'completion'; shell?: ShellType; setup?: boolean }
504
+ | { type: 'repl'; scope?: string }
326
505
  | null => {
327
506
  if (!input) return null;
328
507
 
329
508
  const parts = parseCliInputToParts(input);
330
509
  const terms = parts.filter((p) => p.type === 'term').map((p) => p.value);
331
- const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
510
+ const args = parts.filter((p) => p.type === 'named' || p.type === 'alias');
332
511
 
333
512
  // Helper to check if a key array matches a single key string
334
513
  const keyIs = (key: string[], name: string) => key.length === 1 && key[0] === name;
335
514
 
336
515
  // Check for --help, -h flags (these take precedence over commands)
337
- const hasHelpFlag = opts.some((p) => (p.type === 'option' && keyIs(p.key, 'help')) || (p.type === 'alias' && keyIs(p.key, 'h')));
516
+ const hasHelpFlag = args.some((p) => (p.type === 'named' && keyIs(p.key, 'help')) || (p.type === 'alias' && keyIs(p.key, 'h')));
338
517
 
339
518
  // Extract detail level from --detail=<level> or -d <level>
340
519
  const getDetailLevel = (): DetailLevel | undefined => {
341
- for (const opt of opts) {
342
- if (opt.type === 'option' && keyIs(opt.key, 'detail') && typeof opt.value === 'string') {
343
- if (opt.value === 'minimal' || opt.value === 'standard' || opt.value === 'full') {
344
- return opt.value;
520
+ for (const arg of args) {
521
+ if (arg.type === 'named' && keyIs(arg.key, 'detail') && typeof arg.value === 'string') {
522
+ if (arg.value === 'minimal' || arg.value === 'standard' || arg.value === 'full') {
523
+ return arg.value;
345
524
  }
346
525
  }
347
- if (opt.type === 'alias' && keyIs(opt.key, 'd') && typeof opt.value === 'string') {
348
- if (opt.value === 'minimal' || opt.value === 'standard' || opt.value === 'full') {
349
- return opt.value;
526
+ if (arg.type === 'alias' && keyIs(arg.key, 'd') && typeof arg.value === 'string') {
527
+ if (arg.value === 'minimal' || arg.value === 'standard' || arg.value === 'full') {
528
+ return arg.value;
350
529
  }
351
530
  }
352
531
  }
@@ -357,15 +536,15 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
357
536
  // Extract format from --format=<value> or -f <value>
358
537
  const getFormat = (): FormatLevel | undefined => {
359
538
  const validFormats: FormatLevel[] = ['text', 'ansi', 'console', 'markdown', 'html', 'json', 'auto'];
360
- for (const opt of opts) {
361
- if (opt.type === 'option' && keyIs(opt.key, 'format') && typeof opt.value === 'string') {
362
- if (validFormats.includes(opt.value as FormatLevel)) {
363
- return opt.value as FormatLevel;
539
+ for (const arg of args) {
540
+ if (arg.type === 'named' && keyIs(arg.key, 'format') && typeof arg.value === 'string') {
541
+ if (validFormats.includes(arg.value as FormatLevel)) {
542
+ return arg.value as FormatLevel;
364
543
  }
365
544
  }
366
- if (opt.type === 'alias' && keyIs(opt.key, 'f') && typeof opt.value === 'string') {
367
- if (validFormats.includes(opt.value as FormatLevel)) {
368
- return opt.value as FormatLevel;
545
+ if (arg.type === 'alias' && keyIs(arg.key, 'f') && typeof arg.value === 'string') {
546
+ if (validFormats.includes(arg.value as FormatLevel)) {
547
+ return arg.value as FormatLevel;
369
548
  }
370
549
  }
371
550
  }
@@ -374,8 +553,8 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
374
553
  const format = getFormat();
375
554
 
376
555
  // Check for --version, -v, -V flags
377
- const hasVersionFlag = opts.some(
378
- (p) => (p.type === 'option' && keyIs(p.key, 'version')) || (p.type === 'alias' && (keyIs(p.key, 'v') || keyIs(p.key, 'V'))),
556
+ const hasVersionFlag = args.some(
557
+ (p) => (p.type === 'named' && keyIs(p.key, 'version')) || (p.type === 'alias' && (keyIs(p.key, 'v') || keyIs(p.key, 'V'))),
379
558
  );
380
559
 
381
560
  // If the first term is the program name, skip it
@@ -388,12 +567,30 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
388
567
  const userCompletionCommand = findCommandByName('completion', existingCommand.commands);
389
568
 
390
569
  // Check for 'help' command (only if user hasn't defined one)
570
+ // Supports both 'help <command>' and '<command> help' forms
391
571
  if (!userHelpCommand && normalizedTerms[0] === 'help') {
392
572
  // help <command> - get help for specific command
393
573
  const commandName = normalizedTerms.slice(1).join(' ');
394
574
  const targetCommand = commandName ? findCommandByName(commandName, existingCommand.commands) : undefined;
395
575
  return { type: 'help', command: targetCommand, detail, format };
396
576
  }
577
+ if (!userHelpCommand && normalizedTerms.length > 0 && normalizedTerms[normalizedTerms.length - 1] === 'help') {
578
+ // <command> help - get help for specific command (trailing form)
579
+ const commandTerms = normalizedTerms.slice(0, -1);
580
+ // Walk the command tree to find the deepest matching command
581
+ let targetCommand: AnyPadroneCommand | undefined;
582
+ let current = existingCommand;
583
+ for (const term of commandTerms) {
584
+ const found = findCommandByName(term, current.commands);
585
+ if (found) {
586
+ targetCommand = found;
587
+ current = found;
588
+ } else {
589
+ break;
590
+ }
591
+ }
592
+ return { type: 'help', command: targetCommand, detail, format };
593
+ }
397
594
 
398
595
  // Check for 'version' command (only if user hasn't defined one)
399
596
  if (!userVersionCommand && normalizedTerms[0] === 'version') {
@@ -405,7 +602,8 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
405
602
  const shellArg = normalizedTerms[1] as ShellType | undefined;
406
603
  const validShells: ShellType[] = ['bash', 'zsh', 'fish', 'powershell'];
407
604
  const shell = shellArg && validShells.includes(shellArg) ? shellArg : undefined;
408
- return { type: 'completion', shell };
605
+ const setup = args.some((p) => p.type === 'named' && keyIs(p.key, 'setup'));
606
+ return { type: 'completion', shell, setup };
409
607
  }
410
608
 
411
609
  // Handle help flag - find the command being requested
@@ -422,6 +620,13 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
422
620
  return { type: 'version' };
423
621
  }
424
622
 
623
+ // Check for --repl flag
624
+ const hasReplFlag = args.some((p) => p.type === 'named' && keyIs(p.key, 'repl'));
625
+ if (hasReplFlag) {
626
+ const scope = normalizedTerms.length > 0 ? normalizedTerms.join(' ') : undefined;
627
+ return { type: 'repl', scope };
628
+ }
629
+
425
630
  return null;
426
631
  };
427
632
 
@@ -432,194 +637,642 @@ export function createPadroneBuilder<TBuilder extends PadroneProgram = PadronePr
432
637
  if (!input) return undefined;
433
638
 
434
639
  const parts = parseCliInputToParts(input);
435
- const opts = parts.filter((p) => p.type === 'option' || p.type === 'alias');
640
+ const args = parts.filter((p) => p.type === 'named' || p.type === 'alias');
436
641
 
437
- for (const opt of opts) {
438
- if (opt.type === 'option' && opt.key.length === 1 && opt.key[0] === 'config' && typeof opt.value === 'string') {
439
- return opt.value;
642
+ for (const arg of args) {
643
+ if (arg.type === 'named' && arg.key.length === 1 && arg.key[0] === 'config' && typeof arg.value === 'string') {
644
+ return arg.value;
440
645
  }
441
- if (opt.type === 'alias' && opt.key.length === 1 && opt.key[0] === 'c' && typeof opt.value === 'string') {
442
- return opt.value;
646
+ if (arg.type === 'alias' && arg.key.length === 1 && arg.key[0] === 'c' && typeof arg.value === 'string') {
647
+ return arg.value;
443
648
  }
444
649
  }
445
650
  return undefined;
446
651
  };
447
652
 
448
- const cli: AnyPadroneProgram['cli'] = (input, cliOptions) => {
449
- // Resolve input from process.argv if not provided
450
- const resolvedInput = input ?? (typeof process !== 'undefined' ? (process.argv.slice(2).join(' ') as any) : undefined);
653
+ /**
654
+ * Core execution logic shared by eval() and cli().
655
+ * errorMode controls validation error behavior:
656
+ * - 'soft': return result with issues (eval behavior)
657
+ * - 'hard': print error + help and throw (cli-without-input behavior)
658
+ */
659
+ const execCommand = (resolvedInput: string | undefined, evalOptions?: PadroneEvalPreferences, errorMode: 'soft' | 'hard' = 'soft') => {
660
+ const baseRuntime = getCommandRuntime(existingCommand);
661
+ const runtime = evalOptions?.runtime
662
+ ? Object.assign({}, baseRuntime, Object.fromEntries(Object.entries(evalOptions.runtime).filter(([, v]) => v !== undefined)))
663
+ : baseRuntime;
451
664
 
452
- // Check for built-in help/version/completion commands and flags
665
+ // Check for built-in help/version/completion commands and flags (bypass plugins)
453
666
  const builtin = checkBuiltinCommands(resolvedInput);
454
667
 
455
668
  if (builtin) {
456
669
  if (builtin.type === 'help') {
457
670
  const helpText = generateHelp(existingCommand, builtin.command ?? existingCommand, {
458
671
  detail: builtin.detail,
459
- format: builtin.format,
672
+ format: builtin.format ?? runtime.format,
460
673
  });
461
- console.log(helpText);
674
+ runtime.output(helpText);
462
675
  return {
463
676
  command: existingCommand,
464
677
  args: undefined,
465
- options: undefined,
466
678
  result: helpText,
467
679
  } as any;
468
680
  }
469
681
 
470
682
  if (builtin.type === 'version') {
471
683
  const version = getVersion(existingCommand.version);
472
- console.log(version);
684
+ runtime.output(version);
473
685
  return {
474
686
  command: existingCommand,
475
- options: undefined,
687
+ args: undefined,
476
688
  result: version,
477
689
  } as any;
478
690
  }
479
691
 
480
692
  if (builtin.type === 'completion') {
481
- const completionScript = generateCompletionOutput(existingCommand, builtin.shell);
482
- console.log(completionScript);
483
- return {
484
- command: existingCommand,
485
- options: undefined,
486
- result: completionScript,
487
- } as any;
693
+ return import('./completion.ts').then(({ detectShell, generateCompletionOutput, setupCompletions }) => {
694
+ if (builtin.setup) {
695
+ const shell = builtin.shell ?? detectShell();
696
+ if (!shell) {
697
+ throw new Error('Could not detect shell. Specify one: completion bash --setup');
698
+ }
699
+ const result = setupCompletions(existingCommand.name, shell);
700
+ const message = `${result.updated ? 'Updated' : 'Added'} ${existingCommand.name} completions in ${result.file}`;
701
+ runtime.output(message);
702
+ return {
703
+ command: existingCommand,
704
+ args: undefined,
705
+ result: message,
706
+ };
707
+ }
708
+ const completionScript = generateCompletionOutput(existingCommand, builtin.shell);
709
+ runtime.output(completionScript);
710
+ return {
711
+ command: existingCommand,
712
+ args: undefined,
713
+ result: completionScript,
714
+ };
715
+ }) as any;
488
716
  }
489
717
  }
490
718
 
491
- // Parse the command first (without validating options)
492
- const { command, rawOptions, args } = parseCommand(resolvedInput);
719
+ // Shared plugin state for this execution
720
+ const state: Record<string, unknown> = {};
721
+ const rootPlugins = existingCommand.plugins ?? [];
722
+
723
+ const runPipeline = () => {
724
+ // ── Phase 1: Parse ──────────────────────────────────────────────────
725
+ const parseCtx: PluginParseContext = { input: resolvedInput, command: existingCommand, state };
726
+
727
+ const coreParse = (): PluginParseResult => {
728
+ const { command, rawArgs, args, unmatchedTerms } = parseCommand(parseCtx.input);
729
+
730
+ // Default help: command with no action → show its help when there's nothing to execute.
731
+ const hasSubcommands = command.commands && command.commands.length > 0;
732
+ const hasSchema = command.argsSchema != null;
733
+ if (!command.action && (hasSubcommands || !hasSchema) && unmatchedTerms.length === 0) {
734
+ const helpText = generateHelp(existingCommand, command, { format: runtime.format });
735
+ runtime.output(helpText);
736
+ return {
737
+ command: command,
738
+ rawArgs: { '~help': helpText } as Record<string, unknown>,
739
+ positionalArgs: [],
740
+ };
741
+ }
493
742
 
494
- // Extract config file path from --config or -c flag
495
- const configPath = extractConfigPath(resolvedInput);
743
+ // Reject unmatched terms when the matched command doesn't accept positional args
744
+ if (unmatchedTerms.length > 0) {
745
+ const hasPositionalConfig = command.meta?.positional && command.meta.positional.length > 0;
746
+ if (!hasPositionalConfig) {
747
+ const isRootCommand = command === existingCommand;
748
+ const commandDisplayName = command.name || command.aliases?.[0] || command.path || '(default)';
749
+
750
+ // Collect candidate names for fuzzy suggestion
751
+ const candidateNames: string[] = [];
752
+ if (isRootCommand && existingCommand.commands) {
753
+ for (const cmd of existingCommand.commands) {
754
+ if (!cmd.hidden) {
755
+ candidateNames.push(cmd.name);
756
+ if (cmd.aliases) candidateNames.push(...cmd.aliases);
757
+ }
758
+ }
759
+ } else if (command.commands) {
760
+ for (const cmd of command.commands) {
761
+ if (!cmd.hidden) {
762
+ candidateNames.push(cmd.name);
763
+ if (cmd.aliases) candidateNames.push(...cmd.aliases);
764
+ }
765
+ }
766
+ }
496
767
 
497
- // Resolve config files: command's own configFiles > inherited from parent/root
498
- // undefined = inherit, empty array = no config files (explicit opt-out)
499
- const resolveConfigFiles = (cmd: AnyPadroneCommand): string[] | undefined => {
500
- if (cmd.configFiles !== undefined) return cmd.configFiles;
501
- if (cmd.parent) return resolveConfigFiles(cmd.parent);
502
- return undefined;
503
- };
504
- const effectiveConfigFiles = resolveConfigFiles(command);
768
+ const suggestion = suggestSimilar(unmatchedTerms[0]!, candidateNames);
769
+ const suggestions = suggestion ? [suggestion] : [];
770
+ const baseMsg = isRootCommand
771
+ ? `Unknown command: ${unmatchedTerms[0]}`
772
+ : `Unexpected arguments for '${commandDisplayName}': ${unmatchedTerms.join(' ')}`;
773
+ const errorMsg = suggestions.length ? `${baseMsg}\n\n ${suggestions[0]}` : baseMsg;
774
+
775
+ if (errorMode === 'hard') {
776
+ runtime.error(errorMsg);
777
+ // When we have a suggestion, show a compact single-line "Available commands" note
778
+ // instead of the full help text to avoid overwhelming the user
779
+ if (suggestions.length > 0) {
780
+ const targetCmd = isRootCommand ? existingCommand : command;
781
+ const visibleCommands = (targetCmd.commands ?? []).filter((c) => !c.hidden && c.name);
782
+ if (visibleCommands.length > 0) {
783
+ const cmdList = visibleCommands.map((c) => c.name).join(', ');
784
+ runtime.output(`\nAvailable commands: ${cmdList}`);
785
+ }
786
+ } else {
787
+ const helpText = generateHelp(existingCommand, isRootCommand ? existingCommand : command, { format: runtime.format });
788
+ runtime.error(helpText);
789
+ }
790
+ throw new RoutingError(errorMsg, { suggestions, command: command.path || command.name });
791
+ }
505
792
 
506
- // Resolve config schema: command's own config > inherited from parent/root
507
- const resolveConfigSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['config'] => {
508
- if (cmd.config !== undefined) return cmd.config;
509
- if (cmd.parent) return resolveConfigSchema(cmd.parent);
510
- return undefined;
511
- };
512
- const configSchema = resolveConfigSchema(command);
793
+ // Soft mode: throw too this is a routing error, not a validation issue
794
+ throw new RoutingError(errorMsg, { suggestions, command: command.path || command.name });
795
+ }
796
+ }
513
797
 
514
- // Resolve env schema: command's own envSchema > inherited from parent/root
515
- const resolveEnvSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['envSchema'] => {
516
- if (cmd.envSchema !== undefined) return cmd.envSchema;
517
- if (cmd.parent) return resolveEnvSchema(cmd.parent);
518
- return undefined;
798
+ return { command, rawArgs, positionalArgs: args };
799
+ };
800
+
801
+ // Parse phase: root plugins only
802
+ const parsedOrPromise = runPluginChain('parse', rootPlugins, parseCtx, coreParse);
803
+
804
+ // ── Phases 2 & 3 chained after parse ────────────────────────────────
805
+ const continueAfterParse = (parsed: PluginParseResult) => {
806
+ const { command } = parsed;
807
+ // Validate/execute: collected from parent chain
808
+ const commandPlugins = collectPlugins(command);
809
+
810
+ // Short-circuit: parse returned a help result
811
+ if (parsed.rawArgs['~help']) {
812
+ return {
813
+ command: command,
814
+ args: undefined,
815
+ result: parsed.rawArgs['~help'],
816
+ } as any;
817
+ }
818
+
819
+ // ── Phase 2: Validate ───────────────────────────────────────────
820
+ const validateCtx: PluginValidateContext = {
821
+ command,
822
+ rawArgs: parsed.rawArgs,
823
+ positionalArgs: parsed.positionalArgs,
824
+ state,
825
+ };
826
+
827
+ const coreValidate = (): PluginValidateResult | Promise<PluginValidateResult> => {
828
+ // Determine interactivity
829
+ let flagInteractive: boolean | undefined;
830
+ if (hasInteractiveConfig(command.meta)) {
831
+ if (validateCtx.rawArgs.interactive !== undefined) {
832
+ flagInteractive = validateCtx.rawArgs.interactive !== false && validateCtx.rawArgs.interactive !== 'false';
833
+ delete validateCtx.rawArgs.interactive;
834
+ }
835
+ if (validateCtx.rawArgs.i !== undefined) {
836
+ flagInteractive = validateCtx.rawArgs.i !== false && validateCtx.rawArgs.i !== 'false';
837
+ delete validateCtx.rawArgs.i;
838
+ }
839
+ }
840
+
841
+ const runtimeDefault: boolean | undefined =
842
+ runtime.interactive === 'forced' ? true : runtime.interactive === 'disabled' ? false : undefined;
843
+ const effectiveInteractive: boolean | undefined = flagInteractive ?? evalOptions?.interactive ?? runtimeDefault;
844
+ // Suppress interactive prompts when the command reads stdin — prompts share stdin which is already consumed/closed.
845
+ const commandUsesStdin = !!command.meta?.stdin;
846
+ const stdinIsPiped =
847
+ commandUsesStdin && (runtime.stdin ? !runtime.stdin.isTTY : typeof process !== 'undefined' && process.stdin?.isTTY !== true);
848
+ const interactivitySuppressed =
849
+ runtime.interactive === 'unsupported' || effectiveInteractive === false || (stdinIsPiped && effectiveInteractive !== true);
850
+ const forceInteractive = !interactivitySuppressed && effectiveInteractive === true;
851
+
852
+ // Extract config file path from --config or -c flag
853
+ const configPath = extractConfigPath(parseCtx.input);
854
+
855
+ // Resolve config files: command's own configFiles > inherited from parent/root
856
+ const resolveConfigFiles = (cmd: AnyPadroneCommand): string[] | undefined => {
857
+ if (cmd.configFiles !== undefined) return cmd.configFiles;
858
+ if (cmd.parent) return resolveConfigFiles(cmd.parent);
859
+ return undefined;
860
+ };
861
+ const effectiveConfigFiles = resolveConfigFiles(command);
862
+
863
+ // Resolve config schema: command's own configSchema > inherited from parent/root
864
+ const resolveConfigSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['configSchema'] => {
865
+ if (cmd.configSchema !== undefined) return cmd.configSchema;
866
+ if (cmd.parent) return resolveConfigSchema(cmd.parent);
867
+ return undefined;
868
+ };
869
+ const configSchema = resolveConfigSchema(command);
870
+
871
+ // Resolve env schema: command's own envSchema > inherited from parent/root
872
+ const resolveEnvSchema = (cmd: AnyPadroneCommand): AnyPadroneCommand['envSchema'] => {
873
+ if (cmd.envSchema !== undefined) return cmd.envSchema;
874
+ if (cmd.parent) return resolveEnvSchema(cmd.parent);
875
+ return undefined;
876
+ };
877
+ const envSchema = resolveEnvSchema(command);
878
+
879
+ // Determine config data: explicit --config flag > auto-discovered config
880
+ let configData: Record<string, unknown> | undefined;
881
+ if (configPath) {
882
+ configData = runtime.loadConfigFile(configPath);
883
+ } else if (effectiveConfigFiles?.length) {
884
+ const foundConfigPath = runtime.findFile(effectiveConfigFiles);
885
+ if (foundConfigPath) {
886
+ configData = runtime.loadConfigFile(foundConfigPath) ?? configData;
887
+ }
888
+ }
889
+
890
+ // Step 1: Validate config data against schema if provided
891
+ const validateConfig = (): Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined> => {
892
+ if (configData && configSchema) {
893
+ const configValidated = configSchema['~standard'].validate(configData);
894
+ return thenMaybe(configValidated, (result) => {
895
+ if (result.issues) {
896
+ const issueMessages = result.issues
897
+ .map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`)
898
+ .join('\n');
899
+ throw new ConfigError(`Invalid config file:\n${issueMessages}`, {
900
+ command: command.path || command.name,
901
+ });
902
+ }
903
+ return result.value as unknown as Record<string, unknown>;
904
+ });
905
+ }
906
+ return configData;
907
+ };
908
+
909
+ // Step 2: Validate env vars
910
+ const validateEnv = (): Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined> => {
911
+ let envData: Record<string, unknown> | undefined;
912
+ if (envSchema) {
913
+ const rawEnv = runtime.env();
914
+ const envValidated = envSchema['~standard'].validate(rawEnv);
915
+ return thenMaybe(envValidated, (result) => {
916
+ if (!result.issues) {
917
+ envData = result.value as unknown as Record<string, unknown>;
918
+ }
919
+ return envData;
920
+ });
921
+ }
922
+ return envData;
923
+ };
924
+
925
+ // Step 3: Read stdin if configured and not already provided via CLI
926
+ const readStdin = (): Record<string, unknown> | Promise<Record<string, unknown>> => {
927
+ const stdinConfig = command.meta?.stdin;
928
+ if (!stdinConfig) return {};
929
+
930
+ const { field, as } = parseStdinConfig(stdinConfig);
931
+
932
+ // Skip if the field was already provided via CLI flags (highest precedence)
933
+ if (field in validateCtx.rawArgs && validateCtx.rawArgs[field] !== undefined) return {};
934
+
935
+ // Resolve stdin: use runtime's custom stdin, or default if piped.
936
+ // Returns undefined when stdin is a TTY or unavailable.
937
+ const stdin = resolveStdin(runtime as any);
938
+ if (!stdin) return {};
939
+
940
+ if (as === 'lines') {
941
+ return (async () => {
942
+ const lines: string[] = [];
943
+ for await (const line of stdin.lines()) {
944
+ lines.push(line);
945
+ }
946
+ return { [field]: lines };
947
+ })();
948
+ }
949
+
950
+ // Default: read all as text
951
+ return stdin.text().then((text) => {
952
+ // Don't inject empty stdin
953
+ if (!text) return {};
954
+ return { [field]: text };
955
+ });
956
+ };
957
+
958
+ // Step 4: Preprocess, interactive prompt, and validate
959
+ const finalizeValidation = (
960
+ validatedConfigData: Record<string, unknown> | undefined,
961
+ envData: Record<string, unknown> | undefined,
962
+ stdinData: Record<string, unknown> | undefined,
963
+ ): PluginValidateResult | Promise<PluginValidateResult> => {
964
+ const preprocessedArgs = buildCommandArgs(command, validateCtx.rawArgs, validateCtx.positionalArgs, {
965
+ stdinData,
966
+ envData,
967
+ configData: validatedConfigData,
968
+ });
969
+
970
+ // Early validation: check provided args for errors before prompting.
971
+ // This catches unknown options and invalid values on explicitly-provided fields
972
+ // so the user isn't asked interactive questions for a doomed command.
973
+ const willPrompt = !interactivitySuppressed && runtime.prompt && hasInteractiveConfig(command.meta);
974
+ if (willPrompt) {
975
+ const unknowns = checkUnknownArgs(command, preprocessedArgs);
976
+ if (unknowns.length > 0) {
977
+ const issues: StandardSchemaV1.Issue[] = unknowns.map(({ key, suggestion }) => ({
978
+ path: [key],
979
+ message: suggestion ? `Unknown option: "${key}". ${suggestion}` : `Unknown option: "${key}"`,
980
+ }));
981
+ return { args: undefined, argsResult: { issues } as any };
982
+ }
983
+
984
+ // Run schema validation on what we have so far (before prompting fills missing fields).
985
+ // Only fail on issues for fields the user explicitly provided — skip issues for
986
+ // missing/undefined fields since those will be filled by interactive prompts.
987
+ if (command.argsSchema) {
988
+ const providedKeys = new Set(Object.keys(preprocessedArgs).filter((k) => preprocessedArgs[k] !== undefined));
989
+ const earlyCheck = command.argsSchema['~standard'].validate(preprocessedArgs);
990
+ const checkForProvidedFieldErrors = (result: StandardSchemaV1.Result<unknown>): PluginValidateResult | undefined => {
991
+ if (!result.issues) return undefined;
992
+ // Only keep issues whose path starts with a key the user actually provided
993
+ const providedFieldIssues = result.issues.filter((issue) => {
994
+ const rootKey = issue.path?.[0];
995
+ return rootKey !== undefined && providedKeys.has(String(rootKey));
996
+ });
997
+ if (providedFieldIssues.length > 0) {
998
+ return { args: undefined, argsResult: { issues: providedFieldIssues } as any };
999
+ }
1000
+ return undefined;
1001
+ };
1002
+ const earlyResult = thenMaybe(earlyCheck, (result) => {
1003
+ const errors = checkForProvidedFieldErrors(result);
1004
+ if (errors) return errors;
1005
+ return undefined;
1006
+ });
1007
+ if (earlyResult instanceof Promise) {
1008
+ return earlyResult.then((err) => {
1009
+ if (err) return err;
1010
+ return continueWithPrompt(preprocessedArgs);
1011
+ });
1012
+ }
1013
+ if (earlyResult) return earlyResult;
1014
+ }
1015
+ }
1016
+
1017
+ return continueWithPrompt(preprocessedArgs);
1018
+ };
1019
+
1020
+ const continueWithPrompt = (preprocessedArgs: Record<string, unknown>): PluginValidateResult | Promise<PluginValidateResult> => {
1021
+ const willPrompt = !interactivitySuppressed && runtime.prompt && hasInteractiveConfig(command.meta);
1022
+ const afterInteractive = willPrompt
1023
+ ? promptInteractiveFields(preprocessedArgs, command, runtime, forceInteractive || undefined)
1024
+ : preprocessedArgs;
1025
+
1026
+ return thenMaybe(afterInteractive, (filledArgs) => {
1027
+ const validated = validateCommandArgs(command, filledArgs);
1028
+ return thenMaybe(validated, (v) => v as PluginValidateResult);
1029
+ });
1030
+ };
1031
+
1032
+ // Chain: config → env → stdin → validate
1033
+ const validatedConfig = validateConfig();
1034
+ return thenMaybe(validatedConfig, (cfgData) => {
1035
+ const validatedEnv = validateEnv();
1036
+ return thenMaybe(validatedEnv, (envData) => {
1037
+ const stdinDataOrPromise = readStdin();
1038
+ return thenMaybe(stdinDataOrPromise, (stdinData) => {
1039
+ const hasStdinData = Object.keys(stdinData).length > 0;
1040
+ return finalizeValidation(cfgData, envData, hasStdinData ? stdinData : undefined);
1041
+ });
1042
+ });
1043
+ });
1044
+ };
1045
+
1046
+ const validatedOrPromise = runPluginChain('validate', commandPlugins, validateCtx, coreValidate);
1047
+
1048
+ // ── Phase 3: Execute (or handle validation errors) ──────────────
1049
+ const continueAfterValidate = (v: PluginValidateResult) => {
1050
+ // Handle validation failures
1051
+ if (v.argsResult?.issues) {
1052
+ // Collect known option names for fuzzy suggestion on unknown keys
1053
+ let knownOptions: string[] | undefined;
1054
+ const getKnownOptions = () => {
1055
+ if (knownOptions) return knownOptions;
1056
+ knownOptions = [];
1057
+ if (command.argsSchema) {
1058
+ try {
1059
+ const js = command.argsSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
1060
+ if (js.type === 'object' && js.properties) knownOptions = Object.keys(js.properties);
1061
+ } catch {
1062
+ /* ignore */
1063
+ }
1064
+ }
1065
+ return knownOptions;
1066
+ };
1067
+
1068
+ const issueMessages = v.argsResult.issues
1069
+ .map((i: StandardSchemaV1.Issue) => {
1070
+ const base = ` - ${i.path?.join('.') || 'root'}: ${i.message}`;
1071
+ // Try to suggest for unrecognized key errors
1072
+ const issueAny = i as any;
1073
+ const unrecognizedKeys: string[] | undefined =
1074
+ issueAny.keys ?? i.message?.match(/[Uu]nrecognized key(?:s)?[^"]*"([^"]+)"/)?.slice(1);
1075
+ if (unrecognizedKeys?.length) {
1076
+ const hints = unrecognizedKeys.map((k: string) => suggestSimilar(k, getKnownOptions())).filter(Boolean);
1077
+ if (hints.length) return `${base}\n ${hints.join('\n ')}`;
1078
+ }
1079
+ return base;
1080
+ })
1081
+ .join('\n');
1082
+
1083
+ if (errorMode === 'hard') {
1084
+ const helpText = generateHelp(existingCommand, command, { format: runtime.format });
1085
+ runtime.error(`Validation error:\n${issueMessages}`);
1086
+ runtime.error(helpText);
1087
+ throw new ValidationError(`Validation error:\n${issueMessages}`, v.argsResult.issues as any, {
1088
+ suggestions: v.argsResult.issues.flatMap((i: any) => {
1089
+ const keys: string[] | undefined = i.keys ?? i.message?.match(/[Uu]nrecognized key(?:s)?[^"]*"([^"]+)"/)?.slice(1);
1090
+ if (!keys?.length) return [];
1091
+ return keys.map((k: string) => suggestSimilar(k, getKnownOptions())).filter(Boolean);
1092
+ }),
1093
+ command: command.path || command.name,
1094
+ });
1095
+ }
1096
+
1097
+ // Soft mode: return result with issues, skip the action
1098
+ return {
1099
+ command: command as any,
1100
+ args: undefined,
1101
+ argsResult: v.argsResult,
1102
+ result: undefined,
1103
+ };
1104
+ }
1105
+
1106
+ const executeCtx: PluginExecuteContext = {
1107
+ command,
1108
+ args: v.args,
1109
+ state,
1110
+ };
1111
+
1112
+ const coreExecute = (): PluginExecuteResult => {
1113
+ const handler = command.action ?? noop;
1114
+ const ctx = evalOptions?.runtime ? { ...createActionContext(command), runtime } : createActionContext(command);
1115
+ const result = handler(executeCtx.args as any, ctx);
1116
+ return { result };
1117
+ };
1118
+
1119
+ const executedOrPromise = runPluginChain('execute', commandPlugins, executeCtx, coreExecute);
1120
+
1121
+ return thenMaybe(executedOrPromise, (e) => {
1122
+ const commandResult = {
1123
+ command: command as any,
1124
+ args: v.args,
1125
+ argsResult: v.argsResult,
1126
+ result: e.result,
1127
+ };
1128
+
1129
+ if (command.autoOutput ?? evalOptions?.autoOutput ?? true) {
1130
+ const outputOrPromise = outputValue(e.result, runtime.output);
1131
+ if (outputOrPromise instanceof Promise) {
1132
+ return outputOrPromise.then(() => commandResult);
1133
+ }
1134
+ }
1135
+
1136
+ return commandResult;
1137
+ });
1138
+ };
1139
+
1140
+ return warnIfUnexpectedAsync(thenMaybe(validatedOrPromise, continueAfterValidate), command) as any;
1141
+ };
1142
+
1143
+ return thenMaybe(parsedOrPromise, continueAfterParse) as any;
519
1144
  };
520
- const envSchema = resolveEnvSchema(command);
521
-
522
- // Determine config data: explicit --config flag > auto-discovered config > provided configData
523
- let configData = cliOptions?.configData;
524
- if (configPath) {
525
- // Explicit config path takes precedence
526
- configData = loadConfigFile(configPath);
527
- } else if (effectiveConfigFiles?.length) {
528
- // Search for config files if configFiles is configured (inherited or own)
529
- const foundConfigPath = findConfigFile(effectiveConfigFiles);
530
- if (foundConfigPath) {
531
- configData = loadConfigFile(foundConfigPath) ?? configData;
1145
+
1146
+ return wrapWithLifecycle(rootPlugins, existingCommand, state, resolvedInput, runPipeline, (result) => ({
1147
+ command: existingCommand,
1148
+ args: undefined,
1149
+ argsResult: undefined,
1150
+ result,
1151
+ })) as any;
1152
+ };
1153
+
1154
+ const evalCommand: AnyPadroneProgram['eval'] = (input, evalOptions) => {
1155
+ return execCommand(input as string, evalOptions, 'soft');
1156
+ };
1157
+
1158
+ /**
1159
+ * Collects plugins from the command's parent chain (root → ... → target).
1160
+ * Root/program plugins come first (outermost), target command's plugins last (innermost).
1161
+ *
1162
+ * The `programRoot` parameter provides the current program command, because
1163
+ * subcommands' `.parent` references may be stale (builders are immutable — each
1164
+ * method returns a new builder, so a subcommand's parent was captured before
1165
+ * `.use()` was called on the program). We substitute `programRoot` for the
1166
+ * top of the chain to ensure program-level plugins are always included.
1167
+ */
1168
+ const collectPlugins = (cmd: AnyPadroneCommand): PadronePlugin[] => {
1169
+ const chain: PadronePlugin[][] = [];
1170
+ let current: AnyPadroneCommand | undefined = cmd;
1171
+ while (current) {
1172
+ // If this is the root (no parent), use existingCommand's plugins instead
1173
+ // to pick up plugins added after subcommands were defined.
1174
+ if (!current.parent) {
1175
+ if (existingCommand.plugins?.length) chain.unshift(existingCommand.plugins);
1176
+ } else {
1177
+ if (current.plugins?.length) chain.unshift(current.plugins);
532
1178
  }
1179
+ current = current.parent;
533
1180
  }
1181
+ return chain.flat();
1182
+ };
534
1183
 
535
- // Validate config data against schema if provided
536
- if (configData && configSchema) {
537
- const configValidated = configSchema['~standard'].validate(configData);
538
- if (configValidated instanceof Promise) {
539
- throw new Error('Async validation is not supported. Config schema validate() must return a synchronous result.');
540
- }
541
- if (configValidated.issues) {
542
- const issueMessages = configValidated.issues.map((i) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`).join('\n');
543
- throw new Error(`Invalid config file:\n${issueMessages}`);
1184
+ // Forward declaration assigned by the repl method in the return object, used by cli() for --repl.
1185
+ let replFn: (options?: PadroneReplPreferences) => AsyncIterable<any>;
1186
+ const replActiveRef = { value: false };
1187
+
1188
+ const cli: AnyPadroneProgram['cli'] = (cliOptions) => {
1189
+ const runtime = getCommandRuntime(existingCommand);
1190
+ const resolvedInput = (runtime.argv().join(' ') || undefined) as string | undefined;
1191
+
1192
+ // Check for --repl flag before normal execution
1193
+ if (cliOptions?.repl !== false) {
1194
+ const builtin = checkBuiltinCommands(resolvedInput);
1195
+ if (builtin?.type === 'repl') {
1196
+ const replPrefs: PadroneReplPreferences = {
1197
+ ...(typeof cliOptions?.repl === 'object' ? cliOptions.repl : {}),
1198
+ scope: builtin.scope,
1199
+ autoOutput: (typeof cliOptions?.repl === 'object' ? cliOptions.repl.autoOutput : undefined) ?? cliOptions?.autoOutput,
1200
+ };
1201
+ const drainRepl = async () => {
1202
+ for await (const _ of replFn(replPrefs)) {
1203
+ // Results are handled by command actions
1204
+ }
1205
+ return { command: existingCommand, args: undefined, result: undefined } as any;
1206
+ };
1207
+ return drainRepl() as any;
544
1208
  }
545
- configData = configValidated.value as unknown as Record<string, unknown>;
546
1209
  }
547
1210
 
548
- // Validate env vars against schema if provided
549
- let envData: Record<string, unknown> | undefined = cliOptions?.envData;
550
- if (envSchema) {
551
- const rawEnv = cliOptions?.env ?? (typeof process !== 'undefined' ? process.env : {});
552
- const envValidated = envSchema['~standard'].validate(rawEnv);
553
- if (envValidated instanceof Promise) {
554
- throw new Error('Async validation is not supported. Env schema validate() must return a synchronous result.');
555
- }
556
- // For env vars, we don't throw on validation errors - just use the transformed value if valid
557
- // This is because the schema may use .optional() or .default() for missing env vars
558
- if (!envValidated.issues) {
559
- envData = envValidated.value as unknown as Record<string, unknown>;
1211
+ // Start background update check (non-blocking)
1212
+ let updateCheckPromise: Promise<(() => void) | undefined> | undefined;
1213
+ if (existingCommand.updateCheck) {
1214
+ // Respect --no-update-check flag
1215
+ const hasNoUpdateCheckFlag =
1216
+ resolvedInput &&
1217
+ parseCliInputToParts(resolvedInput).some((p) => p.type === 'named' && p.key.length === 1 && p.key[0] === 'no-update-check');
1218
+ if (!hasNoUpdateCheckFlag) {
1219
+ const currentVersion = getVersion(existingCommand.version);
1220
+ updateCheckPromise = import('./update-check.ts').then(({ createUpdateChecker }) =>
1221
+ createUpdateChecker(existingCommand.name, currentVersion, existingCommand.updateCheck!, runtime),
1222
+ );
560
1223
  }
561
1224
  }
562
1225
 
563
- // Validate options with env and config data
564
- const { options, optionsResult } = validateOptions(command, rawOptions, args, {
565
- envData,
566
- configData,
567
- });
1226
+ const result = execCommand(resolvedInput, cliOptions, 'hard');
568
1227
 
569
- // Handle validation failures
570
- if (optionsResult?.issues) {
571
- const issueMessages = optionsResult.issues
572
- .map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`)
573
- .join('\n');
574
-
575
- if (input === undefined) {
576
- // Called without explicit input (using process.argv): print error + help and throw
577
- const helpText = generateHelp(existingCommand, command, { format: 'text' });
578
- console.error(`Validation error:\n${issueMessages}`);
579
- console.error(helpText);
580
- throw new Error(`Validation error:\n${issueMessages}`);
1228
+ // Show update notification after command output
1229
+ if (updateCheckPromise) {
1230
+ if (result instanceof Promise) {
1231
+ return result.then(async (r) => {
1232
+ const showUpdateNotification = await updateCheckPromise;
1233
+ showUpdateNotification?.();
1234
+ return r;
1235
+ }) as any;
581
1236
  }
582
-
583
- // Called with explicit input: return result with issues, skip the action
584
- return {
585
- command: command as any,
586
- options: undefined,
587
- optionsResult,
588
- result: undefined,
589
- } as any;
1237
+ // For sync results, schedule notification for next tick (non-blocking)
1238
+ updateCheckPromise.then((show) => show?.());
590
1239
  }
591
1240
 
592
- const res = run(command, options) as any;
593
- return {
594
- ...res,
595
- optionsResult,
596
- };
1241
+ return result;
597
1242
  };
598
1243
 
599
- const run: AnyPadroneProgram['run'] = (command, options) => {
1244
+ const run: AnyPadroneProgram['run'] = (command, args) => {
600
1245
  const commandObj = typeof command === 'string' ? findCommandByName(command, existingCommand.commands) : (command as AnyPadroneCommand);
601
- if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
602
- if (!commandObj.handler) throw new Error(`Command "${commandObj.path}" has no handler`);
1246
+ if (!commandObj) throw new RoutingError(`Command "${command ?? ''}" not found`);
1247
+ if (!commandObj.action) throw new RoutingError(`Command "${commandObj.path}" has no action`, { command: commandObj.path });
603
1248
 
604
- const result = commandObj.handler(options as any);
1249
+ const state: Record<string, unknown> = {};
1250
+ const executeCtx: PluginExecuteContext = { command: commandObj, args, state };
605
1251
 
606
- return {
607
- command: commandObj as any,
608
- options: options as any,
609
- result,
1252
+ const coreExecute = (): PluginExecuteResult => {
1253
+ const result = commandObj.action!(executeCtx.args as any, createActionContext(commandObj));
1254
+ return { result };
610
1255
  };
1256
+
1257
+ const commandObjPlugins = collectPlugins(commandObj);
1258
+ const executedOrPromise = runPluginChain('execute', commandObjPlugins, executeCtx, coreExecute);
1259
+
1260
+ const toResult = (e: PluginExecuteResult) => ({
1261
+ command: commandObj as any,
1262
+ args: args as any,
1263
+ result: e.result,
1264
+ });
1265
+
1266
+ if (executedOrPromise instanceof Promise) {
1267
+ return executedOrPromise.then(toResult) as any;
1268
+ }
1269
+ return toResult(executedOrPromise);
611
1270
  };
612
1271
 
613
1272
  const tool: AnyPadroneProgram['tool'] = () => {
614
- const helpText = generateHelp(existingCommand, undefined, { format: 'text', detail: 'full' });
615
-
616
- const description = `\n
617
- This is a CLI tool created with Padrone. You can run any of the defined commands described in the help text below. If you need assistance, refer to the documentation or use the help command.
1273
+ const helpText = generateHelp(existingCommand, undefined, { format: 'text' });
618
1274
 
619
- <help_output>
620
- ${helpText}
621
- </help_output>
622
- `;
1275
+ const description = `Run a command. Pass the full command string including arguments. Use "help <command>" for detailed usage.\n\n${helpText}`;
623
1276
 
624
1277
  return {
625
1278
  type: 'function',
@@ -627,7 +1280,7 @@ ${helpText}
627
1280
  strict: true,
628
1281
  title: existingCommand.description,
629
1282
  description,
630
- inputExamples: [{ input: { command: '<command> [args...] [options...]' } }],
1283
+ inputExamples: [{ input: { command: '<command> [positionals...] [arguments...]' } }],
631
1284
  inputSchema: {
632
1285
  [Symbol.for('vercel.ai.schema') as keyof Schema & symbol]: true,
633
1286
  jsonSchema: {
@@ -642,71 +1295,155 @@ ${helpText}
642
1295
  return { success: false, error: new Error('Expected an object with command property as string.') };
643
1296
  },
644
1297
  } satisfies Schema<{ command: string }> as Schema<{ command: string }>,
645
- needsApproval: (input) => {
646
- const { command, options } = parse(input.command);
647
- if (typeof command.needsApproval === 'function') return command.needsApproval(options);
648
- return !!command.needsApproval;
1298
+ needsApproval: async (input) => {
1299
+ const parsed = await parse(input.command);
1300
+ if (typeof parsed.command.needsApproval === 'function') return parsed.command.needsApproval(parsed.args);
1301
+ return !!parsed.command.needsApproval;
649
1302
  },
650
- execute: (input) => {
651
- return cli(input.command).result;
1303
+ execute: async (input) => {
1304
+ const output: string[] = [];
1305
+ const errors: string[] = [];
1306
+ const result = await evalCommand(input.command, {
1307
+ autoOutput: false,
1308
+ runtime: {
1309
+ output: (...args) => output.push(args.map(String).join(' ')),
1310
+ error: (text) => errors.push(text),
1311
+ interactive: 'unsupported',
1312
+ format: 'text',
1313
+ },
1314
+ });
1315
+ return { result: result.result, logs: output.join('\n'), error: errors.join('\n') };
652
1316
  },
653
1317
  };
654
1318
  };
655
1319
 
656
- return {
1320
+ const builder = {
657
1321
  configure(config) {
658
1322
  return createPadroneBuilder({ ...existingCommand, ...config }) as any;
659
1323
  },
660
- arguments(options, meta) {
661
- // If options is a function, call it with parent's options as base
662
- const resolvedOptions = typeof options === 'function' ? options(existingCommand.options as any) : options;
663
- return createPadroneBuilder({ ...existingCommand, options: resolvedOptions, meta }) as any;
1324
+ runtime(runtimeConfig) {
1325
+ return createPadroneBuilder({ ...existingCommand, runtime: { ...existingCommand.runtime, ...runtimeConfig } }) as any;
1326
+ },
1327
+ async() {
1328
+ return createPadroneBuilder({ ...existingCommand, isAsync: true }) as any;
1329
+ },
1330
+ arguments(schema, meta) {
1331
+ // If schema is a function, call it with parent's arguments as base
1332
+ const resolvedArgs = typeof schema === 'function' ? schema(existingCommand.argsSchema as any) : schema;
1333
+ const isAsync = existingCommand.isAsync || isAsyncBranded(resolvedArgs) || hasInteractiveConfig(meta);
1334
+ return createPadroneBuilder({ ...existingCommand, argsSchema: resolvedArgs, meta, isAsync }) as any;
664
1335
  },
665
1336
  configFile(file, schema) {
666
1337
  const configFiles = file === undefined ? undefined : Array.isArray(file) ? file : [file];
667
- const resolvedConfig = typeof schema === 'function' ? schema(existingCommand.options) : (schema ?? existingCommand.options);
668
- return createPadroneBuilder({ ...existingCommand, configFiles, config: resolvedConfig as any }) as any;
1338
+ const resolvedConfig = typeof schema === 'function' ? schema(existingCommand.argsSchema) : (schema ?? existingCommand.argsSchema);
1339
+ const isAsync = existingCommand.isAsync || isAsyncBranded(resolvedConfig);
1340
+ return createPadroneBuilder({ ...existingCommand, configFiles, configSchema: resolvedConfig as any, isAsync }) as any;
669
1341
  },
670
1342
  env(schema) {
671
- const resolvedEnv = typeof schema === 'function' ? schema(existingCommand.options) : schema;
672
- return createPadroneBuilder({ ...existingCommand, envSchema: resolvedEnv as any }) as any;
1343
+ const resolvedEnv = typeof schema === 'function' ? schema(existingCommand.argsSchema) : schema;
1344
+ const isAsync = existingCommand.isAsync || isAsyncBranded(resolvedEnv);
1345
+ return createPadroneBuilder({ ...existingCommand, envSchema: resolvedEnv as any, isAsync }) as any;
673
1346
  },
674
1347
  action(handler = noop) {
675
- return createPadroneBuilder({ ...existingCommand, handler }) as any;
1348
+ const baseHandler = existingCommand.action ?? noop;
1349
+ return createPadroneBuilder({
1350
+ ...existingCommand,
1351
+ action: (args: any, ctx: any) => (handler as any)(args, ctx, baseHandler),
1352
+ }) as any;
676
1353
  },
677
1354
  wrap(config) {
678
- const handler = createWrapHandler(config, existingCommand.options as any, existingCommand.meta?.positional);
679
- return createPadroneBuilder({ ...existingCommand, handler }) as any;
1355
+ const handler = createWrapHandler(config, existingCommand.argsSchema as any, existingCommand.meta?.positional);
1356
+ return createPadroneBuilder({ ...existingCommand, action: handler }) as any;
680
1357
  },
681
1358
  command(nameOrNames, builderFn) {
682
1359
  // Extract name and aliases from the input
683
1360
  const name = Array.isArray(nameOrNames) ? nameOrNames[0] : nameOrNames;
684
1361
  const aliases = Array.isArray(nameOrNames) && nameOrNames.length > 1 ? (nameOrNames.slice(1) as string[]) : undefined;
685
1362
 
686
- const initialCommand = {
687
- name,
688
- path: existingCommand.path ? `${existingCommand.path} ${name}` : name,
689
- aliases,
690
- parent: existingCommand,
691
- '~types': {} as any,
692
- } satisfies PadroneCommand;
1363
+ // Check if a command with this name already exists (override case)
1364
+ const existingSubcommand = existingCommand.commands?.find((c) => c.name === name) as AnyPadroneCommand | undefined;
1365
+
1366
+ const initialCommand: AnyPadroneCommand = existingSubcommand
1367
+ ? { ...existingSubcommand, aliases: aliases ?? existingSubcommand.aliases, parent: existingCommand }
1368
+ : ({
1369
+ name,
1370
+ path: existingCommand.path ? `${existingCommand.path} ${name}` : name,
1371
+ aliases,
1372
+ parent: existingCommand,
1373
+ '~types': {} as any,
1374
+ } satisfies PadroneCommand);
1375
+
693
1376
  const builder = createPadroneBuilder(initialCommand);
694
1377
 
695
1378
  const commandObj =
696
1379
  ((builderFn?.(builder as any) as unknown as typeof builder)?.[commandSymbol] as AnyPadroneCommand) ?? initialCommand;
697
- return createPadroneBuilder({ ...existingCommand, commands: [...(existingCommand.commands || []), commandObj] }) as any;
1380
+
1381
+ // Merge subcommands when overriding: existing subcommands that aren't replaced are kept
1382
+ const mergedCommandObj = existingSubcommand ? mergeCommands(existingSubcommand, commandObj) : commandObj;
1383
+
1384
+ // Replace existing command or append new one
1385
+ const commands = existingCommand.commands || [];
1386
+ const existingIndex = commands.findIndex((c) => c.name === name);
1387
+ const updatedCommands =
1388
+ existingIndex >= 0
1389
+ ? [...commands.slice(0, existingIndex), mergedCommandObj, ...commands.slice(existingIndex + 1)]
1390
+ : [...commands, mergedCommandObj];
1391
+
1392
+ return createPadroneBuilder({ ...existingCommand, commands: updatedCommands }) as any;
1393
+ },
1394
+
1395
+ mount(nameOrNames, program) {
1396
+ const name = Array.isArray(nameOrNames) ? nameOrNames[0] : nameOrNames;
1397
+ const aliases = Array.isArray(nameOrNames) && nameOrNames.length > 1 ? (nameOrNames.slice(1) as string[]) : undefined;
1398
+
1399
+ // Extract the underlying command from the program
1400
+ const programCommand = (program as any)[commandSymbol] as AnyPadroneCommand | undefined;
1401
+ if (!programCommand) throw new RoutingError('Cannot mount: not a valid Padrone program');
1402
+
1403
+ // Re-path the command tree under the new name
1404
+ const remounted = repathCommandTree(programCommand, name, existingCommand.path || '', existingCommand);
1405
+ remounted.aliases = aliases;
1406
+
1407
+ // Merge with existing command if one with the same name exists
1408
+ const existingSubcommand = existingCommand.commands?.find((c) => c.name === name) as AnyPadroneCommand | undefined;
1409
+ const mergedCommandObj = existingSubcommand ? mergeCommands(existingSubcommand, remounted) : remounted;
1410
+
1411
+ const commands = existingCommand.commands || [];
1412
+ const existingIndex = commands.findIndex((c) => c.name === name);
1413
+ const updatedCommands =
1414
+ existingIndex >= 0
1415
+ ? [...commands.slice(0, existingIndex), mergedCommandObj, ...commands.slice(existingIndex + 1)]
1416
+ : [...commands, mergedCommandObj];
1417
+
1418
+ return createPadroneBuilder({ ...existingCommand, commands: updatedCommands }) as any;
1419
+ },
1420
+
1421
+ use(plugin: PadronePlugin) {
1422
+ return createPadroneBuilder({
1423
+ ...existingCommand,
1424
+ plugins: [...(existingCommand.plugins ?? []), plugin],
1425
+ }) as any;
1426
+ },
1427
+
1428
+ updateCheck(config = {}) {
1429
+ return createPadroneBuilder({ ...existingCommand, updateCheck: config }) as any;
698
1430
  },
699
1431
 
700
1432
  run,
701
1433
  find,
702
1434
  parse,
703
1435
  stringify,
1436
+ eval: evalCommand,
704
1437
  cli,
705
1438
  tool,
706
1439
 
1440
+ repl: (replFn = (options?: PadroneReplPreferences) => {
1441
+ return createReplIterator({ existingCommand, evalCommand, replActiveRef }, options);
1442
+ }),
1443
+
707
1444
  api() {
708
1445
  function buildApi(command: AnyPadroneCommand) {
709
- const runCommand = ((options) => run(command, options).result) as PadroneAPI<AnyPadroneCommand>;
1446
+ const runCommand = ((args) => run(command, args).result) as PadroneAPI<AnyPadroneCommand>;
710
1447
  if (!command.commands) return runCommand;
711
1448
  for (const cmd of command.commands) runCommand[cmd.name] = buildApi(cmd);
712
1449
  return runCommand;
@@ -715,17 +1452,19 @@ ${helpText}
715
1452
  return buildApi(existingCommand);
716
1453
  },
717
1454
 
718
- help(command, options) {
1455
+ help(command, prefs) {
719
1456
  const commandObj = !command
720
1457
  ? existingCommand
721
1458
  : typeof command === 'string'
722
1459
  ? findCommandByName(command, existingCommand.commands)
723
1460
  : (command as AnyPadroneCommand);
724
- if (!commandObj) throw new Error(`Command "${command ?? ''}" not found`);
725
- return generateHelp(existingCommand, commandObj, options);
1461
+ if (!commandObj) throw new RoutingError(`Command "${command ?? ''}" not found`);
1462
+ const runtime = getCommandRuntime(existingCommand);
1463
+ return generateHelp(existingCommand, commandObj, { ...prefs, format: prefs?.format ?? runtime.format });
726
1464
  },
727
1465
 
728
- completion(shell) {
1466
+ async completion(shell) {
1467
+ const { generateCompletionOutput } = await import('./completion.ts');
729
1468
  return generateCompletionOutput(existingCommand, shell as ShellType | undefined);
730
1469
  },
731
1470
 
@@ -733,4 +1472,5 @@ ${helpText}
733
1472
 
734
1473
  [commandSymbol]: existingCommand,
735
1474
  } satisfies AnyPadroneProgram & { [commandSymbol]: AnyPadroneCommand } as any;
1475
+ return builder as TBuilder & { [commandSymbol]: AnyPadroneCommand };
736
1476
  }