padrone 1.4.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 +79 -0
  2. package/README.md +105 -284
  3. package/dist/{args-CVDbyyzG.mjs → args-D5PNDyNu.mjs} +41 -18
  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-ClUK5hcQ.d.mts → formatter-DtHzbP22.d.mts} +34 -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 +493 -265
  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-DjIdJN5G.d.mts → types-Ch8Mk6Qb.d.mts} +310 -62
  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 +68 -40
  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 +380 -30
  60. package/src/completion.ts +120 -47
  61. package/src/create.ts +480 -128
  62. package/src/docs/index.ts +122 -8
  63. package/src/formatter.ts +171 -125
  64. package/src/help.ts +45 -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-CVDbyyzG.mjs.map +0 -1
  78. package/dist/chunk-y_GBKt04.mjs +0 -5
  79. package/dist/formatter-ClUK5hcQ.d.mts.map +0 -1
  80. package/dist/help-CcBe91bV.mjs +0 -1254
  81. package/dist/help-CcBe91bV.mjs.map +0 -1
  82. package/dist/types-DjIdJN5G.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) {
@@ -104,33 +145,62 @@ export function thenMaybe<T, U>(value: T | Promise<T>, fn: (v: T) => U | Promise
104
145
  }
105
146
 
106
147
  /**
107
- * Makes a sync result object thenable by adding a `.then()` method.
148
+ * Makes a sync result object thenable by adding `.then()`, `.catch()`, and `.finally()` methods.
108
149
  * If the value is already a Promise, returns it as-is.
109
150
  * This allows users to write `await program.cli()` or `program.cli().then(...)` regardless of sync/async.
110
151
  *
111
- * The `.then()` resolves with a plain copy (without `.then`) to avoid infinite
152
+ * The `.then()` resolves with a plain copy (without thenable methods) to avoid infinite
112
153
  * recursive unwrapping by the Promise resolution algorithm.
113
154
  */
114
- export function makeThenable<T>(value: T | Promise<T>): T & PromiseLike<T> {
155
+ export function makeThenable<T>(value: T | Promise<T>): Thenable<T> {
115
156
  if (value instanceof Promise) return value as any;
116
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
+ };
117
165
  // biome-ignore lint/suspicious/noThenProperty: intentional thenable shim for sync results
118
166
  (value as any).then = (onfulfilled?: (v: T) => any, onrejected?: (reason: any) => any) => {
119
167
  try {
120
- // Resolve with a plain copy to prevent infinite thenable unwrapping
121
- const plain = { ...value } as any;
122
- delete plain.then;
123
- const result = onfulfilled ? onfulfilled(plain) : plain;
168
+ const result = onfulfilled ? onfulfilled(toPlain()) : toPlain();
124
169
  return Promise.resolve(result);
125
170
  } catch (err) {
126
171
  if (onrejected) return Promise.resolve(onrejected(err));
127
172
  return Promise.reject(err);
128
173
  }
129
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
+ );
130
187
  }
131
188
  return value as any;
132
189
  }
133
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
+
134
204
  export function isIterator(value: unknown): value is Iterator<unknown> {
135
205
  return typeof value === 'object' && value !== null && Symbol.iterator in value && typeof (value as any)[Symbol.iterator] === 'function';
136
206
  }
@@ -185,6 +255,95 @@ export function outputValue(value: unknown, output: (...args: unknown[]) => void
185
255
  output(value);
186
256
  }
187
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
+
188
347
  /**
189
348
  * Runs a plugin chain for a given phase using the onion/middleware pattern.
190
349
  * Plugins are sorted by `order` (ascending, stable), then composed so that
@@ -193,12 +352,13 @@ export function outputValue(value: unknown, output: (...args: unknown[]) => void
193
352
  */
194
353
  export function runPluginChain<TCtx, TResult>(
195
354
  phase: 'start' | 'parse' | 'validate' | 'execute' | 'error' | 'shutdown',
196
- plugins: PadronePlugin[],
355
+ plugins: PadronePlugin<any, any>[],
197
356
  ctx: TCtx,
198
357
  core: () => TResult | Promise<TResult>,
199
358
  ): TResult | Promise<TResult> {
200
- // Filter to plugins that have a handler for this phase, preserve insertion order
201
- 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]);
202
362
  if (phasePlugins.length === 0) return core();
203
363
 
204
364
  // Stable sort by order (lower = outermost). Equal order preserves registration order.
