@clidoc/cli 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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +14 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/commands/completion.d.ts +10 -0
  8. package/dist/commands/completion.d.ts.map +1 -0
  9. package/dist/commands/completion.js +24 -0
  10. package/dist/commands/completion.js.map +1 -0
  11. package/dist/commands/docgen.d.ts +6 -0
  12. package/dist/commands/docgen.d.ts.map +1 -0
  13. package/dist/commands/docgen.js +19 -0
  14. package/dist/commands/docgen.js.map +1 -0
  15. package/dist/commands/generate.d.ts +8 -0
  16. package/dist/commands/generate.d.ts.map +1 -0
  17. package/dist/commands/generate.js +40 -0
  18. package/dist/commands/generate.js.map +1 -0
  19. package/dist/commands/markdown.d.ts +6 -0
  20. package/dist/commands/markdown.d.ts.map +1 -0
  21. package/dist/commands/markdown.js +15 -0
  22. package/dist/commands/markdown.js.map +1 -0
  23. package/dist/commands/mcp.d.ts +16 -0
  24. package/dist/commands/mcp.d.ts.map +1 -0
  25. package/dist/commands/mcp.js +39 -0
  26. package/dist/commands/mcp.js.map +1 -0
  27. package/dist/commands/validate.d.ts +4 -0
  28. package/dist/commands/validate.d.ts.map +1 -0
  29. package/dist/commands/validate.js +17 -0
  30. package/dist/commands/validate.js.map +1 -0
  31. package/dist/definition.d.ts +48 -0
  32. package/dist/definition.d.ts.map +1 -0
  33. package/dist/definition.js +28 -0
  34. package/dist/definition.js.map +1 -0
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +21 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/io.d.ts +2 -0
  40. package/dist/io.d.ts.map +1 -0
  41. package/dist/io.js +12 -0
  42. package/dist/io.js.map +1 -0
  43. package/dist/mcp/compile.d.ts +10 -0
  44. package/dist/mcp/compile.d.ts.map +1 -0
  45. package/dist/mcp/compile.js +133 -0
  46. package/dist/mcp/compile.js.map +1 -0
  47. package/dist/mcp/index.d.ts +15 -0
  48. package/dist/mcp/index.d.ts.map +1 -0
  49. package/dist/mcp/index.js +133 -0
  50. package/dist/mcp/index.js.map +1 -0
  51. package/package.json +72 -0
  52. package/src/bin.ts +12 -0
  53. package/src/commands/completion.ts +28 -0
  54. package/src/commands/docgen.ts +21 -0
  55. package/src/commands/generate.ts +42 -0
  56. package/src/commands/markdown.ts +16 -0
  57. package/src/commands/mcp.ts +42 -0
  58. package/src/commands/validate.ts +18 -0
  59. package/src/definition.ts +29 -0
  60. package/src/index.ts +21 -0
  61. package/src/io.ts +12 -0
  62. package/src/mcp/compile.ts +126 -0
  63. package/src/mcp/index.ts +137 -0
