@crafter/cli-tree 0.1.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/archaeology/cache.d.ts +11 -0
  4. package/dist/archaeology/delegate.d.ts +43 -0
  5. package/dist/archaeology/index.d.ts +12 -0
  6. package/dist/archaeology/index.js +61 -0
  7. package/dist/archaeology/index.js.map +9 -0
  8. package/dist/archaeology/llm.d.ts +1 -0
  9. package/dist/archaeology/merge.d.ts +3 -0
  10. package/dist/archaeology/orchestrator.d.ts +25 -0
  11. package/dist/archaeology/prompts.d.ts +13 -0
  12. package/dist/archaeology/types.d.ts +101 -0
  13. package/dist/archaeology/validate.d.ts +18 -0
  14. package/dist/chunk-57gtsvhb.js +434 -0
  15. package/dist/chunk-57gtsvhb.js.map +16 -0
  16. package/dist/chunk-5aahbfr2.js +293 -0
  17. package/dist/chunk-5aahbfr2.js.map +10 -0
  18. package/dist/chunk-pkfpaae1.js +678 -0
  19. package/dist/chunk-pkfpaae1.js.map +15 -0
  20. package/dist/chunk-q4se2rwe.js +181 -0
  21. package/dist/chunk-q4se2rwe.js.map +14 -0
  22. package/dist/chunk-v5w3w6bd.js +168 -0
  23. package/dist/chunk-v5w3w6bd.js.map +11 -0
  24. package/dist/chunk-ykze151b.js +770 -0
  25. package/dist/chunk-ykze151b.js.map +16 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +433 -0
  28. package/dist/cli.js.map +10 -0
  29. package/dist/encoders/ansi.d.ts +2 -0
  30. package/dist/encoders/html.d.ts +10 -0
  31. package/dist/encoders/string.d.ts +2 -0
  32. package/dist/flow/encode.d.ts +5 -0
  33. package/dist/flow/index.d.ts +8 -0
  34. package/dist/flow/index.js +25 -0
  35. package/dist/flow/index.js.map +9 -0
  36. package/dist/flow/layout.d.ts +30 -0
  37. package/dist/flow/parse.d.ts +2 -0
  38. package/dist/flow/render.d.ts +3 -0
  39. package/dist/flow/types.d.ts +42 -0
  40. package/dist/flow/validate.d.ts +3 -0
  41. package/dist/flow/yaml.d.ts +4 -0
  42. package/dist/grid.d.ts +14 -0
  43. package/dist/index.d.ts +7 -0
  44. package/dist/index.js +21 -0
  45. package/dist/index.js.map +9 -0
  46. package/dist/miner/history.d.ts +6 -0
  47. package/dist/miner/index.d.ts +18 -0
  48. package/dist/miner/index.js +38 -0
  49. package/dist/miner/index.js.map +9 -0
  50. package/dist/miner/sessions.d.ts +3 -0
  51. package/dist/miner/stats.d.ts +2 -0
  52. package/dist/miner/suggest.d.ts +11 -0
  53. package/dist/miner/transitions.d.ts +6 -0
  54. package/dist/miner/types.d.ts +46 -0
  55. package/dist/miner/workflows.d.ts +11 -0
  56. package/dist/parse.d.ts +3 -0
  57. package/dist/render.d.ts +3 -0
  58. package/dist/types.d.ts +39 -0
  59. package/package.json +85 -0
  60. package/skill/SKILL.md +263 -0
  61. package/skill/evals/evals.json +26 -0
  62. package/skill/install.sh +38 -0
  63. package/skill/references/archaeology-guide.md +157 -0
  64. package/skill/references/skill-template.md +120 -0
  65. package/src/archaeology/cache.ts +107 -0
  66. package/src/archaeology/delegate.ts +113 -0
  67. package/src/archaeology/index.ts +48 -0
  68. package/src/archaeology/llm.ts +10 -0
  69. package/src/archaeology/merge.ts +155 -0
  70. package/src/archaeology/orchestrator.ts +185 -0
  71. package/src/archaeology/prompts.ts +178 -0
  72. package/src/archaeology/types.ts +139 -0
  73. package/src/archaeology/validate.ts +157 -0
  74. package/src/cli.ts +451 -0
  75. package/src/encoders/ansi.ts +32 -0
  76. package/src/encoders/html.ts +78 -0
  77. package/src/encoders/string.ts +20 -0
  78. package/src/flow/encode.ts +21 -0
  79. package/src/flow/index.ts +15 -0
  80. package/src/flow/layout.ts +150 -0
  81. package/src/flow/parse.ts +100 -0
  82. package/src/flow/render.ts +186 -0
  83. package/src/flow/types.ts +45 -0
  84. package/src/flow/validate.ts +111 -0
  85. package/src/flow/yaml.ts +235 -0
  86. package/src/grid.ts +59 -0
  87. package/src/index.ts +24 -0
  88. package/src/miner/history.ts +156 -0
  89. package/src/miner/index.ts +76 -0
  90. package/src/miner/sessions.ts +39 -0
  91. package/src/miner/stats.ts +43 -0
  92. package/src/miner/suggest.ts +101 -0
  93. package/src/miner/transitions.ts +62 -0
  94. package/src/miner/types.ts +45 -0
  95. package/src/miner/workflows.ts +96 -0
  96. package/src/parse.ts +321 -0
  97. package/src/render.ts +182 -0
  98. package/src/types.ts +62 -0
  99. package/workflows/docker-deploy.yml +42 -0
  100. package/workflows/docker-parallel.yml +36 -0
  101. package/workflows/gh-issue-to-pr.yml +48 -0
  102. package/workflows/git-pr-flow.yml +36 -0
  103. package/workflows/kubectl-rollout.yml +37 -0
  104. package/workflows/npm-publish.yml +42 -0
