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.
- package/CHANGELOG.md +79 -0
- package/README.md +105 -284
- package/dist/{args-CVDbyyzG.mjs → args-D5PNDyNu.mjs} +41 -18
- package/dist/args-D5PNDyNu.mjs.map +1 -0
- package/dist/chunk-CjcI7cDX.mjs +15 -0
- package/dist/codegen/index.d.mts +28 -3
- package/dist/codegen/index.d.mts.map +1 -1
- package/dist/codegen/index.mjs +169 -19
- package/dist/codegen/index.mjs.map +1 -1
- package/dist/command-utils-B1D-HqCd.mjs +1117 -0
- package/dist/command-utils-B1D-HqCd.mjs.map +1 -0
- package/dist/completion.d.mts +1 -1
- package/dist/completion.d.mts.map +1 -1
- package/dist/completion.mjs +77 -29
- package/dist/completion.mjs.map +1 -1
- package/dist/docs/index.d.mts +22 -2
- package/dist/docs/index.d.mts.map +1 -1
- package/dist/docs/index.mjs +94 -7
- package/dist/docs/index.mjs.map +1 -1
- package/dist/errors-BiVrBgi6.mjs +114 -0
- package/dist/errors-BiVrBgi6.mjs.map +1 -0
- package/dist/{formatter-ClUK5hcQ.d.mts → formatter-DtHzbP22.d.mts} +34 -5
- package/dist/formatter-DtHzbP22.d.mts.map +1 -0
- package/dist/help-bbmu9-qd.mjs +735 -0
- package/dist/help-bbmu9-qd.mjs.map +1 -0
- package/dist/index.d.mts +32 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +493 -265
- package/dist/index.mjs.map +1 -1
- package/dist/mcp-mLWIdUIu.mjs +379 -0
- package/dist/mcp-mLWIdUIu.mjs.map +1 -0
- package/dist/serve-B0u43DK7.mjs +404 -0
- package/dist/serve-B0u43DK7.mjs.map +1 -0
- package/dist/stream-BcC146Ud.mjs +56 -0
- package/dist/stream-BcC146Ud.mjs.map +1 -0
- package/dist/test.d.mts +1 -1
- package/dist/test.mjs +4 -15
- package/dist/test.mjs.map +1 -1
- package/dist/{types-DjIdJN5G.d.mts → types-Ch8Mk6Qb.d.mts} +310 -62
- package/dist/types-Ch8Mk6Qb.d.mts.map +1 -0
- package/dist/{update-check-EbNDkzyV.mjs → update-check-CFX1FV3v.mjs} +2 -2
- package/dist/{update-check-EbNDkzyV.mjs.map → update-check-CFX1FV3v.mjs.map} +1 -1
- package/dist/zod.d.mts +32 -0
- package/dist/zod.d.mts.map +1 -0
- package/dist/zod.mjs +50 -0
- package/dist/zod.mjs.map +1 -0
- package/package.json +10 -2
- package/src/args.ts +68 -40
- package/src/cli/docs.ts +1 -7
- package/src/cli/doctor.ts +195 -10
- package/src/cli/index.ts +1 -1
- package/src/cli/init.ts +2 -3
- package/src/cli/link.ts +2 -2
- package/src/codegen/discovery.ts +80 -28
- package/src/codegen/index.ts +2 -1
- package/src/codegen/parsers/bash.ts +179 -0
- package/src/codegen/schema-to-code.ts +2 -1
- package/src/colorizer.ts +126 -13
- package/src/command-utils.ts +380 -30
- package/src/completion.ts +120 -47
- package/src/create.ts +480 -128
- package/src/docs/index.ts +122 -8
- package/src/formatter.ts +171 -125
- package/src/help.ts +45 -12
- package/src/index.ts +29 -1
- package/src/interactive.ts +45 -4
- package/src/mcp.ts +390 -0
- package/src/repl-loop.ts +16 -3
- package/src/runtime.ts +195 -2
- package/src/serve.ts +442 -0
- package/src/stream.ts +75 -0
- package/src/test.ts +7 -16
- package/src/type-utils.ts +28 -4
- package/src/types.ts +212 -30
- package/src/wrap.ts +23 -25
- package/src/zod.ts +50 -0
- package/dist/args-CVDbyyzG.mjs.map +0 -1
- package/dist/chunk-y_GBKt04.mjs +0 -5
- package/dist/formatter-ClUK5hcQ.d.mts.map +0 -1
- package/dist/help-CcBe91bV.mjs +0 -1254
- package/dist/help-CcBe91bV.mjs.map +0 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
package/src/codegen/discovery.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
}
|
package/src/codegen/index.ts
CHANGED
|
@@ -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(
|
|
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'] };
|