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.
Files changed (80) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +1 -1
  3. package/README.md +92 -49
  4. package/dist/args-CKNh7Dm9.mjs +175 -0
  5. package/dist/args-CKNh7Dm9.mjs.map +1 -0
  6. package/dist/chunk-y_GBKt04.mjs +5 -0
  7. package/dist/codegen/index.d.mts +305 -0
  8. package/dist/codegen/index.d.mts.map +1 -0
  9. package/dist/codegen/index.mjs +1348 -0
  10. package/dist/codegen/index.mjs.map +1 -0
  11. package/dist/completion.d.mts +64 -0
  12. package/dist/completion.d.mts.map +1 -0
  13. package/dist/completion.mjs +417 -0
  14. package/dist/completion.mjs.map +1 -0
  15. package/dist/docs/index.d.mts +34 -0
  16. package/dist/docs/index.d.mts.map +1 -0
  17. package/dist/docs/index.mjs +404 -0
  18. package/dist/docs/index.mjs.map +1 -0
  19. package/dist/formatter-Dvx7jFXr.d.mts +82 -0
  20. package/dist/formatter-Dvx7jFXr.d.mts.map +1 -0
  21. package/dist/help-mUIX0T0V.mjs +1195 -0
  22. package/dist/help-mUIX0T0V.mjs.map +1 -0
  23. package/dist/index.d.mts +122 -438
  24. package/dist/index.d.mts.map +1 -1
  25. package/dist/index.mjs +1240 -1161
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/test.d.mts +112 -0
  28. package/dist/test.d.mts.map +1 -0
  29. package/dist/test.mjs +138 -0
  30. package/dist/test.mjs.map +1 -0
  31. package/dist/types-qrtt0135.d.mts +1037 -0
  32. package/dist/types-qrtt0135.d.mts.map +1 -0
  33. package/dist/update-check-EbNDkzyV.mjs +146 -0
  34. package/dist/update-check-EbNDkzyV.mjs.map +1 -0
  35. package/package.json +61 -20
  36. package/src/args.ts +365 -0
  37. package/src/cli/completions.ts +29 -0
  38. package/src/cli/docs.ts +86 -0
  39. package/src/cli/doctor.ts +312 -0
  40. package/src/cli/index.ts +159 -0
  41. package/src/cli/init.ts +135 -0
  42. package/src/cli/link.ts +320 -0
  43. package/src/cli/wrap.ts +152 -0
  44. package/src/codegen/README.md +118 -0
  45. package/src/codegen/code-builder.ts +226 -0
  46. package/src/codegen/discovery.ts +232 -0
  47. package/src/codegen/file-emitter.ts +73 -0
  48. package/src/codegen/generators/barrel-file.ts +16 -0
  49. package/src/codegen/generators/command-file.ts +184 -0
  50. package/src/codegen/generators/command-tree.ts +124 -0
  51. package/src/codegen/index.ts +33 -0
  52. package/src/codegen/parsers/fish.ts +163 -0
  53. package/src/codegen/parsers/help.ts +378 -0
  54. package/src/codegen/parsers/merge.ts +158 -0
  55. package/src/codegen/parsers/zsh.ts +221 -0
  56. package/src/codegen/schema-to-code.ts +199 -0
  57. package/src/codegen/template.ts +69 -0
  58. package/src/codegen/types.ts +143 -0
  59. package/src/colorizer.ts +2 -2
  60. package/src/command-utils.ts +501 -0
  61. package/src/completion.ts +110 -97
  62. package/src/create.ts +1044 -284
  63. package/src/docs/index.ts +607 -0
  64. package/src/errors.ts +131 -0
  65. package/src/formatter.ts +149 -63
  66. package/src/help.ts +151 -55
  67. package/src/index.ts +13 -15
  68. package/src/interactive.ts +169 -0
  69. package/src/parse.ts +31 -16
  70. package/src/repl-loop.ts +317 -0
  71. package/src/runtime.ts +304 -0
  72. package/src/shell-utils.ts +83 -0
  73. package/src/test.ts +285 -0
  74. package/src/type-helpers.ts +12 -12
  75. package/src/type-utils.ts +124 -14
  76. package/src/types.ts +803 -144
  77. package/src/update-check.ts +244 -0
  78. package/src/wrap.ts +185 -0
  79. package/src/zod.d.ts +2 -2
  80. 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
+ }