@clidoc/core 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.
package/src/index.ts ADDED
@@ -0,0 +1,359 @@
1
+ import { createHash } from 'node:crypto';
2
+ export * from './discovery.js';
3
+ export * from './merge.js';
4
+ export * from './docgen.js';
5
+ export * from './completion.js';
6
+ import { Ajv2020 } from 'ajv/dist/2020.js';
7
+ import * as formatsModule from 'ajv-formats';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import { schema } from './schema.js';
10
+ import { logicalErrors } from './logical.js';
11
+ import type {
12
+ OpenCliDocument,
13
+ CommandItemObject,
14
+ FlagItemObject,
15
+ LicenseObject,
16
+ ContactObject,
17
+ GlobalObject,
18
+ ChoiceObject,
19
+ AlternativeSource,
20
+ } from './types.js';
21
+ export type * from './types.js';
22
+
23
+ /** Supported version of the vendored OpenCLI schema. */
24
+ export const OPENCLI_VERSION = '1.0.0-alpha.14' as const;
25
+ /** Vendored JSON Schema used to validate OpenCLI documents. */
26
+ export const openCliSchema = schema;
27
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
28
+ (formatsModule.default as unknown as (instance: Ajv2020) => void)(ajv);
29
+ const check = ajv.compile(schema);
30
+
31
+ /** Validate against the exact vendored upstream JSON Schema, plus upstream's logical checks. */
32
+ export function validate(document: unknown): { valid: boolean; errors: string[] } {
33
+ const schemaValid = check(document);
34
+ if (!schemaValid) {
35
+ return {
36
+ valid: false,
37
+ errors: (check.errors ?? []).map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`),
38
+ };
39
+ }
40
+ const errors = logicalErrors(document as OpenCliDocument);
41
+ return { valid: errors.length === 0, errors };
42
+ }
43
+
44
+ /** Parse JSON or YAML and reject documents that do not match the specification. */
45
+ export function parse(input: string, options: { format?: 'json' | 'yaml' } = {}): OpenCliDocument {
46
+ const looksLikeJson = /^\s*[{[]/.test(input);
47
+ const format = options.format ?? (looksLikeJson ? 'json' : 'yaml');
48
+ let document: unknown;
49
+ if (format === 'json') {
50
+ try {
51
+ document = JSON.parse(input);
52
+ } catch (error) {
53
+ throw new Error(`Invalid OpenCLI JSON: ${error instanceof Error ? error.message : String(error)}`, {
54
+ cause: error,
55
+ });
56
+ }
57
+ } else {
58
+ try {
59
+ document = parseYaml(input, { uniqueKeys: true });
60
+ } catch (error) {
61
+ throw new Error(`Invalid OpenCLI YAML: ${error instanceof Error ? error.message : String(error)}`, {
62
+ cause: error,
63
+ });
64
+ }
65
+ }
66
+ const result = validate(document);
67
+ if (!result.valid) throw new Error(`Invalid OpenCLI document: ${result.errors.join('; ')}`);
68
+ return document as OpenCliDocument;
69
+ }
70
+
71
+ const heading = (level: number, text: string) => `${'#'.repeat(level)} ${text}\n\n`;
72
+ const escapeCell = (value: unknown) => String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
73
+ function code(value: string): string {
74
+ const longest = Math.max(0, ...Array.from(value.matchAll(/`+/g), (match) => match[0].length));
75
+ const delim = '`'.repeat(longest + 1);
76
+ const padded = value.startsWith('`') || value.endsWith('`') ? ` ${value} ` : value;
77
+ return `${delim}${padded}${delim}`;
78
+ }
79
+ const values = (choices: ChoiceObject[]) =>
80
+ choices
81
+ .map((choice) => (choice.description ? `${choice.value} (${choice.description})` : String(choice.value)))
82
+ .join(', ');
83
+ const details = (item: {
84
+ summary?: string;
85
+ description?: string;
86
+ choices?: ChoiceObject[];
87
+ variadic?: boolean;
88
+ minItems?: number;
89
+ maxItems?: number;
90
+ default?: string | number | boolean;
91
+ aliases?: string[];
92
+ hint?: string;
93
+ passthrough?: boolean;
94
+ alternativeSources?: AlternativeSource[];
95
+ }) => {
96
+ const parts = [item.description ?? item.summary ?? ''];
97
+ if (item.aliases?.length) parts.push(`Aliases: ${item.aliases.map(code).join(', ')}`);
98
+ if (item.variadic)
99
+ parts.push(
100
+ `Variadic${item.minItems === undefined ? '' : ` (min ${item.minItems})`}${item.maxItems === undefined ? '' : ` (max ${item.maxItems})`}`,
101
+ );
102
+ if (item.choices?.length) parts.push(`Choices: ${values(item.choices)}`);
103
+ if (item.default !== undefined) parts.push(`Default: ${code(String(item.default))}`);
104
+ if (item.hint) parts.push(`Hint: ${item.hint}`);
105
+ if (item.passthrough) parts.push('Passthrough');
106
+ if (item.alternativeSources?.length)
107
+ parts.push(
108
+ item.alternativeSources
109
+ .map((source) => `${source.type === '$ENV' ? 'Env' : 'File'}: ${code(source.property)}`)
110
+ .join(', '),
111
+ );
112
+ return parts.filter(Boolean).join('; ');
113
+ };
114
+ function table(headers: string[], rows: unknown[][]): string {
115
+ if (!rows.length) return '';
116
+ return `| ${headers.join(' | ')} |\n| ${headers.map(() => '---').join(' | ')} |\n${rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`).join('\n')}\n\n`;
117
+ }
118
+ function fenced(content: string, language = 'sh'): string {
119
+ const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (match) => match[0].length));
120
+ const fence = '`'.repeat(Math.max(3, longest + 1));
121
+ return `${fence}${language}\n${content}\n${fence}\n\n`;
122
+ }
123
+ function placeholder(name: string, hint?: string): string {
124
+ const value = hint?.trim();
125
+ if (value?.startsWith('<') && value.endsWith('>')) return value;
126
+ return `<${value || name}>`;
127
+ }
128
+ function repeatable(token: string, required: boolean): string {
129
+ return required ? `${token} [${token}]...` : `[${token}]...`;
130
+ }
131
+ function renderArgumentUsage(arg: NonNullable<CommandItemObject['args']>[number]): string {
132
+ const token = placeholder(arg.name);
133
+ const required = Boolean(arg.required || (arg.minItems ?? 0) > 0);
134
+ if (arg.passthrough) {
135
+ const operand = `${token}${arg.variadic ? '...' : ''}`;
136
+ return required ? `-- ${operand}` : `[-- ${operand}]`;
137
+ }
138
+ if (arg.variadic) return required ? `${token}...` : `[${token}...]`;
139
+ return required ? token : `[${token}]`;
140
+ }
141
+ function renderFlagUsage(flag: FlagItemObject): string {
142
+ const token = `--${flag.name}${flag.type === 'boolean' ? '' : ` ${placeholder(flag.name, flag.hint)}`}`;
143
+ const required = Boolean(flag.required || (flag.minItems ?? 0) > 0);
144
+ if (flag.variadic) return repeatable(token, required);
145
+ return required ? token : `[${token}]`;
146
+ }
147
+ function commandInvocation(name: string, binary: string): { command: string; syntax: string[] } {
148
+ const parts: string[] = [];
149
+ const allParts = name.trim().split(/\s+/);
150
+ for (const part of allParts) {
151
+ if (part === '--' || ['<', '{', '['].some((marker) => part.includes(marker))) break;
152
+ parts.push(part);
153
+ }
154
+ const relative = parts.join(' ');
155
+ const command =
156
+ !binary || relative === binary || relative.startsWith(`${binary} `) ? relative : `${binary} ${relative}`;
157
+ return { command, syntax: allParts.slice(parts.length) };
158
+ }
159
+ function renderUsage(name: string, command: CommandItemObject, binary: string, globalFlags: FlagItemObject[]): string {
160
+ const combinedFlags = new Map(globalFlags.map((flag) => [flag.name, flag]));
161
+ for (const flag of command.flags ?? []) combinedFlags.set(flag.name, flag);
162
+ const flags = [...combinedFlags.values()].filter((flag) => !flag.hidden).map(renderFlagUsage);
163
+ const args = (command.args ?? []).map(renderArgumentUsage);
164
+ const invocation = commandInvocation(name, binary);
165
+ const legacyArgs = args.length
166
+ ? []
167
+ : invocation.syntax.filter((part) => !/^\[?flags\]?$/.test(part) && !/^\{commands?\}$/.test(part));
168
+ const legacyFlags = combinedFlags.size ? [] : invocation.syntax.filter((part) => /^\[?flags\]?$/.test(part));
169
+ const operands = [...legacyArgs, ...args];
170
+ const options = [...legacyFlags, ...flags];
171
+ const optionTerminated = (command.args ?? []).some((arg) => arg.passthrough) || legacyArgs.includes('--');
172
+ return (
173
+ heading(3, 'Usage') +
174
+ fenced(
175
+ [invocation.command, ...(optionTerminated ? options : operands), ...(optionTerminated ? operands : options)].join(
176
+ ' ',
177
+ ),
178
+ )
179
+ );
180
+ }
181
+ function renderCommand(
182
+ name: string,
183
+ command: CommandItemObject,
184
+ binary = '',
185
+ globalFlags: FlagItemObject[] = [],
186
+ ): string {
187
+ let out = heading(2, name);
188
+ if (command.summary) out += `${command.summary}\n\n`;
189
+ if (command.description) out += `${command.description}\n\n`;
190
+ if (command.aliases?.length) out += `Aliases: ${command.aliases.map(code).join(', ')}\n\n`;
191
+ if (command.kind === 'group') out += 'Command group\n\n';
192
+ out += renderUsage(name, command, binary, globalFlags);
193
+ out += table(
194
+ ['Argument', 'Type', 'Required', 'Description'],
195
+ (command.args ?? []).map((arg) => [
196
+ code(arg.name),
197
+ arg.type ?? 'string',
198
+ arg.required || (arg.minItems ?? 0) > 0 ? 'Yes' : 'No',
199
+ details(arg),
200
+ ]),
201
+ );
202
+ out += renderFlags(command.flags ?? []);
203
+ out += renderExitCodes(command.exitCodes ?? []);
204
+ if (command.examples?.length) {
205
+ out += heading(3, 'Examples');
206
+ for (const example of command.examples) {
207
+ if (example.title) out += heading(4, example.title);
208
+ out += fenced(example.content);
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+ function renderFlags(flags: FlagItemObject[]): string {
214
+ return table(
215
+ ['Flag', 'Type', 'Required', 'Description'],
216
+ flags
217
+ .filter((flag) => !flag.hidden)
218
+ .map((flag) => [
219
+ code(`--${flag.name}`),
220
+ flag.type,
221
+ flag.required || (flag.minItems ?? 0) > 0 ? 'Yes' : 'No',
222
+ details(flag),
223
+ ]),
224
+ );
225
+ }
226
+ function renderExitCodes(codes: NonNullable<CommandItemObject['exitCodes']>): string {
227
+ return table(
228
+ ['Exit code', 'Status', 'Description'],
229
+ codes.map((item) => [item.code, item.status, item.description ?? item.summary]),
230
+ );
231
+ }
232
+ function renderLicense(license: LicenseObject): string {
233
+ const label = license.url ? `[${license.name}](${license.url})` : license.name;
234
+ return `License: ${label}${license.spdxId ? ` (${license.spdxId})` : ''}\n\n`;
235
+ }
236
+ function renderContact(contact: ContactObject): string {
237
+ const parts = [contact.name, contact.email, contact.url].filter(Boolean);
238
+ return `Contact: ${parts.join(' · ')}\n\n`;
239
+ }
240
+ function renderConfig(config: NonNullable<GlobalObject['config']>): string {
241
+ const rows: [string, string][] = [];
242
+ if (config.json) rows.push(['JSON', config.json]);
243
+ if (config.toml) rows.push(['TOML', config.toml]);
244
+ if (config.yaml) rows.push(['YAML', config.yaml]);
245
+ if (!rows.length) return '';
246
+ return (
247
+ heading(2, 'Configuration') +
248
+ table(
249
+ ['Format', 'Path'],
250
+ rows.map(([format, path]) => [format, code(path)]),
251
+ )
252
+ );
253
+ }
254
+
255
+ /** Render the shared header both `renderMarkdown` and `generatePages`' landing page use. */
256
+ function renderDocumentHeader(document: OpenCliDocument): string {
257
+ let out = heading(1, document.info.title);
258
+ if (document.info.summary) out += `${document.info.summary}\n\n`;
259
+ if (document.info.description) out += `${document.info.description}\n\n`;
260
+ out += `Binary: ${code(document.info.binary)} · Version: ${code(document.info.version)}\n\n`;
261
+ if (document.info.license) out += renderLicense(document.info.license);
262
+ if (document.info.contact) out += renderContact(document.info.contact);
263
+ if (document.install?.length)
264
+ out +=
265
+ heading(2, 'Installation') +
266
+ table(
267
+ ['Method', 'Command or URL', 'Description'],
268
+ document.install.map((method) => [
269
+ method.name,
270
+ method.command ? code(method.command) : (method.url ?? ''),
271
+ method.description ?? '',
272
+ ]),
273
+ );
274
+ if (document.global?.config) out += renderConfig(document.global.config);
275
+ if (document.global?.flags?.length) out += heading(2, 'Global flags') + renderFlags(document.global.flags);
276
+ if (document.global?.exitCodes?.length)
277
+ out += heading(2, 'Global exit codes') + renderExitCodes(document.global.exitCodes);
278
+ return out;
279
+ }
280
+
281
+ function assertDocument(document: unknown): asserts document is OpenCliDocument {
282
+ const result = validate(document);
283
+ if (!result.valid) throw new Error(`Invalid OpenCLI document: ${result.errors.join('; ')}`);
284
+ }
285
+
286
+ /** Render a complete Markdown reference, preserving spec-authored Markdown prose. */
287
+ export function renderMarkdown(document: OpenCliDocument): string {
288
+ assertDocument(document);
289
+ let out = renderDocumentHeader(document);
290
+ for (const [name, command] of Object.entries(document.commands ?? {}).toSorted(([a], [b]) => a.localeCompare(b))) {
291
+ if (!command.hidden) out += renderCommand(name, command, document.info.binary, document.global?.flags ?? []);
292
+ }
293
+ return out.trimEnd() + '\n';
294
+ }
295
+
296
+ export type GeneratedPage = { id: string; title: string; path: string; content: string };
297
+
298
+ /** Strip the leading `<binary> ` prefix commands are keyed with, e.g. `acme run` -> `run`. */
299
+ function withoutBinaryPrefix(name: string, binary: string): string {
300
+ if (binary && name.startsWith(`${binary} `)) {
301
+ const rest = name.slice(binary.length + 1).trim();
302
+ if (rest) return rest;
303
+ }
304
+ return name;
305
+ }
306
+ /** Reserve `~` for deterministic names outside the readable namespace. */
307
+ function commandRoute(name: string, binary: string): string {
308
+ const display = withoutBinaryPrefix(name, binary);
309
+ const hasPrefix = !binary || name === `${binary} ${display}`;
310
+ if (hasPrefix && display.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(display)) return display;
311
+ // Hash UTF-16 code units to preserve distinctions even for lone surrogates.
312
+ const units = Buffer.allocUnsafe(name.length * 2);
313
+ for (let index = 0; index < name.length; index++) units.writeUInt16BE(name.charCodeAt(index), index * 2);
314
+ const readable =
315
+ display
316
+ .toLowerCase()
317
+ .normalize('NFKD')
318
+ .replace(/[^a-z0-9]+/g, '-')
319
+ .replace(/^-+|-+$/g, '')
320
+ .slice(0, 64) || 'command';
321
+ return `${readable}~${createHash('sha256').update(units).digest('hex')}`;
322
+ }
323
+
324
+ /** Produce a landing page and one page per visible command with safe, stable routes. */
325
+ export function generatePages(document: OpenCliDocument, options: { basePath?: string } = {}): GeneratedPage[] {
326
+ assertDocument(document);
327
+ const rawBase = (options.basePath ?? '').replace(/\\/g, '/');
328
+ const segments = rawBase.split('/').filter(Boolean);
329
+ if (segments.some((segment) => segment === '.' || segment === '..' || /%(?:2e|2f|5c)/i.test(segment)))
330
+ throw new Error('Invalid basePath segment');
331
+ const prefix = segments.length ? '/' + segments.map((segment) => encodeURIComponent(segment)).join('/') : '';
332
+ const pages: GeneratedPage[] = [];
333
+ const names = Object.keys(document.commands ?? {})
334
+ .filter((name) => !document.commands?.[name]?.hidden)
335
+ .toSorted((a, b) => a.localeCompare(b));
336
+ const routes = new Map(names.map((name) => [name, commandRoute(name, document.info.binary)]));
337
+ if (new Set(routes.values()).size !== routes.size) throw new Error('Generated command routes collide');
338
+ let landing = renderDocumentHeader(document);
339
+ if (names.length)
340
+ landing +=
341
+ heading(2, 'Commands') +
342
+ names
343
+ .map((name) => `- [${name.replace(/[[\]\\]/g, '\\$&')}](${`${prefix}/commands/${routes.get(name)}`})`)
344
+ .join('\n') +
345
+ '\n\n';
346
+ pages.push({ id: 'index', title: document.info.title, path: prefix || '/', content: landing.trimEnd() + '\n' });
347
+ for (const name of names) {
348
+ const route = routes.get(name)!;
349
+ pages.push({
350
+ id: `command-${route}`,
351
+ title: name,
352
+ path: `${prefix}/commands/${route}`,
353
+ content:
354
+ renderCommand(name, document.commands![name]!, document.info.binary, document.global?.flags ?? []).trimEnd() +
355
+ '\n',
356
+ });
357
+ }
358
+ return pages;
359
+ }
package/src/logical.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { OpenCliDocument, CommandItemObject, AlternativeSource } from './types.js';
2
+
3
+ /**
4
+ * Logical validations ported from upstream `validate/validate.go`, beyond what the JSON Schema
5
+ * can express: positional argument ordering, variadic/min/max constraints, `$FILE` alternative
6
+ * sources referencing a declared `global.config` file, duplicate flag names/aliases, and group
7
+ * commands carrying args or flags.
8
+ */
9
+ export function logicalErrors(document: OpenCliDocument): string[] {
10
+ const errors: string[] = [];
11
+ const definedConfigFiles = new Set<string>();
12
+ if (document.global?.config?.json) definedConfigFiles.add('json');
13
+ if (document.global?.config?.toml) definedConfigFiles.add('toml');
14
+ if (document.global?.config?.yaml) definedConfigFiles.add('yaml');
15
+
16
+ for (const [name, command] of Object.entries(document.commands ?? {})) {
17
+ validateCommand(name, command, definedConfigFiles, errors);
18
+ }
19
+ return errors;
20
+ }
21
+
22
+ function validateCommand(
23
+ name: string,
24
+ command: CommandItemObject,
25
+ definedConfigFiles: Set<string>,
26
+ errors: string[],
27
+ ): void {
28
+ const base = `/commands/${name}`;
29
+ const args = command.args ?? [];
30
+ const flags = command.flags ?? [];
31
+
32
+ if (args.length > 0) {
33
+ validateArgumentOrdering(base, args, errors);
34
+ validateArgumentConstraints(base, args, errors);
35
+ }
36
+
37
+ if (flags.length > 0) {
38
+ validateFlagFileReferences(base, flags, definedConfigFiles, errors);
39
+ validateFlagConstraints(base, flags, errors);
40
+ }
41
+
42
+ if (command.kind === 'group') {
43
+ if (args.length > 0) errors.push(`${base} group command cannot have arguments`);
44
+ if (flags.length > 0) errors.push(`${base} group command cannot have flags`);
45
+ }
46
+ }
47
+
48
+ /** Ensures required positional args don't come after optional ones. */
49
+ function validateArgumentOrdering(base: string, args: NonNullable<CommandItemObject['args']>, errors: string[]): void {
50
+ let seenOptional = false;
51
+ for (const [i, arg] of args.entries()) {
52
+ const isRequired = arg.required === true;
53
+ if (seenOptional && isRequired) {
54
+ errors.push(`${base}/args/${i} required positional argument '${arg.name}' cannot come after optional arguments`);
55
+ }
56
+ if (!isRequired) seenOptional = true;
57
+ }
58
+ }
59
+
60
+ /** Checks that minItems/maxItems are only used with variadic args, and min <= max. */
61
+ function validateArgumentConstraints(
62
+ base: string,
63
+ args: NonNullable<CommandItemObject['args']>,
64
+ errors: string[],
65
+ ): void {
66
+ for (const [i, arg] of args.entries()) {
67
+ const path = `${base}/args/${i}`;
68
+ if ((arg.minItems !== undefined || arg.maxItems !== undefined) && !arg.variadic) {
69
+ const field = arg.minItems !== undefined ? 'minItems' : 'maxItems';
70
+ errors.push(`${path} argument '${arg.name}' has ${field} but is not variadic`);
71
+ }
72
+ if (arg.variadic && arg.minItems !== undefined && arg.maxItems !== undefined && arg.minItems > arg.maxItems) {
73
+ errors.push(
74
+ `${path} argument '${arg.name}' has minItems (${arg.minItems}) greater than maxItems (${arg.maxItems})`,
75
+ );
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Checks that `$FILE` alternative sources reference a file declared in global.config. */
81
+ function validateFlagFileReferences(
82
+ base: string,
83
+ flags: NonNullable<CommandItemObject['flags']>,
84
+ definedConfigFiles: Set<string>,
85
+ errors: string[],
86
+ ): void {
87
+ for (const [i, flag] of flags.entries()) {
88
+ validateFileReferences(
89
+ `${base}/flags/${i}`,
90
+ 'flag',
91
+ flag.name,
92
+ flag.alternativeSources,
93
+ definedConfigFiles,
94
+ errors,
95
+ );
96
+ }
97
+ }
98
+
99
+ function validateFileReferences(
100
+ path: string,
101
+ itemType: string,
102
+ itemName: string,
103
+ altSources: AlternativeSource[] | undefined,
104
+ definedConfigFiles: Set<string>,
105
+ errors: string[],
106
+ ): void {
107
+ for (const [j, source] of (altSources ?? []).entries()) {
108
+ if (source.type === '$FILE' && definedConfigFiles.size === 0) {
109
+ errors.push(
110
+ `${path}/alternativeSources/${j} ${itemType} '${itemName}' references $FILE but no config files are defined in global.config`,
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Checks for duplicate flag names/aliases and variadic+required/minItems/maxItems constraints. */
117
+ function validateFlagConstraints(base: string, flags: NonNullable<CommandItemObject['flags']>, errors: string[]): void {
118
+ const seen = new Map<string, number>();
119
+
120
+ for (const [i, flag] of flags.entries()) {
121
+ const path = `${base}/flags/${i}`;
122
+ const flagName = flag.name;
123
+
124
+ if (flagName) {
125
+ const prevIdx = seen.get(flagName);
126
+ if (prevIdx !== undefined) {
127
+ errors.push(`${path} duplicate flag name '${flagName}' (also defined at index ${prevIdx})`);
128
+ }
129
+ seen.set(flagName, i);
130
+
131
+ for (const alias of flag.aliases ?? []) {
132
+ if (!alias) continue;
133
+ const prevAliasIdx = seen.get(alias);
134
+ if (prevAliasIdx !== undefined) {
135
+ errors.push(`${path}/aliases duplicate flag alias '${alias}' (already defined at index ${prevAliasIdx})`);
136
+ }
137
+ seen.set(alias, i);
138
+ }
139
+ }
140
+
141
+ if (flag.variadic && flag.required) {
142
+ errors.push(
143
+ `${path} variadic flag '${flagName}' cannot be marked as required (variadic flags can be provided 0 or more times)`,
144
+ );
145
+ }
146
+
147
+ if ((flag.minItems !== undefined || flag.maxItems !== undefined) && !flag.variadic) {
148
+ const field = flag.minItems !== undefined ? 'minItems' : 'maxItems';
149
+ errors.push(`${path} flag '${flagName}' has ${field} but is not variadic`);
150
+ }
151
+
152
+ if (flag.variadic && flag.minItems !== undefined && flag.maxItems !== undefined && flag.minItems > flag.maxItems) {
153
+ errors.push(
154
+ `${path} flag '${flagName}' has minItems (${flag.minItems}) greater than maxItems (${flag.maxItems})`,
155
+ );
156
+ }
157
+ }
158
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { validate } from './index.js';
2
+ import type {
3
+ ArgumentItemObject,
4
+ CommandItemObject,
5
+ FlagItemObject,
6
+ GlobalObject,
7
+ InfoObject,
8
+ InstallMethodItemObject,
9
+ OpenCliDocument,
10
+ } from './types.js';
11
+
12
+ /** A named array entry merged with a generated entry of the same name. */
13
+ export type NamedOverride<T extends { name: string }> = Pick<T, 'name'> & Partial<Omit<T, 'name'>>;
14
+ /** Metadata layered onto a generated command. */
15
+ export type CommandOverride = Omit<Partial<CommandItemObject>, 'args' | 'flags'> & {
16
+ args?: NamedOverride<ArgumentItemObject>[];
17
+ flags?: NamedOverride<FlagItemObject>[];
18
+ };
19
+ /** Metadata layered onto the generated global settings. */
20
+ export type GlobalOverride = Omit<Partial<GlobalObject>, 'config' | 'flags'> & {
21
+ config?: Partial<NonNullable<GlobalObject['config']>>;
22
+ flags?: NamedOverride<FlagItemObject>[];
23
+ };
24
+ /** Metadata layered onto the generated CLI identity. */
25
+ export type InfoOverride = Omit<Partial<InfoObject>, 'license' | 'contact'> & {
26
+ license?: Partial<NonNullable<InfoObject['license']>>;
27
+ contact?: Partial<NonNullable<InfoObject['contact']>>;
28
+ };
29
+
30
+ /**
31
+ * Author-supplied additions layered onto a generated OpenCLI document by {@link mergeDocument}.
32
+ * Named flags and arguments require a name for matching, but their remaining fields may be
33
+ * supplied by the generated document. The merged result is validated before it is returned.
34
+ */
35
+ export type DocumentOverrides = {
36
+ info?: InfoOverride;
37
+ install?: InstallMethodItemObject[];
38
+ global?: GlobalOverride;
39
+ commands?: Record<string, CommandOverride>;
40
+ [key: `x-${string}`]: unknown;
41
+ };
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ /** Arrays under these keys are appended rather than replaced. */
48
+ const APPEND_KEYS = new Set(['examples', 'exitCodes']);
49
+ /** Arrays under these keys are merged item-by-item, matched by `name`. */
50
+ const NAME_MERGE_KEYS = new Set(['flags', 'args']);
51
+
52
+ /** Merge two arrays of `{ name: ... }` items (flags/args): match by `name`, append the rest. */
53
+ function mergeNamedArray(base: unknown[], override: unknown[]): unknown[] {
54
+ const result = (base as Record<string, unknown>[]).map((item) => ({ ...item }));
55
+ for (const item of override as Record<string, unknown>[]) {
56
+ const index = result.findIndex((entry) => entry.name === item.name);
57
+ if (index >= 0) result[index] = mergeObjects(result[index]!, item);
58
+ else result.push(item);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ function mergeValue(key: string, base: unknown, override: unknown): unknown {
64
+ if (override === undefined) return base;
65
+ if (Array.isArray(base) && Array.isArray(override)) {
66
+ if (APPEND_KEYS.has(key)) return [...base, ...override];
67
+ if (NAME_MERGE_KEYS.has(key)) return mergeNamedArray(base, override);
68
+ return override;
69
+ }
70
+ if (isPlainObject(base) && isPlainObject(override)) return mergeObjects(base, override);
71
+ return override;
72
+ }
73
+
74
+ function mergeObjects(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
75
+ const result: Record<string, unknown> = { ...base };
76
+ for (const key of Object.keys(override)) result[key] = mergeValue(key, base[key], override[key]);
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Return a new document with `overrides` deep-merged onto `base`. Plain objects merge
82
+ * recursively (so `info.license`, `global`, and individual commands can be extended without
83
+ * repeating the rest). Arrays in `overrides` replace the base array, except `examples` and
84
+ * `exitCodes`, which append, and `flags`/`args`, which are merged item-by-item matched by
85
+ * `name` (so an author can add e.g. `alternativeSources` to one generated flag without
86
+ * repeating the whole flag list). Commands not present in `base` are added as-is.
87
+ *
88
+ * Throws if the merged result fails OpenCLI schema validation, listing every problem found.
89
+ */
90
+ export function mergeDocument(base: OpenCliDocument, overrides: DocumentOverrides): OpenCliDocument {
91
+ const merged = mergeObjects(base as unknown as Record<string, unknown>, overrides as Record<string, unknown>);
92
+ const result = merged as unknown as OpenCliDocument;
93
+ const { valid, errors } = validate(result);
94
+ if (!valid) throw new Error(`Invalid OpenCLI document after merge: ${errors.join('; ')}`);
95
+ return result;
96
+ }