@@ -0,0 +1,126 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
3
+ import { validate, type OpenCliDocument, type ArgumentItemObject, type FlagItemObject } from '@clidoc/core';
4
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
5
+
6
+ /** A tool definition and its validated, shell-free argument encoder. */
7
+ export type CompiledMcpTool = { tool: Tool; argv: (input: unknown) => string[] };
8
+ type Parameter = ArgumentItemObject | FlagItemObject;
9
+ const required = (item: Parameter) => item.required === true || (item.minItems ?? 0) > 0;
10
+ const ownValue = (values: Record<string, unknown> | undefined, key: string) =>
11
+ values && Object.hasOwn(values, key) ? values[key] : undefined;
12
+ const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
13
+
14
+ function objectSchema(items: Parameter[]): Record<string, unknown> {
15
+ const properties = Object.fromEntries(
16
+ items.map((item) => {
17
+ if (item.name === '__proto__') throw new Error('Unsupported parameter name __proto__');
18
+ const scalar: Record<string, unknown> = { type: item.type ?? 'string' };
19
+ if (item.choices?.length) scalar.enum = item.choices.map((choice) => choice.value);
20
+ const schema: Record<string, unknown> = item.variadic
21
+ ? {
22
+ type: 'array',
23
+ items: scalar,
24
+ minItems: item.minItems ?? (required(item) ? 1 : 0),
25
+ ...(item.maxItems === undefined ? {} : { maxItems: item.maxItems }),
26
+ }
27
+ : scalar;
28
+ if (item.description ?? item.summary) schema.description = item.description ?? item.summary;
29
+ // Defaults are descriptive only: the executable remains responsible for applying them.
30
+ if ('default' in item && item.default !== undefined && !item.variadic) schema.default = item.default;
31
+ return [item.name, schema];
32
+ }),
33
+ );
34
+ return {
35
+ type: 'object',
36
+ properties,
37
+ required: items.filter(required).map((item) => item.name),
38
+ additionalProperties: false,
39
+ };
40
+ }
41
+
42
+ /** Compile the supported OpenCLI dialect to MCP tools without executing or modifying the spec. */
43
+ export function compileMcpTools(source: OpenCliDocument): CompiledMcpTool[] {
44
+ const result = validate(source);
45
+ if (!result.valid) throw new Error(`Invalid OpenCLI document: ${result.errors.join('; ')}`);
46
+ const document = structuredClone(source);
47
+ const ajv = new Ajv2020({ allErrors: true, strict: false, ownProperties: true });
48
+ const binary = document.info.binary;
49
+ const names = new Set<string>();
50
+ return Object.entries(document.commands ?? {})
51
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
52
+ .filter(([, command]) => !command.hidden && command.kind !== 'group')
53
+ .map(([path, command]) => {
54
+ if (path !== binary && !path.startsWith(`${binary} `))
55
+ throw new Error(`${path}: command must start with binary ${binary}`);
56
+ const suffix = path.slice(binary.length).trim();
57
+ const words = suffix ? suffix.split(' ') : [];
58
+ if (words.some((word) => !identifier.test(word))) throw new Error(`${path}: unsupported command path`);
59
+ const args = command.args ?? [];
60
+ const flagsByName = new Map<string, FlagItemObject>();
61
+ for (const flag of document.global?.flags ?? []) {
62
+ if (flagsByName.has(flag.name)) throw new Error(`${path}: duplicate global flag ${flag.name}`);
63
+ flagsByName.set(flag.name, flag);
64
+ }
65
+ for (const flag of command.flags ?? []) flagsByName.set(flag.name, flag);
66
+ const allFlags = [...flagsByName.values()];
67
+ for (const flag of allFlags) {
68
+ if (!identifier.test(flag.name)) throw new Error(`${path}: unsupported flag name ${flag.name}`);
69
+ if (flag.hidden && required(flag))
70
+ throw new Error(`${path}: hidden required flag ${flag.name} cannot be exposed`);
71
+ if (flag.variadic && flag.type === 'boolean')
72
+ throw new Error(`${path}: variadic boolean flag ${flag.name} is unsupported`);
73
+ }
74
+ const flags = allFlags.filter((flag) => !flag.hidden);
75
+ const argNames = new Set<string>();
76
+ args.forEach((arg, index) => {
77
+ if (argNames.has(arg.name)) throw new Error(`${path}: duplicate argument ${arg.name}`);
78
+ argNames.add(arg.name);
79
+ if (arg.passthrough) throw new Error(`${path}: passthrough argument ${arg.name} is unsupported`);
80
+ if (arg.variadic && index !== args.length - 1) throw new Error(`${path}: variadic argument must be last`);
81
+ });
82
+ const inputSchema: Tool['inputSchema'] = {
83
+ type: 'object',
84
+ properties: { arguments: objectSchema(args), flags: objectSchema(flags) },
85
+ required: [...(args.some(required) ? ['arguments'] : []), ...(flags.some(required) ? ['flags'] : [])],
86
+ additionalProperties: false,
87
+ };
88
+ const check = ajv.compile(inputSchema);
89
+ const readable = path.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 43);
90
+ const name = `${readable}_${createHash('sha256').update(path).digest('hex').slice(0, 20)}`;
91
+ if (names.has(name)) throw new Error(`${path}: MCP tool name collision`);
92
+ names.add(name);
93
+ return {
94
+ tool: { name, title: path, description: command.description ?? command.summary ?? path, inputSchema },
95
+ argv(input: unknown): string[] {
96
+ if (!check(input)) throw new Error(`Invalid tool arguments: ${ajv.errorsText(check.errors)}`);
97
+ const values = input as { arguments?: Record<string, unknown>; flags?: Record<string, unknown> };
98
+ const argv = [...words];
99
+ for (const flag of flags) {
100
+ const value = ownValue(values.flags, flag.name);
101
+ if (value === undefined) continue;
102
+ for (const entry of Array.isArray(value) ? value : [value]) {
103
+ // Explicit false must override a CLI default of true.
104
+ argv.push(
105
+ flag.type === 'boolean' && entry === true ? `--${flag.name}` : `--${flag.name}=${String(entry)}`,
106
+ );
107
+ }
108
+ }
109
+ const positional: string[] = [];
110
+ let gap = false;
111
+ for (const arg of args) {
112
+ const value = ownValue(values.arguments, arg.name);
113
+ if (value === undefined) {
114
+ gap = true;
115
+ continue;
116
+ }
117
+ if (gap) throw new Error(`Cannot supply ${arg.name} after an omitted positional argument`);
118
+ positional.push(...(Array.isArray(value) ? value : [value]).map(String));
119
+ }
120
+ if (positional.length) argv.push('--', ...positional);
121
+ if (argv.some((value) => value.includes('\0'))) throw new Error('CLI arguments cannot contain NUL bytes');
122
+ return argv;
123
+ },
124
+ };
125
+ });
126
+ }
@@ -0,0 +1,137 @@
1
+ import { createRequire } from 'node:module';
2
+ import { spawn } from 'node:child_process';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
6
+ import type { OpenCliDocument } from '@clidoc/core';
7
+ import { compileMcpTools } from './compile.js';
8
+ export { compileMcpTools, type CompiledMcpTool } from './compile.js';
9
+
10
+ /** Executable and process limits are fixed by the host, never by tool inputs. */
11
+ export type McpServerOptions = {
12
+ executable: string;
13
+ cwd?: string;
14
+ timeoutMs?: number;
15
+ maxOutputBytes?: number;
16
+ };
17
+ const packageJson = createRequire(import.meta.url)('../../package.json') as { version: string };
18
+ const failure = (text: string): CallToolResult => ({ content: [{ type: 'text', text }], isError: true });
19
+
20
+ function execute(
21
+ argv: string[],
22
+ options: Required<Pick<McpServerOptions, 'timeoutMs' | 'maxOutputBytes'>> & McpServerOptions,
23
+ signal: AbortSignal,
24
+ ): Promise<CallToolResult> {
25
+ if (signal.aborted) return Promise.resolve(failure('CLI invocation canceled'));
26
+ return new Promise((resolve) => {
27
+ const child = spawn(options.executable, argv, {
28
+ cwd: options.cwd,
29
+ shell: false,
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ });
32
+ const stdout: Buffer[] = [];
33
+ const stderr: Buffer[] = [];
34
+ let size = 0;
35
+ let finished = false;
36
+ const finish = (result: CallToolResult) => {
37
+ if (finished) return;
38
+ finished = true;
39
+ clearTimeout(timer);
40
+ signal.removeEventListener('abort', cancel);
41
+ resolve(result);
42
+ };
43
+ const stop = (message: string) => {
44
+ child.kill('SIGKILL');
45
+ child.stdout.destroy();
46
+ child.stderr.destroy();
47
+ finish(failure(message));
48
+ };
49
+ const cancel = () => stop('CLI invocation canceled');
50
+ const timer = setTimeout(() => stop(`CLI timed out after ${options.timeoutMs} ms`), options.timeoutMs);
51
+ signal.addEventListener('abort', cancel, { once: true });
52
+ const collect = (chunks: Buffer[], chunk: Buffer) => {
53
+ if (finished) return;
54
+ size += chunk.length;
55
+ if (size > options.maxOutputBytes) stop(`CLI output exceeded ${options.maxOutputBytes} bytes`);
56
+ else chunks.push(chunk);
57
+ };
58
+ child.stdout.on('data', (chunk: Buffer) => collect(stdout, chunk));
59
+ child.stderr.on('data', (chunk: Buffer) => collect(stderr, chunk));
60
+ child.on('error', (error) => finish(failure(`Cannot run CLI: ${error.message}`)));
61
+ child.on('close', (code, exitSignal) => {
62
+ const output = {
63
+ stdout: Buffer.concat(stdout).toString('utf8'),
64
+ stderr: Buffer.concat(stderr).toString('utf8'),
65
+ exitCode: code,
66
+ signal: exitSignal,
67
+ };
68
+ finish({ content: [{ type: 'text', text: JSON.stringify(output) }], isError: code !== 0 });
69
+ });
70
+ });
71
+ }
72
+
73
+ /** Create an MCP server for a trusted local CLI. Connect it to a transport or call serveMcp. */
74
+ export function createMcpServer(document: OpenCliDocument, options: McpServerOptions): Server {
75
+ if (!options.executable || options.executable.includes('\0')) throw new Error('An explicit executable is required');
76
+ const limits = {
77
+ ...options,
78
+ timeoutMs: options.timeoutMs ?? 30000,
79
+ maxOutputBytes: options.maxOutputBytes ?? 1048576,
80
+ };
81
+ for (const [name, value] of [
82
+ ['timeoutMs', limits.timeoutMs],
83
+ ['maxOutputBytes', limits.maxOutputBytes],
84
+ ] as const)
85
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647)
86
+ throw new Error(`${name} must be an integer between 1 and 2147483647`);
87
+ const compiled = compileMcpTools(document);
88
+ const tools = new Map(compiled.map((entry) => [entry.tool.name, entry]));
89
+ const server = new Server({ name: 'clidoc-mcp', version: packageJson.version }, { capabilities: { tools: {} } });
90
+ const active = new Set<AbortController>();
91
+ // The SDK exposes onclose as a callback, not an EventTarget.
92
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener
93
+ server.onclose = () => {
94
+ for (const controller of active) controller.abort();
95
+ };
96
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: compiled.map((entry) => entry.tool) }));
97
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
98
+ const entry = tools.get(request.params.name);
99
+ if (!entry) return failure(`Unknown tool: ${request.params.name}`);
100
+ const controller = new AbortController();
101
+ const cancel = () => controller.abort();
102
+ extra.signal.addEventListener('abort', cancel, { once: true });
103
+ if (extra.signal.aborted) controller.abort();
104
+ active.add(controller);
105
+ try {
106
+ return await execute(entry.argv(request.params.arguments ?? {}), limits, controller.signal);
107
+ } catch (error) {
108
+ return failure(error instanceof Error ? error.message : String(error));
109
+ } finally {
110
+ extra.signal.removeEventListener('abort', cancel);
111
+ active.delete(controller);
112
+ }
113
+ });
114
+ return server;
115
+ }
116
+
117
+ /** Connect a server to stdin/stdout; callers can close the returned server. */
118
+ export async function serveMcp(document: OpenCliDocument, options: McpServerOptions): Promise<Server> {
119
+ const server = createMcpServer(document, options);
120
+ await server.connect(new StdioServerTransport());
121
+ const onclose = server.onclose!;
122
+ const shutdown = () => {
123
+ void server.close();
124
+ };
125
+ // The SDK exposes onclose as a callback, not an EventTarget.
126
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener
127
+ server.onclose = () => {
128
+ process.stdin.removeListener('end', shutdown);
129
+ process.removeListener('SIGINT', shutdown);
130
+ process.removeListener('SIGTERM', shutdown);
131
+ onclose();
132
+ };
133
+ process.stdin.once('end', shutdown);
134
+ process.once('SIGINT', shutdown);
135
+ process.once('SIGTERM', shutdown);
136
+ return server;
137
+ }