padrone 1.3.0 → 1.5.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 (82) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +105 -284
  3. package/dist/{args-DFEI7_G_.mjs → args-D5PNDyNu.mjs} +46 -21
  4. package/dist/args-D5PNDyNu.mjs.map +1 -0
  5. package/dist/chunk-CjcI7cDX.mjs +15 -0
  6. package/dist/codegen/index.d.mts +28 -3
  7. package/dist/codegen/index.d.mts.map +1 -1
  8. package/dist/codegen/index.mjs +169 -19
  9. package/dist/codegen/index.mjs.map +1 -1
  10. package/dist/command-utils-B1D-HqCd.mjs +1117 -0
  11. package/dist/command-utils-B1D-HqCd.mjs.map +1 -0
  12. package/dist/completion.d.mts +1 -1
  13. package/dist/completion.d.mts.map +1 -1
  14. package/dist/completion.mjs +77 -29
  15. package/dist/completion.mjs.map +1 -1
  16. package/dist/docs/index.d.mts +22 -2
  17. package/dist/docs/index.d.mts.map +1 -1
  18. package/dist/docs/index.mjs +94 -7
  19. package/dist/docs/index.mjs.map +1 -1
  20. package/dist/errors-BiVrBgi6.mjs +114 -0
  21. package/dist/errors-BiVrBgi6.mjs.map +1 -0
  22. package/dist/{formatter-XroimS3Q.d.mts → formatter-DtHzbP22.d.mts} +35 -5
  23. package/dist/formatter-DtHzbP22.d.mts.map +1 -0
  24. package/dist/help-bbmu9-qd.mjs +735 -0
  25. package/dist/help-bbmu9-qd.mjs.map +1 -0
  26. package/dist/index.d.mts +32 -3
  27. package/dist/index.d.mts.map +1 -1
  28. package/dist/index.mjs +495 -267
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/mcp-mLWIdUIu.mjs +379 -0
  31. package/dist/mcp-mLWIdUIu.mjs.map +1 -0
  32. package/dist/serve-B0u43DK7.mjs +404 -0
  33. package/dist/serve-B0u43DK7.mjs.map +1 -0
  34. package/dist/stream-BcC146Ud.mjs +56 -0
  35. package/dist/stream-BcC146Ud.mjs.map +1 -0
  36. package/dist/test.d.mts +1 -1
  37. package/dist/test.mjs +4 -15
  38. package/dist/test.mjs.map +1 -1
  39. package/dist/{types-BS7RP5Ls.d.mts → types-Ch8Mk6Qb.d.mts} +311 -63
  40. package/dist/types-Ch8Mk6Qb.d.mts.map +1 -0
  41. package/dist/{update-check-EbNDkzyV.mjs → update-check-CFX1FV3v.mjs} +2 -2
  42. package/dist/{update-check-EbNDkzyV.mjs.map → update-check-CFX1FV3v.mjs.map} +1 -1
  43. package/dist/zod.d.mts +32 -0
  44. package/dist/zod.d.mts.map +1 -0
  45. package/dist/zod.mjs +50 -0
  46. package/dist/zod.mjs.map +1 -0
  47. package/package.json +10 -2
  48. package/src/args.ts +76 -44
  49. package/src/cli/docs.ts +1 -7
  50. package/src/cli/doctor.ts +195 -10
  51. package/src/cli/index.ts +1 -1
  52. package/src/cli/init.ts +2 -3
  53. package/src/cli/link.ts +2 -2
  54. package/src/codegen/discovery.ts +80 -28
  55. package/src/codegen/index.ts +2 -1
  56. package/src/codegen/parsers/bash.ts +179 -0
  57. package/src/codegen/schema-to-code.ts +2 -1
  58. package/src/colorizer.ts +126 -13
  59. package/src/command-utils.ts +401 -23
  60. package/src/completion.ts +120 -47
  61. package/src/create.ts +483 -130
  62. package/src/docs/index.ts +122 -8
  63. package/src/formatter.ts +173 -125
  64. package/src/help.ts +46 -12
  65. package/src/index.ts +29 -1
  66. package/src/interactive.ts +45 -4
  67. package/src/mcp.ts +390 -0
  68. package/src/repl-loop.ts +16 -3
  69. package/src/runtime.ts +195 -2
  70. package/src/serve.ts +442 -0
  71. package/src/stream.ts +75 -0
  72. package/src/test.ts +7 -16
  73. package/src/type-utils.ts +28 -4
  74. package/src/types.ts +212 -30
  75. package/src/wrap.ts +23 -25
  76. package/src/zod.ts +50 -0
  77. package/dist/args-DFEI7_G_.mjs.map +0 -1
  78. package/dist/chunk-y_GBKt04.mjs +0 -5
  79. package/dist/formatter-XroimS3Q.d.mts.map +0 -1
  80. package/dist/help-CgGP7hQU.mjs +0 -1229
  81. package/dist/help-CgGP7hQU.mjs.map +0 -1
  82. package/dist/types-BS7RP5Ls.d.mts.map +0 -1
