padrone 1.0.0 → 1.2.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 +51 -0
- package/LICENSE +1 -1
- package/README.md +92 -49
- package/dist/args-CKNh7Dm9.mjs +175 -0
- package/dist/args-CKNh7Dm9.mjs.map +1 -0
- package/dist/chunk-y_GBKt04.mjs +5 -0
- package/dist/codegen/index.d.mts +305 -0
- package/dist/codegen/index.d.mts.map +1 -0
- package/dist/codegen/index.mjs +1348 -0
- package/dist/codegen/index.mjs.map +1 -0
- package/dist/completion.d.mts +64 -0
- package/dist/completion.d.mts.map +1 -0
- package/dist/completion.mjs +417 -0
- package/dist/completion.mjs.map +1 -0
- package/dist/docs/index.d.mts +34 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +404 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/formatter-Dvx7jFXr.d.mts +82 -0
- package/dist/formatter-Dvx7jFXr.d.mts.map +1 -0
- package/dist/help-mUIX0T0V.mjs +1195 -0
- package/dist/help-mUIX0T0V.mjs.map +1 -0
- package/dist/index.d.mts +122 -438
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1240 -1161
- package/dist/index.mjs.map +1 -1
- package/dist/test.d.mts +112 -0
- package/dist/test.d.mts.map +1 -0
- package/dist/test.mjs +138 -0
- package/dist/test.mjs.map +1 -0
- package/dist/types-qrtt0135.d.mts +1037 -0
- package/dist/types-qrtt0135.d.mts.map +1 -0
- package/dist/update-check-EbNDkzyV.mjs +146 -0
- package/dist/update-check-EbNDkzyV.mjs.map +1 -0
- package/package.json +61 -20
- package/src/args.ts +365 -0
- package/src/cli/completions.ts +29 -0
- package/src/cli/docs.ts +86 -0
- package/src/cli/doctor.ts +312 -0
- package/src/cli/index.ts +159 -0
- package/src/cli/init.ts +135 -0
- package/src/cli/link.ts +320 -0
- package/src/cli/wrap.ts +152 -0
- package/src/codegen/README.md +118 -0
- package/src/codegen/code-builder.ts +226 -0
- package/src/codegen/discovery.ts +232 -0
- package/src/codegen/file-emitter.ts +73 -0
- package/src/codegen/generators/barrel-file.ts +16 -0
- package/src/codegen/generators/command-file.ts +184 -0
- package/src/codegen/generators/command-tree.ts +124 -0
- package/src/codegen/index.ts +33 -0
- package/src/codegen/parsers/fish.ts +163 -0
- package/src/codegen/parsers/help.ts +378 -0
- package/src/codegen/parsers/merge.ts +158 -0
- package/src/codegen/parsers/zsh.ts +221 -0
- package/src/codegen/schema-to-code.ts +199 -0
- package/src/codegen/template.ts +69 -0
- package/src/codegen/types.ts +143 -0
- package/src/colorizer.ts +2 -2
- package/src/command-utils.ts +501 -0
- package/src/completion.ts +110 -97
- package/src/create.ts +1044 -284
- package/src/docs/index.ts +607 -0
- package/src/errors.ts +131 -0
- package/src/formatter.ts +149 -63
- package/src/help.ts +151 -55
- package/src/index.ts +13 -15
- package/src/interactive.ts +169 -0
- package/src/parse.ts +31 -16
- package/src/repl-loop.ts +317 -0
- package/src/runtime.ts +304 -0
- package/src/shell-utils.ts +83 -0
- package/src/test.ts +285 -0
- package/src/type-helpers.ts +12 -12
- package/src/type-utils.ts +124 -14
- package/src/types.ts +803 -144
- package/src/update-check.ts +244 -0
- package/src/wrap.ts +185 -0
- package/src/zod.d.ts +2 -2
- package/src/options.ts +0 -180
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type { CommandMeta, FieldMeta } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
interface ParseHelpOptions {
|
|
4
|
+
/** Name to use for the root command if not detected from the help text */
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type Section = 'none' | 'usage' | 'commands' | 'options' | 'arguments' | 'positional' | 'aliases' | 'skip';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse --help text output into CommandMeta.
|
|
12
|
+
* Handles common styles: GNU coreutils, Go cobra, Python argparse, Node commander/yargs, gh CLI.
|
|
13
|
+
*/
|
|
14
|
+
export function parseHelpOutput(text: string, options?: ParseHelpOptions): CommandMeta {
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const result: CommandMeta = {
|
|
17
|
+
name: options?.name || '',
|
|
18
|
+
arguments: [],
|
|
19
|
+
positionals: [],
|
|
20
|
+
subcommands: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let section: Section = 'none';
|
|
24
|
+
|
|
25
|
+
// Try to extract name from USAGE line
|
|
26
|
+
// Matches: "Usage: mycli", "USAGE\n gh <command>", "Usage:\n myapp deploy [flags]"
|
|
27
|
+
const usageMatch = text.match(/^[Uu](?:SAGE|sage):?\s*(\S+)/m) || text.match(/^USAGE\n\s+(\S+)/m);
|
|
28
|
+
if (usageMatch && !result.name) {
|
|
29
|
+
result.name = usageMatch[1]!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Extract description from lines before first section header
|
|
33
|
+
const descriptionLines: string[] = [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed) {
|
|
37
|
+
if (descriptionLines.length > 0) break;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (isSectionHeader(trimmed)) break;
|
|
41
|
+
if (descriptionLines.length === 0 || descriptionLines.length > 0) {
|
|
42
|
+
descriptionLines.push(trimmed);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (descriptionLines.length > 0) {
|
|
46
|
+
result.description = descriptionLines.join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i]!;
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
|
|
53
|
+
if (!trimmed) continue;
|
|
54
|
+
|
|
55
|
+
// Detect section headers
|
|
56
|
+
const sectionType = detectSection(trimmed);
|
|
57
|
+
if (sectionType) {
|
|
58
|
+
section = sectionType;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse content based on current section
|
|
63
|
+
switch (section) {
|
|
64
|
+
case 'commands': {
|
|
65
|
+
const cmd = parseCommandLine(line);
|
|
66
|
+
if (cmd) {
|
|
67
|
+
result.subcommands!.push(cmd);
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'options': {
|
|
72
|
+
const field = parseOptionLine(line);
|
|
73
|
+
if (field) {
|
|
74
|
+
result.arguments!.push(field);
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'arguments':
|
|
79
|
+
case 'positional': {
|
|
80
|
+
const field = parsePositionalLine(line);
|
|
81
|
+
if (field) {
|
|
82
|
+
result.positionals!.push(field);
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case 'aliases': {
|
|
87
|
+
const aliasMatch = trimmed.match(/^\S+(?:\s+\S+)*$/);
|
|
88
|
+
if (aliasMatch) {
|
|
89
|
+
// Extract alias from lines like "gh pr ls" — the last word is the alias name
|
|
90
|
+
const parts = trimmed.split(/\s+/);
|
|
91
|
+
if (parts.length >= 2) {
|
|
92
|
+
const alias = parts[parts.length - 1]!;
|
|
93
|
+
if (!result.aliases) result.aliases = [];
|
|
94
|
+
result.aliases.push(alias);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
// 'skip', 'usage', 'none' — do nothing
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Clean up empty arrays
|
|
104
|
+
if (result.arguments!.length === 0) delete result.arguments;
|
|
105
|
+
if (result.positionals!.length === 0) delete result.positionals;
|
|
106
|
+
if (result.subcommands!.length === 0) delete result.subcommands;
|
|
107
|
+
if (result.aliases?.length === 0) delete result.aliases;
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isSectionHeader(line: string): boolean {
|
|
113
|
+
// "USAGE", "FLAGS", "CORE COMMANDS", "Options:", "Available Commands:"
|
|
114
|
+
return /^[A-Z][A-Za-z\s]*:?\s*$/i.test(line) || /^[A-Z][A-Z\s]+$/i.test(line);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function detectSection(line: string): Section | null {
|
|
118
|
+
const lower = line.toLowerCase().replace(/:$/, '').trim();
|
|
119
|
+
|
|
120
|
+
// Commands: "commands", "available commands", "subcommands",
|
|
121
|
+
// and gh-style: "core commands", "general commands", "additional commands", etc.
|
|
122
|
+
// Match anything ending in "commands" or "subcommands", but NOT "alias commands"
|
|
123
|
+
if (/^(?:available\s+)?(?:commands|subcommands)$/.test(lower)) return 'commands';
|
|
124
|
+
if (/^(?:\w+\s+)*commands$/.test(lower) && !/alias/.test(lower)) return 'commands';
|
|
125
|
+
|
|
126
|
+
// Options/Flags: "options", "flags", "global options", "inherited flags"
|
|
127
|
+
if (/^(?:global\s+|inherited\s+)?(?:options|flags)$/.test(lower)) return 'options';
|
|
128
|
+
|
|
129
|
+
// Positional arguments
|
|
130
|
+
if (/^(?:positional\s+)?(?:arguments|args|positionals)$/.test(lower)) return 'positional';
|
|
131
|
+
|
|
132
|
+
// Aliases section
|
|
133
|
+
if (/^alias(?:es)?(?:\s+commands)?$/.test(lower)) return 'aliases';
|
|
134
|
+
|
|
135
|
+
// Usage section
|
|
136
|
+
if (lower === 'usage') return 'usage';
|
|
137
|
+
|
|
138
|
+
// Sections to skip entirely
|
|
139
|
+
if (/^(?:help\s+topics|examples?|learn\s+more|json\s+fields|see\s+also|notes?)$/.test(lower)) return 'skip';
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseCommandLine(line: string): CommandMeta | null {
|
|
145
|
+
// Pattern: " command-name: Description text" (gh-style with colon)
|
|
146
|
+
const colonMatch = line.match(/^\s{2,}([\w][\w-]*):?\s{2,}(.+)$/);
|
|
147
|
+
if (colonMatch) {
|
|
148
|
+
const name = colonMatch[1]!.replace(/:$/, '');
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
description: colonMatch[2]!.trim(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pattern: " command-name" (no description)
|
|
156
|
+
const nameOnly = line.match(/^\s{2,}([\w][\w-]*):?\s*$/);
|
|
157
|
+
if (nameOnly) {
|
|
158
|
+
return { name: nameOnly[1]!.replace(/:$/, '') };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseOptionLine(line: string): FieldMeta | null {
|
|
165
|
+
// Try cobra-style first: " -s, --flag type Description" or " --flag type Description"
|
|
166
|
+
// The type hint can be a bare word (string, int) or a complex placeholder ([HOST/]OWNER/REPO)
|
|
167
|
+
// but NOT angle-bracket values like <string> (those fall through to GNU pattern)
|
|
168
|
+
const cobraMatch = line.match(/^\s{2,}(?:(-\w),\s+)?(-{1,2}[\w-]+)(?:\s+([^\s<>]+))?\s{2,}(.+)$/);
|
|
169
|
+
|
|
170
|
+
if (cobraMatch) {
|
|
171
|
+
const shortFlag = cobraMatch[1];
|
|
172
|
+
const longFlag = cobraMatch[2]!;
|
|
173
|
+
const typeHint = cobraMatch[3];
|
|
174
|
+
const description = cobraMatch[4]?.trim();
|
|
175
|
+
|
|
176
|
+
const name = normalizeOptionName(longFlag);
|
|
177
|
+
const aliases = shortFlag ? [normalizeAlias(shortFlag)] : undefined;
|
|
178
|
+
|
|
179
|
+
const { type, ambiguous } = resolveType(typeHint);
|
|
180
|
+
|
|
181
|
+
const { defaultValue, resolvedType } = extractDefault(description, type, ambiguous);
|
|
182
|
+
const { enumValues, resolvedType: finalType } = extractEnum(description, resolvedType);
|
|
183
|
+
|
|
184
|
+
return reconcileField({
|
|
185
|
+
name,
|
|
186
|
+
type: finalType,
|
|
187
|
+
description,
|
|
188
|
+
required: type === 'boolean' ? undefined : true,
|
|
189
|
+
aliases,
|
|
190
|
+
default: defaultValue,
|
|
191
|
+
enumValues,
|
|
192
|
+
ambiguous: ambiguous || undefined,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GNU/argparse-style: " -s, --long-name <value> Description"
|
|
197
|
+
// " --long-name=<value> Description"
|
|
198
|
+
const gnuMatch = line.match(/^\s{2,}(?:(-\w),?\s+)?(-{1,2}[\w-]+)(?:\s*[=\s]\s*(?:<([^>]+)>|\[([^\]]+)\]|(\w+)))?\s{2,}(.+)$/);
|
|
199
|
+
|
|
200
|
+
if (gnuMatch) {
|
|
201
|
+
const shortFlag = gnuMatch[1];
|
|
202
|
+
const longFlag = gnuMatch[2]!;
|
|
203
|
+
const valueName = gnuMatch[3] || gnuMatch[4] || gnuMatch[5];
|
|
204
|
+
const description = gnuMatch[6]?.trim();
|
|
205
|
+
|
|
206
|
+
const name = normalizeOptionName(longFlag);
|
|
207
|
+
const aliases = shortFlag ? [normalizeAlias(shortFlag)] : undefined;
|
|
208
|
+
|
|
209
|
+
const { type, ambiguous } = resolveType(valueName);
|
|
210
|
+
|
|
211
|
+
const { defaultValue, resolvedType } = extractDefault(description, type, ambiguous);
|
|
212
|
+
const { enumValues, resolvedType: finalType } = extractEnum(description, resolvedType);
|
|
213
|
+
|
|
214
|
+
// [value] means optional, <value> means required
|
|
215
|
+
const required = !gnuMatch[4];
|
|
216
|
+
|
|
217
|
+
return reconcileField({
|
|
218
|
+
name,
|
|
219
|
+
type: finalType,
|
|
220
|
+
description,
|
|
221
|
+
required: finalType === 'boolean' ? undefined : required,
|
|
222
|
+
aliases,
|
|
223
|
+
default: defaultValue,
|
|
224
|
+
enumValues,
|
|
225
|
+
ambiguous: ambiguous || undefined,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Simplest pattern: " --name Description"
|
|
230
|
+
const simple = line.match(/^\s{2,}(-{1,2}[\w-]+)\s{2,}(.+)$/);
|
|
231
|
+
if (simple) {
|
|
232
|
+
const name = normalizeOptionName(simple[1]!);
|
|
233
|
+
return {
|
|
234
|
+
name,
|
|
235
|
+
type: 'boolean',
|
|
236
|
+
description: simple[2]!.trim(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve a type hint string to a FieldMeta type.
|
|
245
|
+
*/
|
|
246
|
+
function resolveType(hint: string | undefined): { type: FieldMeta['type']; ambiguous: boolean } {
|
|
247
|
+
if (!hint) return { type: 'boolean', ambiguous: false };
|
|
248
|
+
|
|
249
|
+
const lower = hint.toLowerCase();
|
|
250
|
+
|
|
251
|
+
if (/^(num|number|int|integer|port|count|float|duration)$/.test(lower)) {
|
|
252
|
+
return { type: 'number', ambiguous: false };
|
|
253
|
+
}
|
|
254
|
+
if (/^(str|string|text|name|path|file|dir|url|host|query|expression|template)$/.test(lower)) {
|
|
255
|
+
return { type: 'string', ambiguous: false };
|
|
256
|
+
}
|
|
257
|
+
if (/^(bool|boolean)$/.test(lower)) {
|
|
258
|
+
return { type: 'boolean', ambiguous: false };
|
|
259
|
+
}
|
|
260
|
+
if (/^(strings|fields)$/.test(lower)) {
|
|
261
|
+
return { type: 'array', ambiguous: false };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Unknown type hint — default to string but mark ambiguous
|
|
265
|
+
return { type: 'string', ambiguous: true };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract default value from description text.
|
|
270
|
+
*/
|
|
271
|
+
function extractDefault(
|
|
272
|
+
description: string | undefined,
|
|
273
|
+
type: FieldMeta['type'],
|
|
274
|
+
ambiguous: boolean,
|
|
275
|
+
): { defaultValue: unknown; resolvedType: FieldMeta['type'] } {
|
|
276
|
+
let resolvedType = type;
|
|
277
|
+
let defaultValue: unknown;
|
|
278
|
+
|
|
279
|
+
const defaultMatch = description?.match(/\(default[:\s]+([^)]+)\)/i) || description?.match(/\[default[:\s]+([^\]]+)\]/i);
|
|
280
|
+
if (defaultMatch) {
|
|
281
|
+
const raw = defaultMatch[1]!.trim();
|
|
282
|
+
if (raw === 'true' || raw === 'false') {
|
|
283
|
+
defaultValue = raw === 'true';
|
|
284
|
+
resolvedType = 'boolean';
|
|
285
|
+
} else if (/^\d+$/.test(raw)) {
|
|
286
|
+
defaultValue = parseInt(raw, 10);
|
|
287
|
+
if (resolvedType === 'string' && !ambiguous) resolvedType = 'number';
|
|
288
|
+
} else if (/^\d+\.\d+$/.test(raw)) {
|
|
289
|
+
defaultValue = parseFloat(raw);
|
|
290
|
+
if (resolvedType === 'string' && !ambiguous) resolvedType = 'number';
|
|
291
|
+
} else {
|
|
292
|
+
defaultValue = raw.replace(/^["']|["']$/g, '');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { defaultValue, resolvedType };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Extract enum values from description text.
|
|
301
|
+
*/
|
|
302
|
+
function extractEnum(
|
|
303
|
+
description: string | undefined,
|
|
304
|
+
type: FieldMeta['type'],
|
|
305
|
+
): { enumValues: string[] | undefined; resolvedType: FieldMeta['type'] } {
|
|
306
|
+
// Explicit choices: (choices: a, b, c) or (one of: a|b|c)
|
|
307
|
+
const choiceMatch = description?.match(/\((?:one of|choices?)[:\s]+([^)]+)\)/i);
|
|
308
|
+
if (choiceMatch) {
|
|
309
|
+
const values = choiceMatch[1]!.split(/[,|]/).map((v) => v.trim().replace(/^["']|["']$/g, ''));
|
|
310
|
+
return { enumValues: values, resolvedType: 'enum' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Inline enum in description: {open|closed|merged|all} (gh-style)
|
|
314
|
+
const inlineMatch = description?.match(/\{(\w+(?:\|[\w-]+)+)\}/);
|
|
315
|
+
if (inlineMatch) {
|
|
316
|
+
const values = inlineMatch[1]!.split('|');
|
|
317
|
+
return { enumValues: values, resolvedType: 'enum' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { enumValues: undefined, resolvedType: type };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Fix up a parsed field for edge cases:
|
|
325
|
+
* - Enum default not in the listed values → add it
|
|
326
|
+
* - Default value type doesn't match declared type → reset to type default + mark ambiguous
|
|
327
|
+
*/
|
|
328
|
+
function reconcileField(field: FieldMeta): FieldMeta {
|
|
329
|
+
// If enum has a default that isn't in the value list, add it
|
|
330
|
+
if (field.type === 'enum' && field.enumValues && field.default !== undefined) {
|
|
331
|
+
const defStr = String(field.default);
|
|
332
|
+
if (!field.enumValues.includes(defStr)) {
|
|
333
|
+
field.enumValues = [...field.enumValues, defStr];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// If the default value doesn't match the declared type, reset + mark ambiguous
|
|
338
|
+
if (field.default !== undefined) {
|
|
339
|
+
if (field.type === 'boolean' && typeof field.default !== 'boolean') {
|
|
340
|
+
field.default = false;
|
|
341
|
+
field.ambiguous = true;
|
|
342
|
+
} else if (field.type === 'number' && typeof field.default !== 'number') {
|
|
343
|
+
field.default = 0;
|
|
344
|
+
field.ambiguous = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return field;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parsePositionalLine(line: string): FieldMeta | null {
|
|
352
|
+
// Pattern: " <name> Description"
|
|
353
|
+
// Pattern: " name Description"
|
|
354
|
+
const match = line.match(/^\s{2,}<?(\w[\w-]*)>?\s{2,}(.+)$/);
|
|
355
|
+
if (!match) return null;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
name: match[1]!,
|
|
359
|
+
type: 'string',
|
|
360
|
+
description: match[2]!.trim(),
|
|
361
|
+
positional: true,
|
|
362
|
+
ambiguous: true,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Strip leading dashes from a flag name, preserving kebab-case.
|
|
368
|
+
*/
|
|
369
|
+
function normalizeOptionName(flag: string): string {
|
|
370
|
+
return flag.replace(/^-+/, '');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Strip leading dash from a short alias flag (e.g. '-v' → 'v').
|
|
375
|
+
*/
|
|
376
|
+
function normalizeAlias(alias: string): string {
|
|
377
|
+
return alias.replace(/^-/, '');
|
|
378
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { CommandMeta, FieldMeta } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deep-merge multiple CommandMeta from different sources.
|
|
5
|
+
* Deduplicates fields, resolves conflicts, and combines subcommands.
|
|
6
|
+
*
|
|
7
|
+
* Later sources take precedence for descriptions and types,
|
|
8
|
+
* unless the earlier source was more specific (non-ambiguous).
|
|
9
|
+
*/
|
|
10
|
+
export function mergeCommandMeta(...sources: CommandMeta[]): CommandMeta {
|
|
11
|
+
if (sources.length === 0) {
|
|
12
|
+
return { name: '' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (sources.length === 1) {
|
|
16
|
+
return sources[0]!;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const result: CommandMeta = { name: '' };
|
|
20
|
+
|
|
21
|
+
for (const source of sources) {
|
|
22
|
+
// Name: first non-empty wins
|
|
23
|
+
if (source.name && !result.name) {
|
|
24
|
+
result.name = source.name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Description: last non-empty wins
|
|
28
|
+
if (source.description) {
|
|
29
|
+
result.description = source.description;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Aliases: merge and deduplicate
|
|
33
|
+
if (source.aliases) {
|
|
34
|
+
result.aliases = [...new Set([...(result.aliases || []), ...source.aliases])];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Examples: merge and deduplicate
|
|
38
|
+
if (source.examples) {
|
|
39
|
+
result.examples = [...new Set([...(result.examples || []), ...source.examples])];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Deprecated: last truthy wins
|
|
43
|
+
if (source.deprecated !== undefined) {
|
|
44
|
+
result.deprecated = source.deprecated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Arguments: merge by name
|
|
48
|
+
if (source.arguments) {
|
|
49
|
+
result.arguments = mergeFields(result.arguments || [], source.arguments);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Positionals: merge by name
|
|
53
|
+
if (source.positionals) {
|
|
54
|
+
result.positionals = mergeFields(result.positionals || [], source.positionals);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Subcommands: merge recursively by name
|
|
58
|
+
if (source.subcommands) {
|
|
59
|
+
result.subcommands = mergeSubcommands(result.subcommands || [], source.subcommands);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Clean up empty arrays
|
|
64
|
+
if (result.aliases?.length === 0) delete result.aliases;
|
|
65
|
+
if (result.examples?.length === 0) delete result.examples;
|
|
66
|
+
if (result.arguments?.length === 0) delete result.arguments;
|
|
67
|
+
if (result.positionals?.length === 0) delete result.positionals;
|
|
68
|
+
if (result.subcommands?.length === 0) delete result.subcommands;
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Merge two arrays of FieldMeta by name.
|
|
75
|
+
* Later fields take precedence unless earlier was non-ambiguous.
|
|
76
|
+
*/
|
|
77
|
+
function mergeFields(existing: FieldMeta[], incoming: FieldMeta[]): FieldMeta[] {
|
|
78
|
+
const map = new Map<string, FieldMeta>();
|
|
79
|
+
|
|
80
|
+
for (const field of existing) {
|
|
81
|
+
map.set(field.name, { ...field });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const field of incoming) {
|
|
85
|
+
const prev = map.get(field.name);
|
|
86
|
+
if (!prev) {
|
|
87
|
+
map.set(field.name, { ...field });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Merge the fields
|
|
92
|
+
const merged: FieldMeta = { ...prev };
|
|
93
|
+
|
|
94
|
+
// Type: prefer non-ambiguous source
|
|
95
|
+
if (field.type !== 'unknown') {
|
|
96
|
+
if (prev.ambiguous || !field.ambiguous) {
|
|
97
|
+
merged.type = field.type;
|
|
98
|
+
merged.ambiguous = field.ambiguous;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Description: last non-empty wins
|
|
103
|
+
if (field.description) {
|
|
104
|
+
merged.description = field.description;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Default: last non-undefined wins
|
|
108
|
+
if (field.default !== undefined) {
|
|
109
|
+
merged.default = field.default;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Required: last defined wins
|
|
113
|
+
if (field.required !== undefined) {
|
|
114
|
+
merged.required = field.required;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Aliases: merge and deduplicate
|
|
118
|
+
if (field.aliases) {
|
|
119
|
+
merged.aliases = [...new Set([...(prev.aliases || []), ...field.aliases])];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Enum values: merge and deduplicate
|
|
123
|
+
if (field.enumValues) {
|
|
124
|
+
merged.enumValues = [...new Set([...(prev.enumValues || []), ...field.enumValues])];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Items: last non-empty wins
|
|
128
|
+
if (field.items) {
|
|
129
|
+
merged.items = field.items;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
map.set(field.name, merged);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [...map.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Merge two arrays of CommandMeta by name, recursively.
|
|
140
|
+
*/
|
|
141
|
+
function mergeSubcommands(existing: CommandMeta[], incoming: CommandMeta[]): CommandMeta[] {
|
|
142
|
+
const map = new Map<string, CommandMeta>();
|
|
143
|
+
|
|
144
|
+
for (const cmd of existing) {
|
|
145
|
+
map.set(cmd.name, cmd);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const cmd of incoming) {
|
|
149
|
+
const prev = map.get(cmd.name);
|
|
150
|
+
if (!prev) {
|
|
151
|
+
map.set(cmd.name, cmd);
|
|
152
|
+
} else {
|
|
153
|
+
map.set(cmd.name, mergeCommandMeta(prev, cmd));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [...map.values()];
|
|
158
|
+
}
|