@@ -0,0 +1,101 @@
1
+ import type { MinedWorkflow, CliUsageStats } from "./types";
2
+
3
+ export interface SkillSuggestion {
4
+ name: string;
5
+ description: string;
6
+ cli: string;
7
+ commands: string[];
8
+ frequency: number;
9
+ reason: string;
10
+ priority: "high" | "medium" | "low";
11
+ }
12
+
13
+ export function suggestSkills(
14
+ workflows: MinedWorkflow[],
15
+ stats: CliUsageStats,
16
+ ): SkillSuggestion[] {
17
+ const suggestions: SkillSuggestion[] = [];
18
+
19
+ for (const wf of workflows) {
20
+ const path = wf.path[0] ?? [];
21
+ if (path.length < 2) continue;
22
+
23
+ const suggestion = workflowToSuggestion(wf, stats);
24
+ if (suggestion) suggestions.push(suggestion);
25
+ }
26
+
27
+ return suggestions.sort((a, b) => {
28
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
29
+ if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
30
+ return priorityOrder[a.priority] - priorityOrder[b.priority];
31
+ }
32
+ return b.frequency - a.frequency;
33
+ });
34
+ }
35
+
36
+ function workflowToSuggestion(wf: MinedWorkflow, stats: CliUsageStats): SkillSuggestion | null {
37
+ const path = wf.path[0] ?? [];
38
+ if (path.length < 2) return null;
39
+
40
+ const commands = path.map(sub => `${wf.cli} ${sub}`);
41
+ const frequency = wf.support;
42
+
43
+ const priority = scorePriority(frequency, path.length, stats.totalInvocations);
44
+
45
+ const name = generateSkillName(wf.cli, path);
46
+ const description = generateDescription(wf.cli, path);
47
+ const reason = generateReason(frequency, path, stats);
48
+
49
+ return { name, description, cli: wf.cli, commands, frequency, reason, priority };
50
+ }
51
+
52
+ function scorePriority(frequency: number, pathLength: number, totalInvocations: number): "high" | "medium" | "low" {
53
+ const ratio = frequency / totalInvocations;
54
+ const score = frequency * pathLength * (ratio + 0.1);
55
+
56
+ if (score > 50) return "high";
57
+ if (score > 15) return "medium";
58
+ return "low";
59
+ }
60
+
61
+ function generateSkillName(cli: string, path: string[]): string {
62
+ const knownPatterns: Record<string, string> = {
63
+ "add,commit": "ship",
64
+ "add,commit,push": "ship",
65
+ "commit,push": "push-latest",
66
+ "install,dev": "start",
67
+ "install,run": "start",
68
+ "login,publish": "release",
69
+ "publish,tag": "release",
70
+ "checkout,pull": "sync",
71
+ "fetch,pull": "sync",
72
+ "build,push": "deploy",
73
+ "build,run": "test-build",
74
+ "compose,compose": "compose-iterate",
75
+ };
76
+
77
+ const key = path.join(",");
78
+ if (knownPatterns[key]) return knownPatterns[key]!;
79
+
80
+ return path.join("-");
81
+ }
82
+
83
+ function generateDescription(cli: string, path: string[]): string {
84
+ return `Run ${cli} ${path.join(", then ")} as one command`;
85
+ }
86
+
87
+ function generateReason(frequency: number, path: string[], stats: CliUsageStats): string {
88
+ const ratio = frequency / stats.totalInvocations;
89
+ const pct = (ratio * 100).toFixed(0);
90
+
91
+ if (ratio > 0.1) {
92
+ return `You run this exact sequence ${frequency} times (${pct}% of all your ${stats.cli} usage)`;
93
+ }
94
+ if (frequency >= 50) {
95
+ return `You run this exact sequence ${frequency} times — that's a lot of repetition`;
96
+ }
97
+ if (frequency >= 20) {
98
+ return `You run this exact sequence ${frequency} times`;
99
+ }
100
+ return `Detected ${frequency} times in your shell history`;
101
+ }
@@ -0,0 +1,62 @@
1
+ import type { Session, Transition, HistoryEntry } from "./types";
2
+
3
+ export function extractSubcommand(entry: HistoryEntry): string {
4
+ const [, ...rest] = entry.argv;
5
+ const sub = rest.find(arg => !arg.startsWith("-") && !arg.startsWith("$") && /^[a-z]/i.test(arg));
6
+ return sub ?? "(root)";
7
+ }
8
+
9
+ export function extractSubcommandPath(entry: HistoryEntry, maxDepth = 3): string[] {
10
+ const [, ...rest] = entry.argv;
11
+ const path: string[] = [];
12
+ for (const arg of rest) {
13
+ if (arg.startsWith("-") || arg.startsWith("$")) continue;
14
+ if (!/^[a-z]/i.test(arg)) break;
15
+ path.push(arg);
16
+ if (path.length >= maxDepth) break;
17
+ }
18
+ return path;
19
+ }
20
+
21
+ export function buildTransitions(sessions: Session[]): Map<string, Map<string, number>> {
22
+ const transitions = new Map<string, Map<string, number>>();
23
+
24
+ for (const session of sessions) {
25
+ for (let i = 0; i < session.entries.length - 1; i++) {
26
+ const from = extractSubcommand(session.entries[i]!);
27
+ const to = extractSubcommand(session.entries[i + 1]!);
28
+
29
+ if (!transitions.has(from)) transitions.set(from, new Map());
30
+ const inner = transitions.get(from)!;
31
+ inner.set(to, (inner.get(to) ?? 0) + 1);
32
+ }
33
+ }
34
+
35
+ return transitions;
36
+ }
37
+
38
+ export function normalizeTransitions(
39
+ raw: Map<string, Map<string, number>>,
40
+ minSupport = 3,
41
+ minConfidence = 0.2,
42
+ ): Transition[] {
43
+ const results: Transition[] = [];
44
+
45
+ for (const [from, nexts] of raw.entries()) {
46
+ const total = Array.from(nexts.values()).reduce((a, b) => a + b, 0);
47
+ if (total < minSupport) continue;
48
+
49
+ for (const [to, count] of nexts.entries()) {
50
+ if (count < minSupport) continue;
51
+ const confidence = count / total;
52
+ if (confidence < minConfidence) continue;
53
+ results.push({ from, to, count, confidence });
54
+ }
55
+ }
56
+
57
+ return results.sort((a, b) => b.count - a.count);
58
+ }
59
+
60
+ export function topTransitions(transitions: Transition[], n = 10): Transition[] {
61
+ return transitions.slice(0, n);
62
+ }
@@ -0,0 +1,45 @@
1
+ export interface HistoryEntry {
2
+ timestamp: number;
3
+ raw: string;
4
+ argv: string[];
5
+ }
6
+
7
+ export interface Session {
8
+ start: number;
9
+ end: number;
10
+ entries: HistoryEntry[];
11
+ }
12
+
13
+ export interface Transition {
14
+ from: string;
15
+ to: string;
16
+ count: number;
17
+ confidence: number;
18
+ }
19
+
20
+ export interface MinedWorkflow {
21
+ name: string;
22
+ cli: string;
23
+ path: string[][];
24
+ support: number;
25
+ confidence: number;
26
+ source: "history" | "llm" | "makefile";
27
+ }
28
+
29
+ export interface CliUsageStats {
30
+ cli: string;
31
+ totalInvocations: number;
32
+ uniqueSubcommands: number;
33
+ topSubcommands: Array<{ subcommand: string; count: number }>;
34
+ topFlags: Array<{ flag: string; count: number }>;
35
+ sessionCount: number;
36
+ }
37
+
38
+ export interface MineOptions {
39
+ sessionGapMinutes?: number;
40
+ minSupport?: number;
41
+ minConfidence?: number;
42
+ minPathLength?: number;
43
+ maxPathLength?: number;
44
+ historyPath?: string;
45
+ }
@@ -0,0 +1,96 @@
1
+ import type { Session, MinedWorkflow } from "./types";
2
+ import { extractSubcommand } from "./transitions";
3
+
4
+ export interface PathCluster {
5
+ signature: string;
6
+ path: string[];
7
+ occurrences: number;
8
+ }
9
+
10
+ export function extractPaths(
11
+ sessions: Session[],
12
+ minLength = 2,
13
+ maxLength = 7,
14
+ ): PathCluster[] {
15
+ const pathCounts = new Map<string, { path: string[]; count: number }>();
16
+
17
+ for (const session of sessions) {
18
+ const subcommands = session.entries.map(extractSubcommand);
19
+ const dedupedConsecutive = dedupeConsecutive(subcommands);
20
+
21
+ for (let start = 0; start < dedupedConsecutive.length; start++) {
22
+ const maxEnd = Math.min(start + maxLength, dedupedConsecutive.length);
23
+ for (let end = start + minLength; end <= maxEnd; end++) {
24
+ const slice = dedupedConsecutive.slice(start, end);
25
+ const sig = slice.join(" → ");
26
+ const existing = pathCounts.get(sig);
27
+ if (existing) {
28
+ existing.count += 1;
29
+ } else {
30
+ pathCounts.set(sig, { path: slice, count: 1 });
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ return Array.from(pathCounts.entries())
37
+ .map(([signature, { path, count }]) => ({
38
+ signature,
39
+ path,
40
+ occurrences: count,
41
+ }))
42
+ .sort((a, b) => {
43
+ if (b.occurrences !== a.occurrences) return b.occurrences - a.occurrences;
44
+ return b.path.length - a.path.length;
45
+ });
46
+ }
47
+
48
+ export function clusterIntoWorkflows(
49
+ clusters: PathCluster[],
50
+ cli: string,
51
+ options: { minSupport?: number; topK?: number } = {},
52
+ ): MinedWorkflow[] {
53
+ const minSupport = options.minSupport ?? 2;
54
+ const topK = options.topK ?? 10;
55
+
56
+ const filtered = clusters.filter(c => c.occurrences >= minSupport);
57
+ const deduped = removeSubPaths(filtered);
58
+
59
+ const totalOccurrences = clusters.reduce((sum, c) => sum + c.occurrences, 0) || 1;
60
+
61
+ return deduped.slice(0, topK).map(cluster => ({
62
+ name: cluster.path.join("-"),
63
+ cli,
64
+ path: [cluster.path],
65
+ support: cluster.occurrences,
66
+ confidence: cluster.occurrences / totalOccurrences,
67
+ source: "history" as const,
68
+ }));
69
+ }
70
+
71
+ function dedupeConsecutive(items: string[]): string[] {
72
+ const result: string[] = [];
73
+ for (const item of items) {
74
+ if (result.length === 0 || result[result.length - 1] !== item) {
75
+ result.push(item);
76
+ }
77
+ }
78
+ return result;
79
+ }
80
+
81
+ function removeSubPaths(clusters: PathCluster[]): PathCluster[] {
82
+ const sortedByLength = [...clusters].sort((a, b) => b.path.length - a.path.length);
83
+ const kept: PathCluster[] = [];
84
+
85
+ for (const cluster of sortedByLength) {
86
+ const sig = cluster.signature;
87
+ const alreadyCoveredBy = kept.find(k =>
88
+ k.signature.includes(sig) && k.occurrences >= cluster.occurrences * 0.8,
89
+ );
90
+ if (!alreadyCoveredBy) {
91
+ kept.push(cluster);
92
+ }
93
+ }
94
+
95
+ return kept.sort((a, b) => b.occurrences - a.occurrences);
96
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,321 @@
1
+ import type { CLINode, Flag, Arg } from "./types";
2
+
3
+ export function parseHelp(name: string, helpText: string): CLINode {
4
+ const lines = helpText.split("\n");
5
+ const node: CLINode = { name };
6
+
7
+ const descLine = findDescription(lines);
8
+ if (descLine) node.description = descLine;
9
+
10
+ node.subcommands = parseCommands(lines);
11
+ node.flags = parseFlags(lines);
12
+ node.args = parseUsageArgs(lines);
13
+
14
+ if (node.subcommands.length === 0) delete node.subcommands;
15
+ if (node.flags.length === 0) delete node.flags;
16
+ if (node.args.length === 0) delete node.args;
17
+
18
+ return node;
19
+ }
20
+
21
+ function findDescription(lines: string[]): string | undefined {
22
+ for (const line of lines) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed) continue;
25
+ if (/^(usage|commands|flags|options|available|management|version|\$|the commands)/i.test(trimmed)) continue;
26
+ if (/^[-\s]/.test(trimmed)) continue;
27
+ if (/^(USAGE|CORE|GITHUB|ADDITIONAL|HELP|EXTENSION)/i.test(trimmed)) continue;
28
+ if (trimmed.length > 10 && trimmed.length < 200 && !trimmed.startsWith("--") && !trimmed.startsWith("{")) {
29
+ return trimmed.replace(/[.:]+$/, "").trim();
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function parseCommands(lines: string[]): CLINode[] {
36
+ const commands: CLINode[] = [];
37
+ const seen = new Set<string>();
38
+ let inSection = false;
39
+
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i]!;
42
+ const trimmed = line.trim();
43
+
44
+ if (isCommandSectionHeader(trimmed)) {
45
+ inSection = true;
46
+ continue;
47
+ }
48
+
49
+ if (isNpmAllCommands(trimmed)) {
50
+ parseNpmCommandList(lines, i, commands, seen);
51
+ continue;
52
+ }
53
+
54
+ if (isGoCommandLine(line)) {
55
+ const match = line.match(/^\t(\S+)\s{2,}(.+)/);
56
+ if (match && !seen.has(match[1]!)) {
57
+ seen.add(match[1]!);
58
+ commands.push({ name: match[1]!, description: match[2]!.trim() });
59
+ }
60
+ continue;
61
+ }
62
+
63
+ if (inSection) {
64
+ if (!trimmed) {
65
+ inSection = false;
66
+ continue;
67
+ }
68
+
69
+ if (isNonCommandSection(trimmed)) {
70
+ inSection = false;
71
+ continue;
72
+ }
73
+
74
+ const cmd = parseCommandLine(trimmed);
75
+ if (cmd && !seen.has(cmd.name)) {
76
+ seen.add(cmd.name);
77
+ commands.push(cmd);
78
+ }
79
+ }
80
+ }
81
+
82
+ return commands;
83
+ }
84
+
85
+ function isCommandSectionHeader(line: string): boolean {
86
+ const cleaned = line.replace(/[:\s]+$/, "").toLowerCase();
87
+ return /^(commands|common commands|management commands|available commands|subcommands|core commands|github actions commands|additional commands|extension commands|help commands|alias commands)/.test(cleaned)
88
+ || /^(basic commands|deploy commands|cluster management|troubleshooting|advanced commands|settings commands|configuration|manage your dependencies|review your dependencies|run your scripts|other)/.test(cleaned)
89
+ || /^(start a working area|work on the current change|examine the history|grow.*mark.*tweak|collaborate|ancillary commands|interacting with others|low-level commands)/i.test(cleaned);
90
+ }
91
+
92
+ function isNonCommandSection(line: string): boolean {
93
+ const cleaned = line.replace(/[:\s]+$/, "").toLowerCase();
94
+ return /^(flags|options|usage|examples|environment|learn more|global|see '|find more|use "|\$|additional help|inherited)/.test(cleaned);
95
+ }
96
+
97
+ function parseCommandLine(line: string): CLINode | null {
98
+ let match = line.match(/^(?:(\w{1,4}),\s+)?(\S+?)[:,]?\s{2,}(.+)/);
99
+ if (match) {
100
+ const alias = match[1];
101
+ const name = match[2]!.replace(/[*:,]+$/, "");
102
+ const desc = match[3]!.trim();
103
+ if (name.startsWith("-") || name.length === 0) return null;
104
+ const node: CLINode = { name, description: desc };
105
+ if (alias) node.aliases = [alias];
106
+ return node;
107
+ }
108
+
109
+ match = line.match(/^(\S+)\s{2,}(.+)/);
110
+ if (match) {
111
+ const name = match[1]!.replace(/[*:,]+$/, "");
112
+ if (name.startsWith("-") || name.length === 0) return null;
113
+ return { name, description: match[2]!.trim() };
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function isGoCommandLine(line: string): boolean {
120
+ return /^\t\S+\s{2,}/.test(line);
121
+ }
122
+
123
+ function isNpmAllCommands(line: string): boolean {
124
+ return /^all commands/i.test(line.trim().replace(":", ""));
125
+ }
126
+
127
+ function parseNpmCommandList(lines: string[], startIdx: number, commands: CLINode[], seen: Set<string>) {
128
+ let foundAny = false;
129
+ for (let i = startIdx + 1; i < lines.length; i++) {
130
+ const line = lines[i]!.trim();
131
+ if (!line) {
132
+ if (foundAny) break;
133
+ continue;
134
+ }
135
+ if (/^specify|^more|^configuration|^npm@|^or on/i.test(line)) break;
136
+ const names = line.split(/[,\s]+/).filter(n => n.length > 0 && !n.startsWith("-") && /^[a-z]/.test(n));
137
+ for (const name of names) {
138
+ if (!seen.has(name)) {
139
+ seen.add(name);
140
+ commands.push({ name });
141
+ foundAny = true;
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ function parseFlags(lines: string[]): Flag[] {
148
+ const flags: Flag[] = [];
149
+ const seen = new Set<string>();
150
+ let inFlagSection = false;
151
+
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const line = lines[i]!;
154
+ const trimmed = line.trim();
155
+
156
+ if (/^(flags|options|global flags|global options)/i.test(trimmed.replace(/[:\s]+$/, ""))) {
157
+ inFlagSection = true;
158
+ continue;
159
+ }
160
+
161
+ if (inFlagSection) {
162
+ if (!trimmed) {
163
+ if (i + 1 < lines.length) {
164
+ const next = lines[i + 1]?.trim() ?? "";
165
+ if (next && !next.startsWith("-") && !/^\s/.test(lines[i + 1] ?? "")) {
166
+ inFlagSection = false;
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+
172
+ if (isCommandSectionHeader(trimmed) || isNonCommandSection(trimmed)) {
173
+ if (!/^(flags|options|global)/i.test(trimmed.replace(/[:\s]+$/, ""))) {
174
+ inFlagSection = false;
175
+ continue;
176
+ }
177
+ }
178
+
179
+ const flag = parseFlagLine(trimmed);
180
+ if (flag && !seen.has(flag.name)) {
181
+ seen.add(flag.name);
182
+ flags.push(flag);
183
+ }
184
+ }
185
+ }
186
+
187
+ return flags;
188
+ }
189
+
190
+ function parseFlagLine(line: string): Flag | null {
191
+ let match = line.match(/^\s*(-(\w),\s+)?--(\S+?)(?:=<([^>]+)>|[= ]<([^>]+)>|\s+<([^>]+)>)?\s{2,}(.*)/);
192
+ if (match) {
193
+ const short = match[2];
194
+ const name = match[3]!.replace(/[=,]+$/, "");
195
+ const valHint = match[4] || match[5] || match[6];
196
+ const desc = match[7]?.trim();
197
+ return buildFlag(name, short, valHint, desc);
198
+ }
199
+
200
+ match = line.match(/^\s*(-(\w),\s+)?--([\w-]+)\s*(.*)/);
201
+ if (match) {
202
+ const short = match[2];
203
+ const name = match[3]!;
204
+ const rest = match[4]?.trim() ?? "";
205
+ const flag: Flag = { name, type: "boolean" };
206
+ if (short) flag.short = short;
207
+ if (rest) flag.description = rest;
208
+ return flag;
209
+ }
210
+
211
+ match = line.match(/^\s*-(\w),\s+--?([\w-]+)\s*(.*)/);
212
+ if (match) {
213
+ const short = match[1];
214
+ const name = match[2]!;
215
+ const rest = match[3]?.trim() ?? "";
216
+ const flag: Flag = { name, type: "boolean" };
217
+ if (short) flag.short = short;
218
+ if (rest) flag.description = rest;
219
+ return flag;
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ function buildFlag(name: string, short?: string, valHint?: string, desc?: string): Flag {
226
+ let type: Flag["type"] = "boolean";
227
+ if (valHint) {
228
+ if (/^(num|int|count)/i.test(valHint)) type = "number";
229
+ else type = "string";
230
+ }
231
+
232
+ const flag: Flag = { name, type };
233
+ if (short) flag.short = short;
234
+ if (desc) flag.description = desc;
235
+ return flag;
236
+ }
237
+
238
+ function parseUsageArgs(lines: string[]): Arg[] {
239
+ const args: Arg[] = [];
240
+
241
+ for (const line of lines) {
242
+ if (!/^usage/i.test(line.trim())) continue;
243
+
244
+ const argMatches = line.matchAll(/<(\w+(?:\.\.\.)?)>|\[(\w+(?:\.\.\.)?)\]/g);
245
+ for (const m of argMatches) {
246
+ if (m[1]) {
247
+ const name = m[1].replace("...", "");
248
+ const variadic = m[1].endsWith("...");
249
+ if (!isKnownPlaceholder(name)) {
250
+ args.push({ name, required: true, variadic });
251
+ }
252
+ } else if (m[2]) {
253
+ const name = m[2].replace("...", "");
254
+ const variadic = m[2].endsWith("...");
255
+ if (!isKnownPlaceholder(name)) {
256
+ args.push({ name, required: false, variadic });
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ return args;
263
+ }
264
+
265
+ function isKnownPlaceholder(name: string): boolean {
266
+ return /^(path|name|value|envvar|val)$/i.test(name);
267
+ }
268
+
269
+ export async function parseHelpRecursive(
270
+ binary: string,
271
+ args: string[] = [],
272
+ maxDepth = 3,
273
+ depth = 0,
274
+ ): Promise<CLINode> {
275
+ const helpText = await runHelp(binary, args);
276
+ const name = args.length > 0 ? args[args.length - 1]! : binary;
277
+ const node = parseHelp(name, helpText);
278
+
279
+ if (depth >= maxDepth) return node;
280
+
281
+ if (node.subcommands) {
282
+ const resolved: CLINode[] = [];
283
+ for (const sub of node.subcommands) {
284
+ try {
285
+ const child = await parseHelpRecursive(binary, [...args, sub.name], maxDepth, depth + 1);
286
+ if (sub.description && !child.description) child.description = sub.description;
287
+ if (sub.aliases) child.aliases = sub.aliases;
288
+ resolved.push(child);
289
+ } catch {
290
+ resolved.push(sub);
291
+ }
292
+ }
293
+ node.subcommands = resolved;
294
+ }
295
+
296
+ return node;
297
+ }
298
+
299
+ async function runHelp(binary: string, args: string[]): Promise<string> {
300
+ const proc = Bun.spawn([binary, ...args, "--help"], {
301
+ stdout: "pipe",
302
+ stderr: "pipe",
303
+ });
304
+
305
+ const timeout = setTimeout(() => proc.kill(), 5000);
306
+
307
+ const [stdout, stderr] = await Promise.all([
308
+ new Response(proc.stdout).text(),
309
+ new Response(proc.stderr).text(),
310
+ ]);
311
+
312
+ clearTimeout(timeout);
313
+ await proc.exited;
314
+
315
+ const output = stdout || stderr;
316
+ if (!output.trim()) {
317
+ throw new Error(`No help output from: ${binary} ${args.join(" ")} --help`);
318
+ }
319
+
320
+ return output;
321
+ }