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