@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.
@@ -0,0 +1,285 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validate } from './index.js';
3
+ import type { CommandItemObject, FlagItemObject, OpenCliDocument } from './types.js';
4
+
5
+ /** Shells supported by standalone completion scripts. */
6
+ export type CompletionShell = 'bash' | 'zsh' | 'fish';
7
+ export type CompletionOptions = { shell: CompletionShell; binary?: string };
8
+ // Tables use c=child transition, s=subcommands, o=options, f=flag values, a=arguments.
9
+ // Numeric command states let aliases share a subtree without duplicating scripts.
10
+ type Entry = { mode: string; values: string[] };
11
+ type Node = { command: CommandItemObject; children: Map<string, number> };
12
+
13
+ function compile(document: OpenCliDocument): Map<string, Entry> {
14
+ const binary = document.info.binary;
15
+ const nodes: Node[] = [{ command: {}, children: new Map() }];
16
+ const paths = new Map<string, number>([[binary, 0]]);
17
+ const commands = Object.entries(document.commands ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
18
+ for (const [path, command] of commands) {
19
+ if (path !== binary && !path.startsWith(`${binary} `))
20
+ throw new Error(`Completion command must start with ${binary}: ${path}`);
21
+ let parent = 0;
22
+ let full = binary;
23
+ for (const part of path.slice(binary.length).trim().split(/\s+/).filter(Boolean)) {
24
+ full += ` ${part}`;
25
+ let id = paths.get(full);
26
+ if (id === undefined) {
27
+ id = nodes.length;
28
+ nodes.push({ command: {}, children: new Map() });
29
+ nodes[parent]!.children.set(part, id);
30
+ paths.set(full, id);
31
+ }
32
+ parent = id;
33
+ }
34
+ nodes[parent]!.command = command;
35
+ }
36
+ // Aliases identify a sibling command and share its complete descendant tree.
37
+ for (const node of nodes) {
38
+ for (const id of new Set(node.children.values())) {
39
+ for (const alias of nodes[id]!.command.aliases ?? []) {
40
+ if (!alias || /\s/.test(alias)) throw new Error('Completion command aliases must be single words');
41
+ const existing = node.children.get(alias);
42
+ if (existing !== undefined && existing !== id) throw new Error(`Ambiguous completion alias: ${alias}`);
43
+ node.children.set(alias, id);
44
+ }
45
+ }
46
+ }
47
+ const table = new Map<string, Entry>();
48
+ const put = (key: string, mode: string, values: string[] = []) => table.set(key, { mode, values });
49
+ for (const [id, node] of nodes.entries()) {
50
+ const children = [...node.children].filter(([, child]) => !nodes[child]!.command.hidden);
51
+ put(
52
+ `s:${id}`,
53
+ '',
54
+ children.map(([name]) => name),
55
+ );
56
+ for (const [name, child] of children) put(`c:${id}:${name}`, String(child));
57
+ const flags = new Map<string, FlagItemObject>();
58
+ for (const flag of [...(document.global?.flags ?? []), ...(node.command.flags ?? [])]) {
59
+ flags.set(`--${flag.name}`, flag);
60
+ for (const alias of flag.aliases ?? []) flags.set(`${alias.length === 1 ? '-' : '--'}${alias}`, flag);
61
+ }
62
+ put(
63
+ `o:${id}`,
64
+ '',
65
+ [...flags].filter(([, flag]) => !flag.hidden).map(([name]) => name),
66
+ );
67
+ for (const [name, flag] of flags)
68
+ put(
69
+ `f:${id}:${name}`,
70
+ flag.type === 'boolean' ? 'boolean' : 'value',
71
+ (flag.choices ?? []).map((choice) => String(choice.value)),
72
+ );
73
+ for (const [position, arg] of (node.command.args ?? []).entries())
74
+ put(
75
+ `a:${id}:${position}`,
76
+ arg.variadic ? 'repeat' : 'argument',
77
+ (arg.choices ?? []).map((choice) => String(choice.value)),
78
+ );
79
+ }
80
+ return table;
81
+ }
82
+
83
+ /** Generate a validated, deterministic script with no runtime dependency on clidoc or the target CLI. */
84
+ export function generateCompletion(document: OpenCliDocument, options: CompletionOptions): string {
85
+ const result = validate(document);
86
+ if (!result.valid) throw new Error(`Invalid OpenCLI document: ${result.errors.join('; ')}`);
87
+ if (!['bash', 'zsh', 'fish'].includes(options.shell))
88
+ throw new Error(`Unsupported completion shell: ${options.shell}`);
89
+ const binary = options.binary ?? document.info.binary;
90
+ if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.+-]*$/.test(binary))
91
+ throw new Error('Completion binary must be a single executable name (letters, digits, _, ., +, -)');
92
+ const table = compile(document);
93
+ const text = [
94
+ ...Object.keys(document.commands ?? {}),
95
+ ...[...table].flatMap(([key, entry]) => [key, ...entry.values]),
96
+ ];
97
+ // eslint-disable-next-line no-control-regex -- Shell completion data must not contain control characters.
98
+ if (text.some((value) => /[\x00-\x1f\x7f]/.test(value)))
99
+ throw new Error('Completion names and choices cannot contain control characters');
100
+ const name = `_clidoc_${createHash('sha256').update(binary).digest('hex').slice(0, 16)}`;
101
+ const shell = options.shell;
102
+ const fish = shell === 'fish';
103
+ const quote = (value: string) =>
104
+ `'${fish ? value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") : value.replace(/'/g, "'\\''")}'`;
105
+ const cases = [...table]
106
+ .map(([key, { mode, values }]) =>
107
+ fish
108
+ ? ` case ${quote(key.replace(/[\\*?[\]]/g, '\\$&'))}\n set mode ${quote(mode)}\n set reply ${values.map(quote).join(' ')}`
109
+ : ` ${quote(key)}) mode=${quote(mode)}; reply=(${values.map(quote).join(' ')});;`,
110
+ )
111
+ .join('\n');
112
+ const header = '# Generated by clidoc. Regenerate after changing the CLI schema.\n';
113
+ return header + (fish ? fishScript(name, binary, cases) : shScript(name, binary, cases, shell));
114
+ }
115
+
116
+ function shScript(name: string, binary: string, cases: string, shell: 'bash' | 'zsh'): string {
117
+ const bash = shell === 'bash';
118
+ return `${name}_lookup() {
119
+ mode=''; reply=()
120
+ case "$1" in
121
+ ${cases}
122
+ esac
123
+ }
124
+ ${name}() {
125
+ ${bash ? '' : 'emulate -L zsh'}
126
+ local state=0 pos=0 ended=0 pending='' cur word key mode prefix='' candidate i
127
+ local -a reply candidates tokens
128
+ ${
129
+ bash
130
+ ? `COMPREPLY=()
131
+ # Bash splits '=' at COMP_WORDBREAKS; reconstruct tokens up to the cursor.
132
+ for ((i=1; i<=COMP_CWORD; i++)); do
133
+ word="\${COMP_WORDS[i]}"
134
+ if [[ "$word" == '=' && \${#tokens[@]} -gt 0 ]]; then
135
+ tokens[\${#tokens[@]}-1]+='='
136
+ elif [[ \${#tokens[@]} -gt 0 && "\${tokens[\${#tokens[@]}-1]}" == *= ]]; then
137
+ tokens[\${#tokens[@]}-1]+="$word"
138
+ else
139
+ tokens+=("$word")
140
+ fi
141
+ done
142
+ cur="\${tokens[\${#tokens[@]}-1]}"
143
+ unset "tokens[\${#tokens[@]}-1]"`
144
+ : `tokens=("\${(@)words[2,CURRENT-1]}")
145
+ (( CURRENT > 2 )) || tokens=()
146
+ cur="\${words[CURRENT]}"`
147
+ }
148
+ for word in "\${tokens[@]}"; do
149
+ if [[ -n "$pending" ]]; then pending=''; continue; fi
150
+ if [[ "$ended" == 0 && "$word" == '--' ]]; then ended=1; continue; fi
151
+ if [[ "$ended" == 0 && "$word" == -* ]]; then
152
+ ${name}_lookup "f:$state:\${word%%=*}"
153
+ if [[ "$mode" == value && "$word" != *=* ]]; then pending="f:$state:$word"; fi
154
+ continue
155
+ fi
156
+ if [[ "$ended" == 0 && "$pos" == 0 ]]; then
157
+ ${name}_lookup "c:$state:$word"
158
+ if [[ -n "$mode" ]]; then state="$mode"; continue; fi
159
+ fi
160
+ ${name}_lookup "a:$state:$pos"
161
+ [[ "$mode" == repeat ]] || pos=$((pos + 1))
162
+ done
163
+ if [[ -n "$pending" ]]; then
164
+ ${name}_lookup "$pending"
165
+ candidates=("\${reply[@]}")
166
+ elif [[ "$ended" == 0 && "$cur" == --*=* ]]; then
167
+ key="\${cur%%=*}"
168
+ prefix="$key="
169
+ cur="\${cur#*=}"
170
+ ${name}_lookup "f:$state:$key"
171
+ candidates=("\${reply[@]}")
172
+ elif [[ "$ended" == 0 && "$cur" == -* ]]; then
173
+ ${name}_lookup "o:$state"
174
+ candidates=("\${reply[@]}")
175
+ else
176
+ ${name}_lookup "a:$state:$pos"
177
+ candidates=("\${reply[@]}")
178
+ if [[ "$ended" == 0 && "$pos" == 0 ]]; then
179
+ ${name}_lookup "s:$state"
180
+ candidates+=("\${reply[@]}")
181
+ fi
182
+ fi
183
+ ${
184
+ bash
185
+ ? `for candidate in "\${candidates[@]}"; do
186
+ [[ "$candidate" == "$cur"* ]] && COMPREPLY+=("$prefix$candidate")
187
+ done
188
+ if [[ \${#candidates[@]} == 0 && "$cur" != -* ]]; then
189
+ while IFS= read -r candidate; do COMPREPLY+=("$prefix$candidate"); done < <(compgen -f -- "$cur")
190
+ fi
191
+ # Readline replaces only the value when '=' is a word break.
192
+ if [[ -n "$prefix" && "$COMP_WORDBREAKS" == *'='* ]]; then
193
+ for ((i=0; i<\${#COMPREPLY[@]}; i++)); do COMPREPLY[i]="\${COMPREPLY[i]#*=}"; done
194
+ fi`
195
+ : `if (( \${#candidates[@]} )); then
196
+ [[ -z "$prefix" ]] || compset -P '*='
197
+ compadd -- "\${candidates[@]}"
198
+ elif [[ "$cur" != -* ]]; then
199
+ [[ -z "$prefix" ]] || compset -P '*='
200
+ _files
201
+ fi`
202
+ }
203
+ return 0
204
+ }
205
+ ${bash ? `complete -o filenames -F ${name} -- '${binary}'` : `compdef ${name} '${binary}'`}
206
+ `;
207
+ }
208
+
209
+ function fishScript(name: string, binary: string, cases: string): string {
210
+ // Fish's no-scope-shadowing allows the helper to update caller-local reply/mode.
211
+ const lookup = (key: string) => `${name}_lookup ${key}`;
212
+ return `function ${name}_lookup --no-scope-shadowing
213
+ set mode ''; set reply
214
+ switch "$argv[1]"
215
+ ${cases}
216
+ end
217
+ end
218
+ function ${name}
219
+ set -l tokens (commandline -opc)
220
+ set -e tokens[1]
221
+ set -l cur (commandline -ct)
222
+ set -l state 0
223
+ set -l pos 0
224
+ set -l ended 0
225
+ set -l pending ''
226
+ set -l mode ''
227
+ set -l reply
228
+ set -l candidates
229
+ set -l prefix ''
230
+ for word in $tokens
231
+ if test -n "$pending"
232
+ set pending ''; continue
233
+ end
234
+ if test $ended = 0; and test "$word" = --
235
+ set ended 1; continue
236
+ end
237
+ if test $ended = 0; and string match -q -- '-*' "$word"
238
+ set -l flag (string split -m 1 '=' -- "$word")[1]
239
+ ${lookup('"f:$state:$flag"')}
240
+ if test "$mode" = value; and not string match -q '*=*' -- "$word"
241
+ set pending "f:$state:$word"
242
+ end
243
+ continue
244
+ end
245
+ if test $ended = 0; and test $pos = 0
246
+ ${lookup('"c:$state:$word"')}
247
+ if test -n "$mode"
248
+ set state $mode; continue
249
+ end
250
+ end
251
+ ${lookup('"a:$state:$pos"')}
252
+ if test "$mode" != repeat; set pos (math $pos + 1); end
253
+ end
254
+ if test -n "$pending"
255
+ ${lookup('"$pending"')}
256
+ set candidates $reply
257
+ else if test $ended = 0; and string match -q -- '--*=*' "$cur"
258
+ set -l pair (string split -m 1 '=' -- "$cur")
259
+ set prefix "$pair[1]="
260
+ set cur "$pair[2]"
261
+ ${lookup('"f:$state:$pair[1]"')}
262
+ set candidates $reply
263
+ else if test $ended = 0; and string match -q -- '-*' "$cur"
264
+ ${lookup('"o:$state"')}
265
+ set candidates $reply
266
+ else
267
+ ${lookup('"a:$state:$pos"')}
268
+ set candidates $reply
269
+ if test $ended = 0; and test $pos = 0
270
+ ${lookup('"s:$state"')}
271
+ set -a candidates $reply
272
+ end
273
+ end
274
+ for candidate in $candidates
275
+ printf '%s\\n' "$prefix$candidate"
276
+ end
277
+ if test (count $candidates) = 0; and not string match -q -- '-*' "$cur"
278
+ for candidate in (__fish_complete_path "$cur")
279
+ printf '%s\\n' "$prefix$candidate"
280
+ end
281
+ end
282
+ end
283
+ complete -c '${binary}' -f -a '(${name})'
284
+ `;
285
+ }
@@ -0,0 +1,45 @@
1
+ import { writeOpenCliDocument } from './docgen.js';
2
+ import type { OpenCliDocument } from './types.js';
3
+
4
+ /** Hidden subcommand upstream OpenCLI adapters use for machine discovery. */
5
+ export const OPENCLI_DISCOVERY_COMMAND = '__opencli' as const;
6
+
7
+ /** Sentinel returned by {@link parseOutArg} for argv this module doesn't recognize. */
8
+ const INVALID = Symbol('invalid');
9
+
10
+ /**
11
+ * Parse `-o <file>` / `--out <file>` / `--out=<file>` from the args following `__opencli`,
12
+ * matching upstream OpenCLI's `ocobra` adapter flag. Returns `undefined` for no args (write to
13
+ * stdout), the file path for a recognized flag, or {@link INVALID} for anything else.
14
+ */
15
+ function parseOutArg(args: readonly string[]): string | undefined | typeof INVALID {
16
+ if (args.length === 0) return undefined;
17
+ if (args.length === 1) {
18
+ const match = /^(?:-o|--out)=(.+)$/.exec(args[0]!);
19
+ return match ? match[1]! : INVALID;
20
+ }
21
+ if (args.length === 2 && (args[0] === '-o' || args[0] === '--out')) return args[1];
22
+ return INVALID;
23
+ }
24
+
25
+ /**
26
+ * If argv requests the OpenCLI document (`__opencli` subcommand, optionally with upstream's
27
+ * `-o`/`--out <file>` flag), write it — JSON, 2-space indented, single trailing newline — to that
28
+ * file or to stdout, and return true. Otherwise return false without writing anything, so the
29
+ * caller can continue parsing argv as usual.
30
+ */
31
+ export async function handleOpenCliRequest(
32
+ argv: readonly string[],
33
+ document: () => OpenCliDocument,
34
+ write: (chunk: string) => void = (chunk) => process.stdout.write(chunk),
35
+ ): Promise<boolean> {
36
+ if (argv[0] !== OPENCLI_DISCOVERY_COMMAND) return false;
37
+ const outFile = parseOutArg(argv.slice(1));
38
+ if (outFile === INVALID) return false;
39
+ if (outFile === undefined) {
40
+ write(`${JSON.stringify(document(), null, 2)}\n`);
41
+ } else {
42
+ await writeOpenCliDocument(document(), outFile);
43
+ }
44
+ return true;
45
+ }
package/src/docgen.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { stringify as stringifyYaml } from 'yaml';
3
+ import { renderMarkdown } from './index.js';
4
+ import type { InfoObject, OpenCliDocument } from './types.js';
5
+
6
+ /** The minimal shape of `package.json` used to derive an {@link InfoObject}. */
7
+ export type PackageJsonLike = {
8
+ name?: string;
9
+ version?: string;
10
+ description?: string;
11
+ bin?: string | Record<string, string>;
12
+ };
13
+
14
+ /** Strip a npm scope (`@scope/name` -> `name`) for use as a default binary name. */
15
+ function unscopedName(name: string): string {
16
+ return name.replace(/^@[^/]+\//, '');
17
+ }
18
+
19
+ /**
20
+ * Derive an {@link InfoObject} from a parsed `package.json`, so adapters don't each need their
21
+ * own `title`/`binary`/`version` bookkeeping. `overrides` wins over anything derived from `pkg`,
22
+ * and is required for whatever `pkg` cannot express (e.g. a package that exposes several binaries).
23
+ */
24
+ export function infoFromPackageJson(pkg: PackageJsonLike, overrides: Partial<InfoObject> = {}): InfoObject {
25
+ const binary =
26
+ overrides.binary ??
27
+ (typeof pkg.bin === 'object' && pkg.bin !== null ? Object.keys(pkg.bin)[0] : undefined) ??
28
+ (pkg.name ? unscopedName(pkg.name) : undefined);
29
+ if (!binary) throw new Error('infoFromPackageJson: could not determine a binary name; pass overrides.binary');
30
+ const version = overrides.version ?? pkg.version;
31
+ if (!version) throw new Error('infoFromPackageJson: package.json has no version; pass overrides.version');
32
+ const title = overrides.title ?? pkg.name ?? binary;
33
+ const info: InfoObject = { ...overrides, title, binary, version };
34
+ if (info.summary === undefined && pkg.description !== undefined) info.summary = pkg.description;
35
+ return info;
36
+ }
37
+
38
+ /** Output format for {@link writeOpenCliDocument}. */
39
+ export type DocumentFormat = 'json' | 'yaml' | 'markdown';
40
+
41
+ /**
42
+ * Render `document` (JSON, YAML, or Markdown) and write it to `output`, or to stdout if `output`
43
+ * is omitted. Shared by every adapter's docgen command and by `handleOpenCliRequest`.
44
+ */
45
+ export async function writeOpenCliDocument(
46
+ document: OpenCliDocument,
47
+ output?: string,
48
+ format: DocumentFormat = 'json',
49
+ ): Promise<void> {
50
+ const content =
51
+ format === 'markdown'
52
+ ? renderMarkdown(document)
53
+ : format === 'yaml'
54
+ ? stringifyYaml(document)
55
+ : `${JSON.stringify(document, null, 2)}\n`;
56
+ if (output === undefined) {
57
+ process.stdout.write(content);
58
+ return;
59
+ }
60
+ await writeFile(output, content);
61
+ }