@@ -225,7 +385,7 @@ export function runPluginChain<TCtx, TResult>(
225
385
  * - Always: `shutdown` plugins run (success or failure).
226
386
  */
227
387
  export function wrapWithLifecycle<T>(
228
- plugins: PadronePlugin[],
388
+ plugins: PadronePlugin<any, any>[],
229
389
  command: AnyPadroneCommand,
230
390
  state: Record<string, unknown>,
231
391
  input: string | undefined,
@@ -236,10 +396,54 @@ export function wrapWithLifecycle<T>(
236
396
  const hasError = plugins.some((p) => p.error);
237
397
  const hasShutdown = plugins.some((p) => p.shutdown);
238
398
 
239
- // Fast path: no lifecycle plugins
240
- 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
+ }
241
444
 
242
445
  const runShutdown = (error?: unknown, result?: unknown) => {
446
+ cleanupProgress(error);
243
447
  if (!hasShutdown) return;
244
448
  const ctx: PluginShutdownContext = { command, state, error, result };
245
449
  return runPluginChain('shutdown', plugins, ctx, () => {});
@@ -304,6 +508,85 @@ export function getCommandRuntime(cmd: AnyPadroneCommand): ResolvedPadroneRuntim
304
508
  return resolveRuntime();
305
509
  }
306
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
+
307
590
  export function isAsyncBranded(schema: unknown): boolean {
308
591
  return !!schema && typeof schema === 'object' && '~async' in schema && (schema as any)['~async'] === true;
309
592
  }
@@ -336,6 +619,7 @@ export function repathCommandTree(
336
619
  parentPath: string,
337
620
  parent: AnyPadroneCommand,
338
621
  ): AnyPadroneCommand {
622
+ resolveCommand(cmd);
339
623
  const newPath = parentPath ? `${parentPath} ${newName}` : newName;
340
624
  const remounted: AnyPadroneCommand = {
341
625
  ...cmd,
@@ -363,6 +647,7 @@ export function buildReplCompleter(
363
647
  inScope?: boolean;
364
648
  },
365
649
  ): (line: string) => [string[], string] {
650
+ resolveAllCommands(rootCommand);
366
651
  return (line: string): [string[], string] => {
367
652
  const trimmed = line.trimStart();
368
653
  const parts = trimmed.split(/\s+/);
@@ -382,9 +667,12 @@ export function buildReplCompleter(
382
667
  const commandParts = parts.slice(0, -1).filter((p) => !p.startsWith('-'));
383
668
  let targetCommand = rootCommand;
384
669
  for (const part of commandParts) {
670
+ resolveCommand(targetCommand);
385
671
  const sub = targetCommand.commands?.find((c) => c.name === part || c.aliases?.includes(part));
386
- if (sub) targetCommand = sub;
387
- else break;
672
+ if (sub) {
673
+ resolveCommand(sub);
674
+ targetCommand = sub;
675
+ } else break;
388
676
  }
389
677
 
390
678
  // Get options for this command
@@ -393,7 +681,7 @@ export function buildReplCompleter(
393
681
  try {
394
682
  const argsMeta = targetCommand.meta?.fields;
395
683
  const { flags, aliases } = extractSchemaMetadata(targetCommand.argsSchema, argsMeta, targetCommand.meta?.autoAlias);
396
- 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>;
397
685
  if (jsonSchema.type === 'object' && jsonSchema.properties) {
398
686
  for (const key of Object.keys(jsonSchema.properties)) {
399
687
  options.push(`--${key}`);
@@ -421,9 +709,12 @@ export function buildReplCompleter(
421
709
  // Walk into subcommands for all but the last token
422
710
  let targetCommand = rootCommand;
423
711
  for (let i = 0; i < commandParts.length - 1; i++) {
712
+ resolveCommand(targetCommand);
424
713
  const sub = targetCommand.commands?.find((c) => c.name === commandParts[i] || c.aliases?.includes(commandParts[i]!));
425
- if (sub) targetCommand = sub;
426
- else break;
714
+ if (sub) {
715
+ resolveCommand(sub);
716
+ targetCommand = sub;
717
+ } else break;
427
718
  }
428
719
 
429
720
  const candidates: string[] = [];
@@ -505,28 +796,87 @@ export function findCommandByName(name: string, commands?: AnyPadroneCommand[]):
505
796
  if (!commands) return undefined;
506
797
 
507
798
  const foundByName = commands.find((cmd) => cmd.name === name);
508
- if (foundByName) return foundByName;
799
+ if (foundByName) return resolveCommand(foundByName);
509
800
 
510
801
  // Check for aliases
511
802
  const foundByAlias = commands.find((cmd) => cmd.aliases?.includes(name));
512
- if (foundByAlias) return foundByAlias;
803
+ if (foundByAlias) return resolveCommand(foundByAlias);
513
804
 
514
805
  for (const cmd of commands) {
515
- if (cmd.commands && name.startsWith(`${cmd.name} `)) {
516
- const subCommandName = name.slice(cmd.name.length + 1);
517
- const subCommand = findCommandByName(subCommandName, cmd.commands);
518
- 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
+ }
519
813
  }
520
814
  // Check aliases for nested commands
521
- if (cmd.commands && cmd.aliases) {
815
+ if (cmd.aliases) {
522
816
  for (const alias of cmd.aliases) {
523
817
  if (name.startsWith(`${alias} `)) {
524
- const subCommandName = name.slice(alias.length + 1);
525
- const subCommand = findCommandByName(subCommandName, cmd.commands);
526
- 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
+ }
527
824
  }
528
825
  }
529
826
  }
530
827
  }
531
828
  return undefined;
532
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
+ }