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
package/src/cli/doctor.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { resolve } from 'node:path';
2
- import { commandSymbol } from '../command-utils.ts';
2
+ import { JSON_SCHEMA_OPTS } from '../args.ts';
3
+ import { getCommand, isPadroneProgram } from '../command-utils.ts';
3
4
  import type { AnyPadroneCommand, PadroneActionContext } from '../types.ts';
5
+ import { detectEntry } from './link.ts';
4
6
 
5
7
  interface DoctorArgs {
6
- entry: string;
8
+ entry?: string;
7
9
  }
8
10
 
9
11
  type Severity = 'error' | 'warning';
@@ -15,7 +17,18 @@ interface Diagnostic {
15
17
  }
16
18
 
17
19
  export async function runDoctor(args: DoctorArgs, _ctx: PadroneActionContext) {
18
- const entryPath = resolve(args.entry);
20
+ let entryPath: string;
21
+
22
+ if (args.entry) {
23
+ entryPath = resolve(args.entry);
24
+ } else {
25
+ const detected = detectEntry(process.cwd());
26
+ if (!detected) {
27
+ console.error('Could not detect entry point. Provide an entry file or add a "bin" field to package.json.');
28
+ process.exit(1);
29
+ }
30
+ entryPath = detected.entry;
31
+ }
19
32
 
20
33
  let mod: Record<string, unknown>;
21
34
  try {
@@ -33,7 +46,7 @@ export async function runDoctor(args: DoctorArgs, _ctx: PadroneActionContext) {
33
46
  process.exit(1);
34
47
  }
35
48
 
36
- const cmd = (program as any)[commandSymbol] as AnyPadroneCommand;
49
+ const cmd = getCommand(program as object);
37
50
  const diagnostics: Diagnostic[] = [];
38
51
 
39
52
  collectDiagnostics(cmd, diagnostics);
@@ -63,6 +76,8 @@ export async function runDoctor(args: DoctorArgs, _ctx: PadroneActionContext) {
63
76
  }
64
77
 
65
78
  function collectDiagnostics(cmd: AnyPadroneCommand, diagnostics: Diagnostic[]) {
79
+ checkCircularParentRefs(cmd, diagnostics);
80
+
66
81
  const allCommands = flattenCommands(cmd);
67
82
 
68
83
  checkDuplicateAliases(allCommands, diagnostics);
@@ -71,6 +86,11 @@ function collectDiagnostics(cmd: AnyPadroneCommand, diagnostics: Diagnostic[]) {
71
86
  checkSchemasWithoutDescriptions(allCommands, diagnostics);
72
87
  checkConflictingPositionals(allCommands, diagnostics);
73
88
  checkUnusedPlugins(allCommands, diagnostics);
89
+ checkDuplicateOptionFlagsAndAliases(allCommands, diagnostics);
90
+ checkUnreachableCommands(allCommands, diagnostics);
91
+ checkMissingCommandDescriptions(allCommands, diagnostics);
92
+ checkEmptyCommandGroups(allCommands, diagnostics);
93
+ checkDeprecatedWithoutHint(allCommands, diagnostics);
74
94
  }
75
95
 
76
96
  function flattenCommands(cmd: AnyPadroneCommand): AnyPadroneCommand[] {
@@ -90,7 +110,7 @@ function commandDisplayName(cmd: AnyPadroneCommand): string {
90
110
  function getJsonSchema(cmd: AnyPadroneCommand): Record<string, any> | null {
91
111
  try {
92
112
  if (!cmd.argsSchema) return null;
93
- return cmd.argsSchema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
113
+ return cmd.argsSchema['~standard'].jsonSchema.input(JSON_SCHEMA_OPTS) as Record<string, any>;
94
114
  } catch {
95
115
  return null;
96
116
  }
@@ -313,6 +333,176 @@ function checkUnusedPlugins(commands: AnyPadroneCommand[], diagnostics: Diagnost
313
333
  }
314
334
  }
315
335
 
336
+ /**
337
+ * Check for circular parent references in the command tree.
338
+ * Uses a visited set to detect cycles before flattening.
339
+ */
340
+ function checkCircularParentRefs(cmd: AnyPadroneCommand, diagnostics: Diagnostic[]) {
341
+ const visited = new Set<AnyPadroneCommand>();
342
+
343
+ function walk(node: AnyPadroneCommand) {
344
+ if (visited.has(node)) {
345
+ diagnostics.push({
346
+ severity: 'error',
347
+ command: commandDisplayName(node),
348
+ message: 'Circular reference detected in command tree.',
349
+ });
350
+ return;
351
+ }
352
+ visited.add(node);
353
+ if (node.commands) {
354
+ for (const sub of node.commands) {
355
+ walk(sub);
356
+ }
357
+ }
358
+ }
359
+
360
+ walk(cmd);
361
+ }
362
+
363
+ /**
364
+ * Check for duplicate flags or aliases within a single command's meta fields.
365
+ * e.g., two different options both using `-v` or both aliased to `--output`.
366
+ */
367
+ function checkDuplicateOptionFlagsAndAliases(commands: AnyPadroneCommand[], diagnostics: Diagnostic[]) {
368
+ for (const cmd of commands) {
369
+ if (!cmd.meta?.fields) continue;
370
+
371
+ const seenFlags = new Map<string, string>();
372
+ const seenAliases = new Map<string, string>();
373
+
374
+ for (const [fieldName, fieldMeta] of Object.entries(cmd.meta.fields)) {
375
+ if (!fieldMeta) continue;
376
+
377
+ if (fieldMeta.flags) {
378
+ const flagList = typeof fieldMeta.flags === 'string' ? [fieldMeta.flags] : fieldMeta.flags;
379
+ for (const flag of flagList) {
380
+ const existing = seenFlags.get(flag);
381
+ if (existing) {
382
+ diagnostics.push({
383
+ severity: 'error',
384
+ command: commandDisplayName(cmd),
385
+ message: `Flag "-${flag}" is used by both "${existing}" and "${fieldName}".`,
386
+ });
387
+ } else {
388
+ seenFlags.set(flag, fieldName);
389
+ }
390
+ }
391
+ }
392
+
393
+ if (fieldMeta.alias) {
394
+ const aliasList = typeof fieldMeta.alias === 'string' ? [fieldMeta.alias] : fieldMeta.alias;
395
+ for (const alias of aliasList) {
396
+ const existing = seenAliases.get(alias);
397
+ if (existing) {
398
+ diagnostics.push({
399
+ severity: 'error',
400
+ command: commandDisplayName(cmd),
401
+ message: `Alias "--${alias}" is used by both "${existing}" and "${fieldName}".`,
402
+ });
403
+ } else {
404
+ seenAliases.set(alias, fieldName);
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Check for commands that are unreachable because a parent is hidden.
414
+ * A hidden parent makes all its children effectively invisible to users.
415
+ */
416
+ function checkUnreachableCommands(commands: AnyPadroneCommand[], diagnostics: Diagnostic[]) {
417
+ for (const cmd of commands) {
418
+ if (!cmd.parent || cmd.hidden) continue;
419
+
420
+ let ancestor: AnyPadroneCommand | undefined = cmd.parent;
421
+ while (ancestor) {
422
+ if (ancestor.hidden && ancestor.parent) {
423
+ diagnostics.push({
424
+ severity: 'warning',
425
+ command: commandDisplayName(cmd),
426
+ message: `Command is unreachable because parent "${commandDisplayName(ancestor)}" is hidden.`,
427
+ });
428
+ break;
429
+ }
430
+ ancestor = ancestor.parent;
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Check for non-root commands without a title or description.
437
+ */
438
+ function checkMissingCommandDescriptions(commands: AnyPadroneCommand[], diagnostics: Diagnostic[]) {
439
+ for (const cmd of commands) {
440
+ if (!cmd.parent) continue;
441
+ if (cmd.hidden) continue;
442
+
443
+ if (!cmd.title && !cmd.description) {
444
+ diagnostics.push({
445
+ severity: 'warning',
446
+ command: commandDisplayName(cmd),
447
+ message: 'Command has no title or description.',
448
+ });
449
+ }
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Check for branch commands (have subcommands) that have no leaf descendants.
455
+ * This catches empty command groups where all subcommands are also empty branches.
456
+ */
457
+ function checkEmptyCommandGroups(commands: AnyPadroneCommand[], diagnostics: Diagnostic[]) {
458
+ function hasLeafDescendant(cmd: AnyPadroneCommand): boolean {
459
+ if (!cmd.commands || cmd.commands.length === 0) return true;
460
+ return cmd.commands.some(hasLeafDescendant);
461
+ }
462
+
463
+ for (const cmd of commands) {
464
+ if (!cmd.commands || cmd.commands.length === 0) continue;
465
+
466
+ const allSubsEmpty = cmd.commands.every((sub) => sub.commands && sub.commands.length > 0 && !hasLeafDescendant(sub));
467
+
468
+ if (allSubsEmpty) {
469
+ diagnostics.push({
470
+ severity: 'warning',
471
+ command: commandDisplayName(cmd),
472
+ message: 'Command group has no reachable leaf commands.',
473
+ });
474
+ }
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Check for deprecated commands that use `deprecated: true` without a hint message.
480
+ */
481
+ function checkDeprecatedWithoutHint(commands: AnyPadroneCommand[], diagnostics: Diagnostic[]) {
482
+ for (const cmd of commands) {
483
+ if (cmd.deprecated === true) {
484
+ diagnostics.push({
485
+ severity: 'warning',
486
+ command: commandDisplayName(cmd),
487
+ message: 'Command is deprecated without a replacement hint. Use a string to provide guidance (e.g., "Use \'new-cmd\' instead").',
488
+ });
489
+ }
490
+
491
+ // Also check deprecated option fields
492
+ if (cmd.meta?.fields) {
493
+ for (const [fieldName, fieldMeta] of Object.entries(cmd.meta.fields)) {
494
+ if (fieldMeta?.deprecated === true) {
495
+ diagnostics.push({
496
+ severity: 'warning',
497
+ command: commandDisplayName(cmd),
498
+ message: `Option "${fieldName}" is deprecated without a replacement hint.`,
499
+ });
500
+ }
501
+ }
502
+ }
503
+ }
504
+ }
505
+
316
506
  function findProgram(mod: Record<string, unknown>): unknown | null {
317
507
  const defaultExport = mod.default;
318
508
  if (isPadroneProgram(defaultExport)) return defaultExport;
@@ -323,8 +513,3 @@ function findProgram(mod: Record<string, unknown>): unknown | null {
323
513
 
324
514
  return null;
325
515
  }
326
-
327
- function isPadroneProgram(value: unknown): boolean {
328
- if (!value || typeof value !== 'object') return false;
329
- return commandSymbol in value;
330
- }
package/src/cli/index.ts CHANGED
@@ -60,7 +60,7 @@ const PadroneCLI = createPadrone('padrone')
60
60
  })
61
61
  .arguments(
62
62
  z.object({
63
- entry: z.string().describe('Entry file that exports a Padrone program'),
63
+ entry: z.string().optional().describe('Entry file that exports a Padrone program (auto-detected from package.json if omitted)'),
64
64
  }),
65
65
  {
66
66
  positional: ['entry'],
package/src/cli/init.ts CHANGED
@@ -16,9 +16,7 @@ const packageJsonTemplate = template(`{
16
16
  "private": true,
17
17
  "type": "module",
18
18
  "module": "src/index.ts",
19
- "bin": {
20
- "{{name}}": "src/index.ts"
21
- },
19
+ "bin": "src/index.ts",
22
20
  "scripts": {
23
21
  "start": "bun src/index.ts",
24
22
  "dev": "bun --watch src/index.ts",
@@ -131,5 +129,6 @@ export async function runInit(args: InitArgs, ctx: PadroneActionContext) {
131
129
  output(` cd ${dir}`);
132
130
  }
133
131
  output(' bun install');
132
+ output(' bun update');
134
133
  output(' bun run dev');
135
134
  }
package/src/cli/link.ts CHANGED
@@ -49,7 +49,7 @@ function writeLinks(links: LinksData) {
49
49
  writeFileSync(LINKS_FILE, `${JSON.stringify(links, null, 2)}\n`);
50
50
  }
51
51
 
52
- interface DetectedEntry {
52
+ export interface DetectedEntry {
53
53
  entry: string;
54
54
  name: string;
55
55
  /** Full run command prefix parsed from scripts (e.g. "bun --conditions=padrone@dev") */
@@ -70,7 +70,7 @@ function parseRunPrefix(script: string, entryRelative: string, dir: string): str
70
70
  return undefined;
71
71
  }
72
72
 
73
- function detectEntry(dir: string): DetectedEntry | undefined {
73
+ export function detectEntry(dir: string): DetectedEntry | undefined {
74
74
  const pkgPath = resolve(dir, 'package.json');
75
75
  if (!existsSync(pkgPath)) return undefined;
76
76
 
@@ -1,15 +1,23 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { parseBashCompletions } from './parsers/bash.ts';
1
5
  import { parseFishCompletions } from './parsers/fish.ts';
2
6
  import { parseHelpOutput } from './parsers/help.ts';
3
7
  import { mergeCommandMeta } from './parsers/merge.ts';
4
8
  import { parseZshCompletions } from './parsers/zsh.ts';
5
9
  import type { CommandMeta, GeneratorLogger } from './types.ts';
6
10
 
7
- export type DiscoverySource = 'help' | 'fish' | 'zsh';
11
+ export type DiscoverySource = 'help' | 'completion' | 'bash' | 'fish' | 'zsh';
8
12
 
9
13
  export interface DiscoveryOptions {
10
14
  /** The command to discover (e.g. 'gh', 'docker', 'kubectl'). */
11
15
  command: string;
12
- /** Which parsing sources to use. Default: ['help'] */
16
+ /**
17
+ * Which parsing sources to use. Default: ['help'].
18
+ * Use `'completion'` to auto-detect the best shell completion source
19
+ * by probing `<cmd> completion <shell>` (bash → fish → zsh).
20
+ */
13
21
  sources?: DiscoverySource[];
14
22
  /** Max subcommand depth. 0 = root only, undefined = unlimited. */
15
23
  depth?: number;
@@ -35,7 +43,10 @@ export interface DiscoveryResult {
35
43
  * parsing shell completion scripts.
36
44
  */
37
45
  export async function discoverCli(options: DiscoveryOptions): Promise<DiscoveryResult> {
38
- const { command, sources = ['help'], depth, delay = 50, log, timeout = 10000 } = options;
46
+ const { command, sources: rawSources = ['help'], depth, delay = 50, log, timeout = 10000 } = options;
47
+
48
+ // Resolve 'completion' source by probing for the best available shell
49
+ const sources = await resolveSources(rawSources, command, timeout, log);
39
50
 
40
51
  const warnings: string[] = [];
41
52
  let invocations = 0;
@@ -60,7 +71,18 @@ export async function discoverCli(options: DiscoveryOptions): Promise<DiscoveryR
60
71
  results.push(helpResult);
61
72
  }
62
73
 
63
- // Source 2: Fish completions
74
+ // Source 2: Bash completions
75
+ if (sources.includes('bash')) {
76
+ log?.info(`Parsing bash completions for ${command}...`);
77
+ const bashText = await getCompletionScript(command, 'bash', timeout);
78
+ if (bashText) {
79
+ results.push(parseBashCompletions(bashText));
80
+ } else {
81
+ warnings.push('Could not obtain bash completion script');
82
+ }
83
+ }
84
+
85
+ // Source 3: Fish completions
64
86
  if (sources.includes('fish')) {
65
87
  log?.info(`Parsing fish completions for ${command}...`);
66
88
  const fishText = await getCompletionScript(command, 'fish', timeout);
@@ -71,7 +93,7 @@ export async function discoverCli(options: DiscoveryOptions): Promise<DiscoveryR
71
93
  }
72
94
  }
73
95
 
74
- // Source 3: Zsh completions
96
+ // Source 4: Zsh completions
75
97
  if (sources.includes('zsh')) {
76
98
  log?.info(`Parsing zsh completions for ${command}...`);
77
99
  const zshText = await getCompletionScript(command, 'zsh', timeout);
@@ -163,25 +185,13 @@ async function runHelp(command: string, args: string[], timeout: number): Promis
163
185
  */
164
186
  async function runCommand(command: string, args: string[], timeout: number): Promise<string | null> {
165
187
  try {
166
- const proc = Bun.spawn([command, ...args], {
167
- stdout: 'pipe',
168
- stderr: 'pipe',
169
- stdin: 'ignore',
188
+ const { stdout, stderr } = await new Promise<{ stdout: string; stderr: string }>((resolve) => {
189
+ execFile(command, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (_error, stdout, stderr) => {
190
+ // Resolve even on non-zero exit — many CLIs exit non-zero on --help
191
+ resolve({ stdout: (stdout ?? '').trim(), stderr: (stderr ?? '').trim() });
192
+ });
170
193
  });
171
194
 
172
- const timer = setTimeout(() => proc.kill(), timeout);
173
-
174
- const [_exitCode, stdoutBuf, stderrBuf] = await Promise.all([
175
- proc.exited,
176
- new Response(proc.stdout).arrayBuffer(),
177
- new Response(proc.stderr).arrayBuffer(),
178
- ]);
179
-
180
- clearTimeout(timer);
181
-
182
- const stdout = new TextDecoder().decode(stdoutBuf).trim();
183
- const stderr = new TextDecoder().decode(stderrBuf).trim();
184
-
185
195
  // Some CLIs output help to stderr, some exit non-zero on --help
186
196
  const combined = stdout || stderr;
187
197
  if (!combined) return null;
@@ -199,7 +209,7 @@ async function runCommand(command: string, args: string[], timeout: number): Pro
199
209
  * Try to get a shell completion script for a command.
200
210
  * Checks both `<cmd> completion <shell>` and well-known file paths.
201
211
  */
202
- async function getCompletionScript(command: string, shell: 'fish' | 'zsh', timeout: number): Promise<string | null> {
212
+ async function getCompletionScript(command: string, shell: 'bash' | 'fish' | 'zsh', timeout: number): Promise<string | null> {
203
213
  // Try `<cmd> completion <shell>`
204
214
  const completionArgs = ['completion', shell];
205
215
  let result = await runCommand(command, completionArgs, timeout);
@@ -213,20 +223,62 @@ async function getCompletionScript(command: string, shell: 'fish' | 'zsh', timeo
213
223
  const paths =
214
224
  shell === 'fish'
215
225
  ? [`/usr/share/fish/vendor_completions.d/${command}.fish`, `/usr/local/share/fish/vendor_completions.d/${command}.fish`]
216
- : [`/usr/share/zsh/site-functions/_${command}`, `/usr/local/share/zsh/site-functions/_${command}`];
226
+ : shell === 'bash'
227
+ ? [
228
+ `/usr/share/bash-completion/completions/${command}`,
229
+ `/usr/local/share/bash-completion/completions/${command}`,
230
+ `/etc/bash_completion.d/${command}`,
231
+ ]
232
+ : [`/usr/share/zsh/site-functions/_${command}`, `/usr/local/share/zsh/site-functions/_${command}`];
217
233
 
218
234
  for (const path of paths) {
219
235
  try {
220
- const file = Bun.file(path);
221
- if (await file.exists()) {
222
- return await file.text();
223
- }
236
+ return await readFile(path, 'utf-8');
224
237
  } catch {}
225
238
  }
226
239
 
227
240
  return null;
228
241
  }
229
242
 
243
+ /**
244
+ * Detect the best shell for completion parsing by probing the command.
245
+ * Tries `<cmd> completion <shell>` for bash, fish, zsh (in that order).
246
+ * Returns the shell name if successful, or null if no completion command exists.
247
+ */
248
+ export async function detectCompletionShell(command: string, timeout = 5000): Promise<'bash' | 'fish' | 'zsh' | null> {
249
+ for (const shell of ['bash', 'fish', 'zsh'] as const) {
250
+ const result = await getCompletionScript(command, shell, timeout);
251
+ if (result) return shell;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * Resolve 'completion' entries in the sources array by probing for the best available shell.
258
+ * Other sources are passed through unchanged.
259
+ */
260
+ async function resolveSources(
261
+ sources: DiscoverySource[],
262
+ command: string,
263
+ timeout: number,
264
+ log?: GeneratorLogger,
265
+ ): Promise<DiscoverySource[]> {
266
+ if (!sources.includes('completion')) return sources;
267
+
268
+ log?.info(`Probing ${command} for completion command...`);
269
+ const shell = await detectCompletionShell(command, timeout);
270
+
271
+ return sources.flatMap((s) => {
272
+ if (s !== 'completion') return s;
273
+ if (shell) {
274
+ log?.info(` Found ${shell} completion support`);
275
+ return shell;
276
+ }
277
+ log?.info(' No completion command found, skipping');
278
+ return [];
279
+ });
280
+ }
281
+
230
282
  function sleep(ms: number): Promise<void> {
231
283
  return new Promise((resolve) => setTimeout(resolve, ms));
232
284
  }
@@ -4,7 +4,7 @@
4
4
  export { createCodeBuilder } from './code-builder.ts';
5
5
  export type { DiscoveryOptions, DiscoveryResult, DiscoverySource } from './discovery.ts';
6
6
  // Discovery
7
- export { discoverCli } from './discovery.ts';
7
+ export { detectCompletionShell, discoverCli } from './discovery.ts';
8
8
  export { createFileEmitter } from './file-emitter.ts';
9
9
  // Generators
10
10
  export { generateBarrelFile } from './generators/barrel-file.ts';
@@ -13,6 +13,7 @@ export { generateCommandFile } from './generators/command-file.ts';
13
13
  export type { CommandTreeOptions } from './generators/command-tree.ts';
14
14
  export { generateCommandTree } from './generators/command-tree.ts';
15
15
  // Parsers
16
+ export { parseBashCompletions } from './parsers/bash.ts';
16
17
  export { parseFishCompletions } from './parsers/fish.ts';
17
18
  export { parseHelpOutput } from './parsers/help.ts';
18
19
  export { mergeCommandMeta } from './parsers/merge.ts';
@@ -0,0 +1,179 @@
1
+ import type { CommandMeta, FieldMeta } from '../types.ts';
2
+
3
+ /**
4
+ * Parse bash completion scripts into CommandMeta.
5
+ *
6
+ * Bash completions typically use `complete -F <func> <command>` and define
7
+ * a function that sets COMPREPLY. Common patterns:
8
+ *
9
+ * local commands="init build deploy"
10
+ * local args="--verbose --output --format"
11
+ * case "$prev" in --format) COMPREPLY=($(compgen -W "json yaml toml" ...)) ;;
12
+ * COMPREPLY=($(compgen -W "$commands" ...))
13
+ * COMPREPLY=($(compgen -W "$args" ...))
14
+ */
15
+ export function parseBashCompletions(text: string): CommandMeta {
16
+ const result: CommandMeta = {
17
+ name: '',
18
+ arguments: [],
19
+ subcommands: [],
20
+ };
21
+
22
+ // Detect command name from `complete -F _func <command>` or `complete -o ... -F _func <command>`
23
+ const completeMatch = text.match(/complete\s+(?:[^-]|-[^F])*-F\s+\S+\s+(\S+)/);
24
+ if (completeMatch) {
25
+ result.name = completeMatch[1]!;
26
+ }
27
+
28
+ // Fallback: detect from marker comments like ###-begin-<name>-completion-###
29
+ if (!result.name) {
30
+ const markerMatch = text.match(/###-begin-(\S+)-completion-###/);
31
+ if (markerMatch) {
32
+ result.name = markerMatch[1]!;
33
+ }
34
+ }
35
+
36
+ // Join continuation lines for easier parsing
37
+ const joined = text.replace(/\\\n\s*/g, ' ');
38
+
39
+ // Collect variable values: local commands="..." or local args="..."
40
+ const variables = extractVariables(joined);
41
+
42
+ // Extract subcommand names from commands variable or compgen -W "cmd1 cmd2"
43
+ const commandWords = variables.get('commands') ?? variables.get('cmds') ?? variables.get('subcommands');
44
+ if (commandWords) {
45
+ for (const name of splitWords(commandWords)) {
46
+ result.subcommands!.push({ name });
47
+ }
48
+ }
49
+
50
+ // Extract option names from args/opts/options variable
51
+ const argWords = variables.get('args') ?? variables.get('opts') ?? variables.get('options') ?? variables.get('flags');
52
+ const optionNames = new Set<string>();
53
+
54
+ if (argWords) {
55
+ for (const word of splitWords(argWords)) {
56
+ if (word.startsWith('-')) {
57
+ optionNames.add(word);
58
+ }
59
+ }
60
+ }
61
+
62
+ // Also scan for compgen -W patterns not tied to a case statement
63
+ const compgenRegex = /compgen\s+-W\s+["']([^"']+)["']/g;
64
+ let compgenMatch: RegExpExecArray | null;
65
+ while ((compgenMatch = compgenRegex.exec(joined)) !== null) {
66
+ // Skip variable references like $args, $commands
67
+ if (compgenMatch[1]!.startsWith('$')) continue;
68
+ for (const word of splitWords(compgenMatch[1]!)) {
69
+ if (word.startsWith('-')) {
70
+ optionNames.add(word);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Parse case statement for option value completions (enum detection)
76
+ const enumMap = parseCaseStatement(joined);
77
+
78
+ // Build argument fields
79
+ const seenArgs = new Set<string>();
80
+
81
+ for (const rawName of optionNames) {
82
+ // Skip builtins
83
+ if (rawName === '--help' || rawName === '-h' || rawName === '--version' || rawName === '-V') continue;
84
+
85
+ const name = rawName.replace(/^-+/, '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
86
+ if (!name || seenArgs.has(name)) continue;
87
+ seenArgs.add(name);
88
+
89
+ // Check if this option has enum values from case statement
90
+ const enumValues = enumMap.get(rawName);
91
+
92
+ const isShort = rawName.startsWith('-') && !rawName.startsWith('--') && rawName.length === 2;
93
+ const field: FieldMeta = {
94
+ name,
95
+ type: enumValues ? 'enum' : 'string',
96
+ enumValues,
97
+ ambiguous: !enumValues,
98
+ };
99
+
100
+ // If there's a corresponding long-form, record alias
101
+ if (isShort) {
102
+ field.aliases = [rawName];
103
+ }
104
+
105
+ result.arguments!.push(field);
106
+ }
107
+
108
+ if (result.arguments!.length === 0) delete result.arguments;
109
+ if (result.subcommands!.length === 0) delete result.subcommands;
110
+
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Extract variable assignments: `local VAR="value"`, `VAR="value"`, `local VAR='value'`
116
+ */
117
+ function extractVariables(text: string): Map<string, string> {
118
+ const vars = new Map<string, string>();
119
+ const regex = /(?:local\s+)?(\w+)=["']([^"']+)["']/g;
120
+ let match: RegExpExecArray | null;
121
+
122
+ while ((match = regex.exec(text)) !== null) {
123
+ vars.set(match[1]!, match[2]!);
124
+ }
125
+
126
+ return vars;
127
+ }
128
+
129
+ /**
130
+ * Parse case statements to find option → enum value mappings.
131
+ *
132
+ * Matches patterns like:
133
+ * case "$prev" in
134
+ * --format) COMPREPLY=($(compgen -W "json yaml toml" ...)) ;;
135
+ * --env|--environment) COMPREPLY=($(compgen -W "dev staging prod" ...)) ;;
136
+ */
137
+ function parseCaseStatement(text: string): Map<string, string[]> {
138
+ const result = new Map<string, string[]>();
139
+
140
+ // Find case blocks on $prev or similar variables
141
+ const caseRegex = /case\s+[^i]*\bin\b\s*([\s\S]*?)esac/g;
142
+ let caseMatch: RegExpExecArray | null;
143
+
144
+ while ((caseMatch = caseRegex.exec(text)) !== null) {
145
+ const body = caseMatch[1]!;
146
+
147
+ // Split into branches by ;; delimiter
148
+ const branches = body.split(';;');
149
+ for (const branch of branches) {
150
+ // Match pattern: --opt1|--opt2) ... compgen -W "values" ...
151
+ const patternMatch = branch.match(/^\s*([-\w|]+)\)/);
152
+ if (!patternMatch) continue;
153
+
154
+ // Find compgen -W "values" within this branch
155
+ const compgenMatch = branch.match(/compgen\s+-W\s+["']([^"']+)["']/);
156
+ if (!compgenMatch) continue;
157
+
158
+ const values = compgenMatch[1]!;
159
+ const words = splitWords(values).filter((w) => !w.startsWith('$'));
160
+ if (words.length === 0) continue;
161
+
162
+ for (const pattern of patternMatch[1]!.split('|')) {
163
+ const trimmed = pattern.trim();
164
+ if (trimmed.startsWith('-')) {
165
+ result.set(trimmed, words);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Split a space-separated word list, filtering empty strings.
176
+ */
177
+ function splitWords(text: string): string[] {
178
+ return text.split(/\s+/).filter(Boolean);
179
+ }
@@ -1,4 +1,5 @@
1
1
  import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { JSON_SCHEMA_OPTS } from '../args.ts';
2
3
  import type { FieldMeta } from './types.ts';
3
4
 
4
5
  interface SchemaToCodeResult {
@@ -57,7 +58,7 @@ function jsonSchemaPropertyToZod(prop: Record<string, any>, required: boolean, a
57
58
  */
58
59
  export function schemaToCode(schema: StandardSchemaV1): SchemaToCodeResult {
59
60
  try {
60
- const jsonSchema = (schema as any)['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
61
+ const jsonSchema = (schema as any)['~standard'].jsonSchema.input(JSON_SCHEMA_OPTS) as Record<string, any>;
61
62
  return jsonSchemaToCode(jsonSchema);
62
63
  } catch {
63
64
  return { code: 'z.unknown()', imports: ['z'] };