@@ -1,5 +1,6 @@
1
- import { extractSchemaMetadata } from './args.ts';
2
- import { type ResolvedPadroneRuntime, resolveRuntime } from './runtime.ts';
1
+ import { extractSchemaMetadata, JSON_SCHEMA_OPTS } from './args.ts';
2
+ import { type PadroneProgressIndicator, type ResolvedPadroneRuntime, resolveRuntime } from './runtime.ts';
3
+ import type { Thenable } from './type-utils.ts';
3
4
  import type {
4
5
  AnyPadroneCommand,
5
6
  PadronePlugin,
@@ -10,6 +11,42 @@ import type {
10
11
  PluginStartContext,
11
12
  } from './types.ts';
12
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Lazy command resolution
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const lazyResolver = Symbol('lazyResolver');
19
+
20
+ /** Resolves a lazy command in place by calling its stored resolver. No-op if already resolved. */
21
+ export function resolveCommand(cmd: AnyPadroneCommand): AnyPadroneCommand {
22
+ const resolver = (cmd as any)[lazyResolver];
23
+ if (resolver) {
24
+ delete (cmd as any)[lazyResolver];
25
+ resolver(cmd);
26
+ }
27
+ return cmd;
28
+ }
29
+
30
+ /** Recursively resolves a command and all its descendants. */
31
+ export function resolveAllCommands(cmd: AnyPadroneCommand): void {
32
+ resolveCommand(cmd);
33
+ if (cmd.commands) {
34
+ for (const sub of cmd.commands) resolveAllCommands(sub);
35
+ }
36
+ }
37
+
38
+ /** Checks whether a value is a Padrone program/builder. */
39
+ export function isPadroneProgram(value: unknown): value is object {
40
+ return !!value && typeof value === 'object' && commandSymbol in value;
41
+ }
42
+
43
+ /** Extracts the underlying command from a program/builder and resolves the full command tree. */
44
+ export function getCommand(program: object): AnyPadroneCommand {
45
+ const cmd = commandSymbol in program ? ((program as any)[commandSymbol] as AnyPadroneCommand) : (program as AnyPadroneCommand);
46
+ resolveAllCommands(cmd);
47
+ return cmd;
48
+ }
49
+
13
50
  /**
14
51
  * Brands a schema as async, signaling that its `validate()` may return a Promise.
15
52
  * When an async-branded schema is passed to `.arguments()`, `.configFile()`, or `.env()`,
@@ -45,6 +82,7 @@ export const configKeys = [
45
82
  'version',
46
83
  'deprecated',
47
84
  'hidden',
85
+ 'mutation',
48
86
  'needsApproval',
49
87
  'autoOutput',
50
88
  'updateCheck',
@@ -57,6 +95,8 @@ export const configKeys = [
57
95
  * - Subcommands are recursively merged by name.
58
96
  */
59
97
  export function mergeCommands(existing: AnyPadroneCommand, override: AnyPadroneCommand): AnyPadroneCommand {
98
+ resolveCommand(existing);
99
+ resolveCommand(override);
60
100
  const merged: AnyPadroneCommand = { ...existing };
61
101
 
62
102
  // Merge config fields
@@ -75,6 +115,7 @@ export function mergeCommands(existing: AnyPadroneCommand, override: AnyPadroneC
75
115
  if (override.runtime !== existing.runtime) merged.runtime = override.runtime;
76
116
  if (override.plugins !== existing.plugins) merged.plugins = override.plugins;
77
117
  if (override.aliases !== existing.aliases) merged.aliases = override.aliases;
118
+ if (override.progress !== existing.progress) merged.progress = override.progress;
78
119
 
79
120
  // Recursively merge subcommands by name
80
121
  if (override.commands) {
@@ -103,6 +144,63 @@ export function thenMaybe<T, U>(value: T | Promise<T>, fn: (v: T) => U | Promise
103
144
  return fn(value);
104
145
  }
105
146
 
147
+ /**
148
+ * Makes a sync result object thenable by adding `.then()`, `.catch()`, and `.finally()` methods.
149
+ * If the value is already a Promise, returns it as-is.
150
+ * This allows users to write `await program.cli()` or `program.cli().then(...)` regardless of sync/async.
151
+ *
152
+ * The `.then()` resolves with a plain copy (without thenable methods) to avoid infinite
153
+ * recursive unwrapping by the Promise resolution algorithm.
154
+ */
155
+ export function makeThenable<T>(value: T | Promise<T>): Thenable<T> {
156
+ if (value instanceof Promise) return value as any;
157
+ if (value !== null && typeof value === 'object' && !('then' in value)) {
158
+ const toPlain = () => {
159
+ const plain = { ...value } as any;
160
+ delete plain.then;
161
+ delete plain.catch;
162
+ delete plain.finally;
163
+ return plain as T;
164
+ };
165
+ // biome-ignore lint/suspicious/noThenProperty: intentional thenable shim for sync results
166
+ (value as any).then = (onfulfilled?: (v: T) => any, onrejected?: (reason: any) => any) => {
167
+ try {
168
+ const result = onfulfilled ? onfulfilled(toPlain()) : toPlain();
169
+ return Promise.resolve(result);
170
+ } catch (err) {
171
+ if (onrejected) return Promise.resolve(onrejected(err));
172
+ return Promise.reject(err);
173
+ }
174
+ };
175
+ (value as any).catch = (onrejected?: (reason: any) => any) => (value as any).then(undefined, onrejected);
176
+ (value as any).finally = (onfinally?: () => void) =>
177
+ (value as any).then(
178
+ (v: any) => {
179
+ onfinally?.();
180
+ return v;
181
+ },
182
+ (err: any) => {
183
+ onfinally?.();
184
+ throw err;
185
+ },
186
+ );
187
+ }
188
+ return value as any;
189
+ }
190
+
191
+ /**
192
+ * Wraps a Promise to include a `drain()` method at the top level.
193
+ * This allows `await promise.drain()` without first awaiting the promise.
194
+ * Since cli/eval never reject, this just delegates to the resolved result's `drain()`.
195
+ */
196
+ export function withPromiseDrain<T extends Promise<any>>(promise: T): T & { drain: () => Promise<any> } {
197
+ (promise as any).drain = async () => {
198
+ const resolved = await promise;
199
+ return resolved.drain();
200
+ };
201
+ return promise as any;
202
+ }
203
+
106
204
  export function isIterator(value: unknown): value is Iterator<unknown> {
107
205
  return typeof value === 'object' && value !== null && Symbol.iterator in value && typeof (value as any)[Symbol.iterator] === 'function';
108
206
  }
@@ -157,6 +255,95 @@ export function outputValue(value: unknown, output: (...args: unknown[]) => void
157
255
  output(value);
158
256
  }
159
257
 
258
+ /**
259
+ * Resolves a result value by unwrapping Promises and collecting iterables into arrays.
260
+ * This is the runtime counterpart of the `Drained<T>` type.
261
+ */
262
+ export async function drainValue(value: unknown): Promise<unknown> {
263
+ // Unwrap promises first
264
+ if (value instanceof Promise) {
265
+ return drainValue(await value);
266
+ }
267
+
268
+ // Async iterator — collect into array
269
+ if (isAsyncIterator(value)) {
270
+ const items: unknown[] = [];
271
+ const iter = (value as any)[Symbol.asyncIterator]();
272
+ while (true) {
273
+ const { done, value: item } = await iter.next();
274
+ if (done) break;
275
+ items.push(item);
276
+ }
277
+ return items;
278
+ }
279
+
280
+ // Sync iterator (but not string/array)
281
+ if (typeof value !== 'string' && !Array.isArray(value) && isIterator(value)) {
282
+ const items: unknown[] = [];
283
+ const iter = (value as any)[Symbol.iterator]();
284
+ while (true) {
285
+ const { done, value: item } = iter.next();
286
+ if (done) break;
287
+ items.push(item);
288
+ }
289
+ return items;
290
+ }
291
+
292
+ return value;
293
+ }
294
+
295
+ /**
296
+ * Attaches a `drain()` method to a command result object.
297
+ * If the result has an `error` field, `drain()` returns `{ error }`.
298
+ * Otherwise, resolves the result (unwrapping Promises, collecting iterables), catches errors,
299
+ * and returns a discriminated union `{ value } | { error }` that never throws.
300
+ */
301
+ export function withDrain<T extends Record<string, unknown>>(obj: T): T & { drain: () => Promise<any> } {
302
+ (obj as any).drain = async () => {
303
+ if ('error' in obj && obj.error !== undefined) {
304
+ return { error: obj.error };
305
+ }
306
+ try {
307
+ const value = await drainValue(obj.result);
308
+ return { value };
309
+ } catch (err) {
310
+ return { error: err };
311
+ }
312
+ };
313
+ return obj as any;
314
+ }
315
+
316
+ /**
317
+ * Creates an error command result with a `drain()` that returns the error.
318
+ */
319
+ export function errorResult(error: unknown, partial?: { command?: unknown; args?: unknown; argsResult?: unknown }) {
320
+ return withDrain({
321
+ error,
322
+ result: undefined,
323
+ command: partial?.command,
324
+ args: partial?.args,
325
+ argsResult: partial?.argsResult,
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Deduplicates plugins by `id`. When multiple plugins share the same `id`,
331
+ * only the last one in the array is kept. Plugins without an `id` are always kept.
332
+ */
333
+ function deduplicatePlugins(plugins: PadronePlugin<any, any>[]): PadronePlugin<any, any>[] {
334
+ // Fast path: no ids at all
335
+ if (!plugins.some((p) => p.id)) return plugins;
336
+
337
+ // Find the last index for each id
338
+ const lastIndex = new Map<string, number>();
339
+ for (let i = 0; i < plugins.length; i++) {
340
+ const id = plugins[i]!.id;
341
+ if (id) lastIndex.set(id, i);
342
+ }
343
+
344
+ return plugins.filter((p, i) => !p.id || lastIndex.get(p.id) === i);
345
+ }
346
+
160
347
  /**
161
348
  * Runs a plugin chain for a given phase using the onion/middleware pattern.
162
349
  * Plugins are sorted by `order` (ascending, stable), then composed so that
@@ -165,12 +352,13 @@ export function outputValue(value: unknown, output: (...args: unknown[]) => void
165
352
  */
166
353
  export function runPluginChain<TCtx, TResult>(
167
354
  phase: 'start' | 'parse' | 'validate' | 'execute' | 'error' | 'shutdown',
168
- plugins: PadronePlugin[],
355
+ plugins: PadronePlugin<any, any>[],
169
356
  ctx: TCtx,
170
357
  core: () => TResult | Promise<TResult>,
171
358
  ): TResult | Promise<TResult> {
172
- // Filter to plugins that have a handler for this phase, preserve insertion order
173
- const phasePlugins = plugins.filter((p) => p[phase]);
359
+ // Deduplicate by id (last wins), then filter to plugins that have a handler for this phase
360
+ const deduped = deduplicatePlugins(plugins);
361
+ const phasePlugins = deduped.filter((p) => p[phase]);
174
362
  if (phasePlugins.length === 0) return core();
175
363
 
176
364
  // Stable sort by order (lower = outermost). Equal order preserves registration order.
@@ -197,7 +385,7 @@ export function runPluginChain<TCtx, TResult>(
197
385
  * - Always: `shutdown` plugins run (success or failure).
198
386
  */
199
387
  export function wrapWithLifecycle<T>(
200
- plugins: PadronePlugin[],
388
+ plugins: PadronePlugin<any, any>[],
201
389
  command: AnyPadroneCommand,
202
390
  state: Record<string, unknown>,
203
391
  input: string | undefined,
@@ -208,10 +396,54 @@ export function wrapWithLifecycle<T>(
208
396
  const hasError = plugins.some((p) => p.error);
209
397
  const hasShutdown = plugins.some((p) => p.shutdown);
210
398
 
211
- // Fast path: no lifecycle plugins
212
- if (!hasStart && !hasError && !hasShutdown) return pipeline();
399
+ const cleanupProgress = (error?: unknown, result?: unknown) => {
400
+ const indicator = state._progress as PadroneProgressIndicator | undefined;
401
+ if (indicator) {
402
+ // If there's no progress config (lazy/manual indicator), just stop it silently
403
+ const hasProgressConfig = '_progressMsg' in state;
404
+ if (!hasProgressConfig) {
405
+ indicator.stop();
406
+ } else if (error !== undefined) {
407
+ const fallback = error instanceof Error ? error.message : String(error);
408
+ const { message: errorMsg, indicator: errorIcon } = resolveProgressMessage(state._progressError, error, fallback);
409
+ indicator.fail(errorMsg, errorIcon !== undefined ? { indicator: errorIcon } : undefined);
410
+ } else {
411
+ const { message: successMsg, indicator: successIcon } = resolveProgressMessage(state._progressSuccess, result);
412
+ indicator.succeed(successMsg, successIcon !== undefined ? { indicator: successIcon } : undefined);
413
+ }
414
+ (state._restoreOutput as (() => void) | undefined)?.();
415
+ state._progress = undefined;
416
+ state._restoreOutput = undefined;
417
+ }
418
+ };
419
+
420
+ // Fast path: no lifecycle plugins — still need progress cleanup
421
+ if (!hasStart && !hasError && !hasShutdown) {
422
+ let result: T | Promise<T>;
423
+ try {
424
+ result = pipeline();
425
+ } catch (e) {
426
+ cleanupProgress(e);
427
+ throw e;
428
+ }
429
+ if (result instanceof Promise) {
430
+ return result.then(
431
+ (r) => {
432
+ cleanupProgress();
433
+ return r;
434
+ },
435
+ (e) => {
436
+ cleanupProgress(e);
437
+ throw e;
438
+ },
439
+ );
440
+ }
441
+ cleanupProgress();
442
+ return result;
443
+ }
213
444
 
214
445
  const runShutdown = (error?: unknown, result?: unknown) => {
446
+ cleanupProgress(error);
215
447
  if (!hasShutdown) return;
216
448
  const ctx: PluginShutdownContext = { command, state, error, result };
217
449
  return runPluginChain('shutdown', plugins, ctx, () => {});
@@ -276,6 +508,85 @@ export function getCommandRuntime(cmd: AnyPadroneCommand): ResolvedPadroneRuntim
276
508
  return resolveRuntime();
277
509
  }
278
510
 
511
+ /** No-op progress indicator returned when the runtime doesn't provide a `progress` factory. */
512
+ const noopIndicator: PadroneProgressIndicator = {
513
+ update() {},
514
+ succeed() {},
515
+ fail() {},
516
+ stop() {},
517
+ pause() {},
518
+ resume() {},
519
+ };
520
+
521
+ /** Creates a progress indicator from the runtime, or returns a no-op if unavailable. */
522
+ export function createProgress(
523
+ runtime: ResolvedPadroneRuntime,
524
+ message: string,
525
+ options?: import('./runtime.ts').PadroneProgressOptions,
526
+ ): PadroneProgressIndicator {
527
+ return runtime.progress?.(message, options) ?? noopIndicator;
528
+ }
529
+
530
+ /**
531
+ * Creates a lazy progress indicator that defers real indicator creation until first use.
532
+ * This allows `ctx.progress` to work even without `.progress()` config, as long as the
533
+ * runtime provides a progress factory.
534
+ */
535
+ export function createLazyIndicator(runtime: ResolvedPadroneRuntime, state: Record<string, unknown>): PadroneProgressIndicator {
536
+ if (!runtime.progress) return noopIndicator;
537
+
538
+ let real: PadroneProgressIndicator | undefined;
539
+ const ensure = (message?: string) => {
540
+ if (!real) {
541
+ real = runtime.progress!(message ?? '', undefined);
542
+ state._progress = real;
543
+ }
544
+ return real;
545
+ };
546
+
547
+ return {
548
+ update(msg) {
549
+ ensure(msg).update(msg);
550
+ },
551
+ succeed(msg) {
552
+ if (real) real.succeed(msg);
553
+ },
554
+ fail(msg) {
555
+ if (real) real.fail(msg);
556
+ },
557
+ stop() {
558
+ if (real) real.stop();
559
+ },
560
+ pause() {
561
+ if (real) real.pause();
562
+ },
563
+ resume() {
564
+ if (real) real.resume();
565
+ },
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Resolves a progress message field (static or callback) into the arguments for succeed/fail.
571
+ * Handles string, null, `{ message, indicator }` objects, and callback functions.
572
+ */
573
+ export function resolveProgressMessage(
574
+ field: unknown,
575
+ value: unknown,
576
+ fallback?: string,
577
+ ): { message: string | null | undefined; indicator?: string } {
578
+ const raw = typeof field === 'function' ? (field as (v: unknown) => unknown)(value) : field;
579
+ if (raw === undefined) return { message: fallback };
580
+ if (raw === null || typeof raw === 'string') return { message: raw };
581
+ if (typeof raw === 'object' && raw !== null) {
582
+ const obj = raw as { message?: string | null; indicator?: string };
583
+ return { message: obj.message, indicator: obj.indicator };
584
+ }
585
+ return { message: fallback };
586
+ }
587
+
588
+ export { noopIndicator };
589
+
279
590
  export function isAsyncBranded(schema: unknown): boolean {
280
591
  return !!schema && typeof schema === 'object' && '~async' in schema && (schema as any)['~async'] === true;
281
592
  }
@@ -308,6 +619,7 @@ export function repathCommandTree(
308
619
  parentPath: string,
309
620
  parent: AnyPadroneCommand,
310
621
  ): AnyPadroneCommand {
622
+ resolveCommand(cmd);
311
623
  const newPath = parentPath ? `${parentPath} ${newName}` : newName;
312
624
  const remounted: AnyPadroneCommand = {
313
625
  ...cmd,
@@ -335,6 +647,7 @@ export function buildReplCompleter(
335
647
  inScope?: boolean;
336
648
  },
337
649
  ): (line: string) => [string[], string] {
650
+ resolveAllCommands(rootCommand);
338
651
  return (line: string): [string[], string] => {
339
652
  const trimmed = line.trimStart();
340
653
  const parts = trimmed.split(/\s+/);
@@ -354,9 +667,12 @@ export function buildReplCompleter(
354
667
  const commandParts = parts.slice(0, -1).filter((p) => !p.startsWith('-'));
355
668
  let targetCommand = rootCommand;
356
669
  for (const part of commandParts) {
670
+ resolveCommand(targetCommand);
357
671
  const sub = targetCommand.commands?.find((c) => c.name === part || c.aliases?.includes(part));
358
- if (sub) targetCommand = sub;
359
- else break;
672
+ if (sub) {
673
+ resolveCommand(sub);
674
+ targetCommand = sub;
675
+ } else break;
360
676
  }
361
677
 
362
678
  // Get options for this command
@@ -365,7 +681,7 @@ export function buildReplCompleter(
365
681
  try {
366
682
  const argsMeta = targetCommand.meta?.fields;
367
683
  const { flags, aliases } = extractSchemaMetadata(targetCommand.argsSchema, argsMeta, targetCommand.meta?.autoAlias);
368
- const jsonSchema = targetCommand.argsSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
684
+ const jsonSchema = targetCommand.argsSchema['~standard'].jsonSchema.input(JSON_SCHEMA_OPTS) as Record<string, any>;
369
685
  if (jsonSchema.type === 'object' && jsonSchema.properties) {
370
686
  for (const key of Object.keys(jsonSchema.properties)) {
371
687
  options.push(`--${key}`);
@@ -393,9 +709,12 @@ export function buildReplCompleter(
393
709
  // Walk into subcommands for all but the last token
394
710
  let targetCommand = rootCommand;
395
711
  for (let i = 0; i < commandParts.length - 1; i++) {
712
+ resolveCommand(targetCommand);
396
713
  const sub = targetCommand.commands?.find((c) => c.name === commandParts[i] || c.aliases?.includes(commandParts[i]!));
397
- if (sub) targetCommand = sub;
398
- else break;
714
+ if (sub) {
715
+ resolveCommand(sub);
716
+ targetCommand = sub;
717
+ } else break;
399
718
  }
400
719
 
401
720
  const candidates: string[] = [];
@@ -477,28 +796,87 @@ export function findCommandByName(name: string, commands?: AnyPadroneCommand[]):
477
796
  if (!commands) return undefined;
478
797
 
479
798
  const foundByName = commands.find((cmd) => cmd.name === name);
480
- if (foundByName) return foundByName;
799
+ if (foundByName) return resolveCommand(foundByName);
481
800
 
482
801
  // Check for aliases
483
802
  const foundByAlias = commands.find((cmd) => cmd.aliases?.includes(name));
484
- if (foundByAlias) return foundByAlias;
803
+ if (foundByAlias) return resolveCommand(foundByAlias);
485
804
 
486
805
  for (const cmd of commands) {
487
- if (cmd.commands && name.startsWith(`${cmd.name} `)) {
488
- const subCommandName = name.slice(cmd.name.length + 1);
489
- const subCommand = findCommandByName(subCommandName, cmd.commands);
490
- if (subCommand) return subCommand;
806
+ if (name.startsWith(`${cmd.name} `)) {
807
+ resolveCommand(cmd);
808
+ if (cmd.commands) {
809
+ const subCommandName = name.slice(cmd.name.length + 1);
810
+ const subCommand = findCommandByName(subCommandName, cmd.commands);
811
+ if (subCommand) return subCommand;
812
+ }
491
813
  }
492
814
  // Check aliases for nested commands
493
- if (cmd.commands && cmd.aliases) {
815
+ if (cmd.aliases) {
494
816
  for (const alias of cmd.aliases) {
495
817
  if (name.startsWith(`${alias} `)) {
496
- const subCommandName = name.slice(alias.length + 1);
497
- const subCommand = findCommandByName(subCommandName, cmd.commands);
498
- if (subCommand) return subCommand;
818
+ resolveCommand(cmd);
819
+ if (cmd.commands) {
820
+ const subCommandName = name.slice(alias.length + 1);
821
+ const subCommand = findCommandByName(subCommandName, cmd.commands);
822
+ if (subCommand) return subCommand;
823
+ }
499
824
  }
500
825
  }
501
826
  }
502
827
  }
503
828
  return undefined;
504
829
  }
830
+
831
+ // ---------------------------------------------------------------------------
832
+ // Shared utilities for MCP and serve
833
+ // ---------------------------------------------------------------------------
834
+
835
+ export type CollectedEndpoint = { name: string; command: AnyPadroneCommand };
836
+
837
+ /** Collect all actionable commands recursively. Hidden commands are excluded. */
838
+ export function collectEndpoints(commands: AnyPadroneCommand[] | undefined, prefix: string): CollectedEndpoint[] {
839
+ if (!commands) return [];
840
+ const endpoints: CollectedEndpoint[] = [];
841
+ for (const cmd of commands) {
842
+ resolveCommand(cmd);
843
+ if (cmd.hidden) continue;
844
+ const path = cmd.name ? (prefix ? `${prefix}.${cmd.name}` : cmd.name) : prefix;
845
+ if (cmd.action || cmd.argsSchema) {
846
+ endpoints.push({ name: path, command: cmd });
847
+ }
848
+ if (cmd.commands?.length) {
849
+ endpoints.push(...collectEndpoints(cmd.commands, path));
850
+ }
851
+ }
852
+ return endpoints;
853
+ }
854
+
855
+ /** Build the JSON Schema for a command's arguments. */
856
+ export function buildInputSchema(cmd: AnyPadroneCommand): Record<string, unknown> {
857
+ if (!cmd.argsSchema) {
858
+ return { type: 'object', additionalProperties: false };
859
+ }
860
+ try {
861
+ return cmd.argsSchema['~standard'].jsonSchema.input(JSON_SCHEMA_OPTS) as Record<string, unknown>;
862
+ } catch {
863
+ return { type: 'object', additionalProperties: false };
864
+ }
865
+ }
866
+
867
+ /** Serialize a record of args into CLI flag strings. */
868
+ export function serializeArgsToFlags(args: Record<string, unknown>): string[] {
869
+ const parts: string[] = [];
870
+ for (const [key, value] of Object.entries(args)) {
871
+ if (value === undefined) continue;
872
+ if (typeof value === 'boolean') {
873
+ parts.push(value ? `--${key}` : `--no-${key}`);
874
+ } else if (Array.isArray(value)) {
875
+ for (const v of value) parts.push(`--${key}=${String(v)}`);
876
+ } else {
877
+ const strVal = String(value);
878
+ parts.push(strVal.includes(' ') ? `--${key}="${strVal}"` : `--${key}=${strVal}`);
879
+ }
880
+ }
881
+ return parts;
882
+ }