runspec-node 0.3.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 (64) hide show
  1. package/bin/runspec.js +4 -0
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +384 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/errors.d.ts +25 -0
  7. package/dist/errors.d.ts.map +1 -0
  8. package/dist/errors.js +146 -0
  9. package/dist/errors.js.map +1 -0
  10. package/dist/finder.d.ts +6 -0
  11. package/dist/finder.d.ts.map +1 -0
  12. package/dist/finder.js +91 -0
  13. package/dist/finder.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +22 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/inference.d.ts +10 -0
  19. package/dist/inference.d.ts.map +1 -0
  20. package/dist/inference.js +69 -0
  21. package/dist/inference.js.map +1 -0
  22. package/dist/loader.d.ts +3 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +142 -0
  25. package/dist/loader.js.map +1 -0
  26. package/dist/models.d.ts +57 -0
  27. package/dist/models.d.ts.map +1 -0
  28. package/dist/models.js +3 -0
  29. package/dist/models.js.map +1 -0
  30. package/dist/parser.d.ts +10 -0
  31. package/dist/parser.d.ts.map +1 -0
  32. package/dist/parser.js +251 -0
  33. package/dist/parser.js.map +1 -0
  34. package/dist/serve.d.ts +2 -0
  35. package/dist/serve.d.ts.map +1 -0
  36. package/dist/serve.js +199 -0
  37. package/dist/serve.js.map +1 -0
  38. package/dist/types.d.ts +6 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +121 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/validator.d.ts +5 -0
  43. package/dist/validator.d.ts.map +1 -0
  44. package/dist/validator.js +56 -0
  45. package/dist/validator.js.map +1 -0
  46. package/jest.config.js +8 -0
  47. package/package.json +36 -0
  48. package/src/cli.ts +378 -0
  49. package/src/errors.ts +126 -0
  50. package/src/finder.ts +59 -0
  51. package/src/index.ts +6 -0
  52. package/src/inference.ts +77 -0
  53. package/src/loader.ts +120 -0
  54. package/src/models.ts +61 -0
  55. package/src/parser.ts +239 -0
  56. package/src/serve.ts +196 -0
  57. package/src/types.ts +96 -0
  58. package/src/validator.ts +64 -0
  59. package/tests/test_inference.test.ts +153 -0
  60. package/tests/test_integration.test.ts +197 -0
  61. package/tests/test_loader.test.ts +169 -0
  62. package/tests/test_types.test.ts +110 -0
  63. package/tests/test_validator.test.ts +120 -0
  64. package/tsconfig.json +18 -0
@@ -0,0 +1,77 @@
1
+ import type { ArgSpec, ScriptSpec } from './models';
2
+ import { RunSpecError } from './errors';
3
+
4
+ export const AUTONOMY_LEVELS = ['autonomous', 'confirm', 'supervised', 'manual'] as const;
5
+ export const AUTONOMY_RANK = Object.fromEntries(AUTONOMY_LEVELS.map((l, i) => [l, i]));
6
+
7
+ export function inferArg(raw: ArgSpec): ArgSpec {
8
+ const result = { ...raw };
9
+ const def = result.default;
10
+ const options = result.options;
11
+
12
+ if (!result.type) {
13
+ if (options !== undefined) {
14
+ result.type = 'choice';
15
+ } else if (typeof def === 'boolean') {
16
+ result.type = 'flag';
17
+ } else if (typeof def === 'number' && Number.isInteger(def)) {
18
+ result.type = 'int';
19
+ } else if (typeof def === 'number') {
20
+ result.type = 'float';
21
+ } else if (typeof def === 'string') {
22
+ result.type = 'str';
23
+ } else {
24
+ result.type = 'str';
25
+ }
26
+ }
27
+
28
+ if (result.required === undefined) {
29
+ const hasNoDefault = def === undefined || def === null;
30
+ result.required = hasNoDefault && result.type !== 'flag';
31
+ }
32
+
33
+ if (result.type === 'choice' && (!options || options.length === 0)) {
34
+ throw new RunSpecError(`Argument '${raw.name}' has type 'choice' but no 'options' list was provided.`);
35
+ }
36
+
37
+ return result;
38
+ }
39
+
40
+ export function inferScript(raw: ScriptSpec, configAutonomy: string): ScriptSpec {
41
+ const result = { ...raw };
42
+
43
+ if (!result.autonomy) {
44
+ result.autonomy = configAutonomy;
45
+ }
46
+
47
+ result.args = Object.fromEntries(Object.entries(result.args ?? {}).map(([name, arg]) => [name, inferArg(arg)]));
48
+
49
+ result.commands = Object.fromEntries(
50
+ Object.entries(result.commands ?? {}).map(([name, cmd]) => [name, inferScript(cmd, configAutonomy)]),
51
+ );
52
+
53
+ return result;
54
+ }
55
+
56
+ export function effectiveAutonomy(
57
+ scriptAutonomy: string,
58
+ providedArgs: Record<string, unknown>,
59
+ argSpecs: Record<string, ArgSpec>,
60
+ ): string {
61
+ let effective = scriptAutonomy;
62
+
63
+ for (const [argName, value] of Object.entries(providedArgs)) {
64
+ if (value === null || value === undefined) continue;
65
+ const spec = argSpecs[argName];
66
+ const argAutonomy = spec?.autonomy;
67
+ if (argAutonomy && isMoreRestrictive(argAutonomy, effective)) {
68
+ effective = argAutonomy;
69
+ }
70
+ }
71
+
72
+ return effective;
73
+ }
74
+
75
+ export function isMoreRestrictive(candidate: string, current: string): boolean {
76
+ return (AUTONOMY_RANK[candidate] ?? 0) > (AUTONOMY_RANK[current] ?? 0);
77
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,120 @@
1
+ import * as fs from 'fs';
2
+ import { parse as parseTOML } from 'smol-toml';
3
+ import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec } from './models';
4
+
5
+ export function loadRaw(configPath: string, format: 'pyproject' | 'runspec'): RawSpec {
6
+ const content = fs.readFileSync(configPath, 'utf-8');
7
+ const data = parseTOML(content) as Record<string, unknown>;
8
+
9
+ let raw: Record<string, unknown>;
10
+ let entryPoints: Record<string, string> = {};
11
+
12
+ if (format === 'pyproject') {
13
+ raw = ((data as any)?.tool?.runspec ?? {}) as Record<string, unknown>;
14
+ entryPoints = readEntryPoints(data);
15
+ } else {
16
+ raw = data;
17
+ }
18
+
19
+ const runnablesRaw: Record<string, Record<string, unknown>> = {};
20
+ for (const [key, value] of Object.entries(raw)) {
21
+ if (key !== 'config' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
22
+ runnablesRaw[key] = value as Record<string, unknown>;
23
+ }
24
+ }
25
+
26
+ return {
27
+ config: normaliseConfig((raw['config'] ?? {}) as Record<string, unknown>),
28
+ runnables: normaliseRunnables(runnablesRaw),
29
+ entryPoints,
30
+ };
31
+ }
32
+
33
+ function normaliseConfig(raw: Record<string, unknown>): RawConfig {
34
+ return {
35
+ autonomyDefault: (raw['autonomy-default'] as string | undefined) ?? 'confirm',
36
+ lang: raw['lang'] as string | undefined,
37
+ version: String(raw['version'] ?? '1'),
38
+ };
39
+ }
40
+
41
+ function normaliseRunnables(raw: Record<string, Record<string, unknown>>): Record<string, ScriptSpec> {
42
+ return Object.fromEntries(Object.entries(raw).map(([name, data]) => [name, normaliseScript(name, data)]));
43
+ }
44
+
45
+ function normaliseScript(name: string, raw: Record<string, unknown>): ScriptSpec {
46
+ return {
47
+ name,
48
+ description: raw['description'] as string | undefined,
49
+ autonomy: raw['autonomy'] as string | undefined,
50
+ autonomyReason: raw['autonomy-reason'] as string | undefined,
51
+ output: raw['output'] as string | undefined,
52
+ args: normaliseArgs((raw['args'] ?? {}) as Record<string, unknown>),
53
+ groups: normaliseGroups((raw['groups'] ?? {}) as Record<string, unknown>),
54
+ commands: normaliseCommands((raw['commands'] ?? {}) as Record<string, Record<string, unknown>>),
55
+ };
56
+ }
57
+
58
+ function normaliseArgs(raw: Record<string, unknown>): Record<string, ArgSpec> {
59
+ return Object.fromEntries(
60
+ Object.entries(raw).map(([name, value]) => {
61
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
62
+ return [name, normaliseArg(name, value as Record<string, unknown>)];
63
+ }
64
+ return [name, normaliseArg(name, { default: value })];
65
+ }),
66
+ );
67
+ }
68
+
69
+ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
70
+ return {
71
+ name,
72
+ type: (raw['type'] as string | undefined) ?? undefined,
73
+ required: raw['required'] as boolean | undefined,
74
+ default: raw['default'],
75
+ description: raw['description'] as string | undefined,
76
+ options: raw['options'] as string[] | undefined,
77
+ range: raw['range'] as [number, number] | undefined,
78
+ multiple: (raw['multiple'] as boolean | undefined) ?? false,
79
+ delimiter: raw['delimiter'] as string | undefined,
80
+ short: raw['short'] as string | undefined,
81
+ env: raw['env'] as string | undefined,
82
+ deprecated: raw['deprecated'] as string | undefined,
83
+ autonomy: raw['autonomy'] as string | undefined,
84
+ ui: raw['ui'] as string | undefined,
85
+ meta: raw['meta'] as Record<string, unknown> | undefined,
86
+ };
87
+ }
88
+
89
+ function normaliseGroups(raw: Record<string, unknown>): Record<string, GroupSpec> {
90
+ return Object.fromEntries(
91
+ Object.entries(raw).map(([name, data]) => {
92
+ const g = data as Record<string, unknown>;
93
+ return [
94
+ name,
95
+ {
96
+ name,
97
+ args: (g['args'] as string[]) ?? [],
98
+ exclusive: (g['exclusive'] as boolean | undefined) ?? false,
99
+ inclusive: (g['inclusive'] as boolean | undefined) ?? false,
100
+ atLeastOne: (g['at-least-one'] as boolean | undefined) ?? false,
101
+ exactlyOne: (g['exactly-one'] as boolean | undefined) ?? false,
102
+ condition: g['if'] as string | undefined,
103
+ requires: (g['requires'] as string[] | undefined) ?? [],
104
+ } satisfies GroupSpec,
105
+ ];
106
+ }),
107
+ );
108
+ }
109
+
110
+ function normaliseCommands(raw: Record<string, Record<string, unknown>>): Record<string, ScriptSpec> {
111
+ return Object.fromEntries(Object.entries(raw).map(([name, data]) => [name, normaliseScript(name, data)]));
112
+ }
113
+
114
+ function readEntryPoints(data: Record<string, unknown>): Record<string, string> {
115
+ const projectScripts = (data as any)?.project?.scripts;
116
+ if (projectScripts && typeof projectScripts === 'object') return projectScripts as Record<string, string>;
117
+ const poetryScripts = (data as any)?.tool?.poetry?.scripts;
118
+ if (poetryScripts && typeof poetryScripts === 'object') return poetryScripts as Record<string, string>;
119
+ return {};
120
+ }
package/src/models.ts ADDED
@@ -0,0 +1,61 @@
1
+ export interface RawConfig {
2
+ autonomyDefault: string;
3
+ lang?: string;
4
+ version: string;
5
+ }
6
+
7
+ export interface ArgSpec {
8
+ name: string;
9
+ type?: string;
10
+ required?: boolean;
11
+ default?: unknown;
12
+ description?: string;
13
+ options?: string[];
14
+ range?: [number, number];
15
+ multiple?: boolean;
16
+ delimiter?: string;
17
+ short?: string;
18
+ env?: string;
19
+ deprecated?: string;
20
+ autonomy?: string;
21
+ ui?: string;
22
+ meta?: Record<string, unknown>;
23
+ }
24
+
25
+ export interface GroupSpec {
26
+ name: string;
27
+ args: string[];
28
+ exclusive?: boolean;
29
+ inclusive?: boolean;
30
+ atLeastOne?: boolean;
31
+ exactlyOne?: boolean;
32
+ condition?: string;
33
+ requires?: string[];
34
+ }
35
+
36
+ export interface ScriptSpec {
37
+ name: string;
38
+ description?: string;
39
+ autonomy?: string;
40
+ autonomyReason?: string;
41
+ output?: string;
42
+ args: Record<string, ArgSpec>;
43
+ groups: Record<string, GroupSpec>;
44
+ commands: Record<string, ScriptSpec>;
45
+ }
46
+
47
+ export interface RawSpec {
48
+ config: RawConfig;
49
+ runnables: Record<string, ScriptSpec>;
50
+ entryPoints: Record<string, string>;
51
+ }
52
+
53
+ export interface ParsedArgs {
54
+ [key: string]: unknown;
55
+ readonly __agent__: boolean;
56
+ readonly __script__: string;
57
+ readonly __command__: string | undefined;
58
+ readonly __autonomy__: string;
59
+ readonly __source__: string;
60
+ readonly __spec__: ScriptSpec;
61
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,239 @@
1
+ import * as path from 'path';
2
+ import { findConfig, findScriptName } from './finder';
3
+ import { loadRaw } from './loader';
4
+ import { inferScript, effectiveAutonomy } from './inference';
5
+ import { coerce } from './types';
6
+ import { validateArgs, validateGroups, raiseIfErrors } from './validator';
7
+ import { RunSpecError } from './errors';
8
+ import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
9
+
10
+ export interface ParseOptions {
11
+ scriptName?: string;
12
+ argv?: string[];
13
+ cwd?: string;
14
+ }
15
+
16
+ export function parse(opts: ParseOptions = {}): ParsedArgs {
17
+ const { scriptName, argv: argvOverride, cwd } = opts;
18
+
19
+ const { configPath, format } = findConfig(cwd);
20
+ const raw = loadRaw(configPath, format);
21
+ const config = raw.config;
22
+
23
+ const name = scriptName ?? findScriptName(configPath, format) ?? inferFromArgv();
24
+ if (!name) throw new RunSpecError('✗ Could not determine runnable name. Pass scriptName option.');
25
+ if (name === 'config') throw new RunSpecError("✗ 'config' is a reserved name in runspec.\n Rename your runnable.");
26
+
27
+ if (!(name in raw.runnables)) {
28
+ const available = Object.keys(raw.runnables).join(', ') || '(none)';
29
+ throw new RunSpecError(`✗ Runnable '${name}' not found.\n Available: ${available}\n Config: ${configPath}`);
30
+ }
31
+
32
+ const rawScript = inferScript(raw.runnables[name], config.autonomyDefault);
33
+
34
+ let argv = argvOverride ?? process.argv.slice(2);
35
+ let activeScript = rawScript;
36
+ let activeCommand: string | undefined;
37
+
38
+ const commands = rawScript.commands ?? {};
39
+ if (Object.keys(commands).length > 0 && argv.length > 0 && argv[0] in commands) {
40
+ activeCommand = argv[0];
41
+ activeScript = commands[argv[0]];
42
+ argv = argv.slice(1);
43
+ }
44
+
45
+ if (argv.includes('--help') || argv.includes('-h')) {
46
+ printHelp(name, activeScript, activeCommand);
47
+ process.exit(0);
48
+ }
49
+
50
+ let parsedValues = parseArgv(argv, activeScript.args ?? {});
51
+ parsedValues = applyEnv(parsedValues, activeScript.args ?? {});
52
+ parsedValues = applyDefaults(parsedValues, activeScript.args ?? {});
53
+
54
+ raiseIfErrors(validateArgs(parsedValues, activeScript.args ?? {}));
55
+ raiseIfErrors(validateGroups(parsedValues, activeScript.groups ?? {}));
56
+
57
+ const coercedValues = coerceValues(parsedValues, activeScript.args ?? {});
58
+
59
+ const autonomy = effectiveAutonomy(
60
+ activeScript.autonomy ?? config.autonomyDefault,
61
+ parsedValues,
62
+ activeScript.args ?? {},
63
+ );
64
+
65
+ const agent = ['1', 'true', 'yes'].includes((process.env['RUNSPEC_AGENT'] ?? '').toLowerCase());
66
+
67
+ return {
68
+ ...coercedValues,
69
+ __agent__: agent,
70
+ __script__: name,
71
+ __command__: activeCommand,
72
+ __autonomy__: autonomy,
73
+ __source__: configPath,
74
+ __spec__: activeScript,
75
+ } as ParsedArgs;
76
+ }
77
+
78
+ export function loadSpec(opts: ParseOptions = {}): ParsedArgs {
79
+ return parse({ ...opts, argv: [] });
80
+ }
81
+
82
+ function inferFromArgv(): string {
83
+ const argv1 = process.argv[1] ?? '';
84
+ return path.basename(argv1, path.extname(argv1)) || 'unknown';
85
+ }
86
+
87
+ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<string, unknown> {
88
+ const nameMap: Record<string, string> = {};
89
+ const shortMap: Record<string, string> = {};
90
+
91
+ for (const [name, spec] of Object.entries(argSpecs)) {
92
+ const norm = name.replace(/-/g, '_');
93
+ nameMap[`--${name}`] = norm;
94
+ nameMap[`--${norm}`] = norm;
95
+ if (spec.short) shortMap[spec.short] = norm;
96
+ }
97
+
98
+ const result: Record<string, unknown> = {};
99
+ for (const name of Object.keys(argSpecs)) {
100
+ result[name.replace(/-/g, '_')] = undefined;
101
+ }
102
+
103
+ let i = 0;
104
+ while (i < argv.length) {
105
+ const token = argv[i];
106
+
107
+ if (token.startsWith('--') && token.includes('=')) {
108
+ const eqIdx = token.indexOf('=');
109
+ const key = token.slice(0, eqIdx);
110
+ const value = token.slice(eqIdx + 1);
111
+ const norm = nameMap[key];
112
+ if (norm) {
113
+ const hyphenName = norm.replace(/_/g, '-');
114
+ const spec = argSpecs[hyphenName] ?? argSpecs[norm] ?? {};
115
+ result[norm] = appendOrSet(result[norm], value, spec);
116
+ }
117
+ i++;
118
+ continue;
119
+ }
120
+
121
+ const norm = nameMap[token] ?? shortMap[token];
122
+ if (norm) {
123
+ const hyphenName = norm.replace(/_/g, '-');
124
+ const spec = argSpecs[hyphenName] ?? argSpecs[norm] ?? {};
125
+ const argType = spec.type ?? 'str';
126
+
127
+ if (argType === 'flag') {
128
+ result[norm] = true;
129
+ i++;
130
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
131
+ const raw = argv[i + 1];
132
+ const delimiter = spec.delimiter;
133
+ const parsed: string | string[] = delimiter ? raw.split(delimiter) : raw;
134
+ result[norm] = appendOrSet(result[norm], parsed, spec);
135
+ i += 2;
136
+ } else {
137
+ result[norm] = true;
138
+ i++;
139
+ }
140
+ continue;
141
+ }
142
+
143
+ i++;
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ function appendOrSet(current: unknown, value: unknown, spec: ArgSpec): unknown {
150
+ if (spec.multiple) {
151
+ if (Array.isArray(value)) return [...((current as unknown[]) ?? []), ...value];
152
+ return [...((current as unknown[]) ?? []), value];
153
+ }
154
+ return value;
155
+ }
156
+
157
+ function applyEnv(parsed: Record<string, unknown>, argSpecs: Record<string, ArgSpec>): Record<string, unknown> {
158
+ const result = { ...parsed };
159
+ for (const [name, spec] of Object.entries(argSpecs)) {
160
+ const norm = name.replace(/-/g, '_');
161
+ if ((result[norm] === null || result[norm] === undefined) && spec.env) {
162
+ const envVal = process.env[spec.env];
163
+ if (envVal !== undefined) result[norm] = envVal;
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+
169
+ function applyDefaults(parsed: Record<string, unknown>, argSpecs: Record<string, ArgSpec>): Record<string, unknown> {
170
+ const result = { ...parsed };
171
+ for (const [name, spec] of Object.entries(argSpecs)) {
172
+ const norm = name.replace(/-/g, '_');
173
+ if ((result[norm] === null || result[norm] === undefined) && spec.default !== undefined && spec.default !== null) {
174
+ result[norm] = spec.default;
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+
180
+ function coerceValues(parsed: Record<string, unknown>, argSpecs: Record<string, ArgSpec>): Record<string, unknown> {
181
+ const result: Record<string, unknown> = {};
182
+ for (const [name, spec] of Object.entries(argSpecs)) {
183
+ const norm = name.replace(/-/g, '_');
184
+ const value = parsed[norm];
185
+ if (value === null || value === undefined) {
186
+ result[norm] = undefined;
187
+ continue;
188
+ }
189
+ try {
190
+ result[norm] = coerce(value, spec);
191
+ } catch (e) {
192
+ throw new RunSpecError(`✗ ${(e as Error).message}`);
193
+ }
194
+ }
195
+ return result;
196
+ }
197
+
198
+ export function printHelp(name: string, script: ScriptSpec, command?: string): void {
199
+ const fullName = command ? `${name} ${command}` : name;
200
+ const args = script.args ?? {};
201
+
202
+ const usageParts = [fullName];
203
+ for (const [argName, spec] of Object.entries(args)) {
204
+ const flag = `--${argName}`;
205
+ if (spec.type === 'flag') {
206
+ usageParts.push(`[${flag}]`);
207
+ } else if (spec.required) {
208
+ usageParts.push(`${flag} <${spec.type ?? 'str'}>`);
209
+ } else {
210
+ usageParts.push(`[${flag} <${spec.type ?? 'str'}>]`);
211
+ }
212
+ }
213
+
214
+ console.log(`Usage: ${usageParts.join(' ')}`);
215
+ if (script.description) console.log(`\n${script.description}`);
216
+
217
+ if (Object.keys(args).length > 0) {
218
+ console.log('\nArguments:');
219
+ for (const [argName, spec] of Object.entries(args)) {
220
+ const flag = ` --${argName}`;
221
+ const parts: string[] = [];
222
+ if (spec.type === 'flag') parts.push('flag');
223
+ else parts.push(spec.type ?? 'str');
224
+ if (spec.required) parts.push('required');
225
+ else if (spec.default !== undefined && spec.default !== null) parts.push(`default: ${spec.default}`);
226
+ if (spec.options) parts.push(`one of: ${spec.options.join(', ')}`);
227
+ const meta = `(${parts.join(', ')})`;
228
+ if (spec.description) console.log(`${flag.padEnd(24)} ${spec.description} ${meta}`);
229
+ else console.log(`${flag.padEnd(24)} ${meta}`);
230
+ }
231
+ }
232
+
233
+ if (script.autonomy) {
234
+ console.log(`\nAutonomy: ${script.autonomy}`);
235
+ if (script.autonomyReason) console.log(` ${script.autonomyReason}`);
236
+ }
237
+
238
+ console.log('\n -h, --help Show this message and exit');
239
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,196 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as readline from 'readline';
4
+ import { spawnSync } from 'child_process';
5
+ import { findConfig } from './finder';
6
+ import { loadRaw } from './loader';
7
+ import { inferScript } from './inference';
8
+ import { buildSchema } from './cli';
9
+
10
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
11
+ const ERR_PARSE = -32700;
12
+ const ERR_METHOD_NOT_FOUND = -32601;
13
+ const ERR_INVALID_PARAMS = -32602;
14
+
15
+ export function serve(): void {
16
+ let configPath: string;
17
+ let format: 'pyproject' | 'runspec';
18
+
19
+ try {
20
+ ({ configPath, format } = findConfig(process.cwd()));
21
+ } catch (e) {
22
+ process.stderr.write(`runspec serve: ${(e as Error).message}\n`);
23
+ process.exit(1);
24
+ }
25
+
26
+ const raw = loadRaw(configPath, format);
27
+ const config = raw.config;
28
+
29
+ const tools: Record<string, Record<string, unknown>> = {};
30
+ const argSpecs: Record<string, Record<string, unknown>> = {};
31
+
32
+ for (const [name, runnable] of Object.entries(raw.runnables)) {
33
+ const inferred = inferScript(runnable, config.autonomyDefault);
34
+ tools[name] = buildSchema(name, inferred, 'mcp');
35
+ argSpecs[name] = inferred.args ?? {};
36
+ }
37
+
38
+ const serverName = serverNameFromConfig(config as unknown as Record<string, unknown>);
39
+ const binDir = path.join(path.dirname(configPath), 'node_modules', '.bin');
40
+
41
+ mcpLoop(tools, argSpecs, binDir, serverName);
42
+ }
43
+
44
+ function mcpLoop(
45
+ tools: Record<string, Record<string, unknown>>,
46
+ argSpecs: Record<string, Record<string, unknown>>,
47
+ binDir: string,
48
+ serverName: string,
49
+ ): void {
50
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
51
+
52
+ rl.on('line', (line) => {
53
+ const trimmed = line.trim();
54
+ if (!trimmed) return;
55
+
56
+ let request: Record<string, unknown>;
57
+ try {
58
+ request = JSON.parse(trimmed) as Record<string, unknown>;
59
+ } catch {
60
+ writeMsg({ jsonrpc: '2.0', id: null, error: { code: ERR_PARSE, message: 'Parse error' } });
61
+ return;
62
+ }
63
+
64
+ const response = dispatch(request, tools, argSpecs, binDir, serverName);
65
+ if (response !== null) writeMsg(response);
66
+ });
67
+ }
68
+
69
+ function dispatch(
70
+ request: Record<string, unknown>,
71
+ tools: Record<string, Record<string, unknown>>,
72
+ argSpecs: Record<string, Record<string, unknown>>,
73
+ binDir: string,
74
+ serverName: string,
75
+ ): Record<string, unknown> | null {
76
+ const method = (request['method'] as string) ?? '';
77
+ const reqId = request['id'];
78
+
79
+ if (reqId === undefined || reqId === null) return null;
80
+
81
+ if (method === 'initialize') return handleInitialize(reqId, serverName);
82
+ if (method === 'tools/list') return handleToolsList(reqId, tools);
83
+ if (method === 'tools/call') {
84
+ return handleToolsCall(reqId, (request['params'] ?? {}) as Record<string, unknown>, tools, argSpecs, binDir);
85
+ }
86
+
87
+ return { jsonrpc: '2.0', id: reqId, error: { code: ERR_METHOD_NOT_FOUND, message: `Method not found: ${method}` } };
88
+ }
89
+
90
+ function handleInitialize(reqId: unknown, serverName: string): Record<string, unknown> {
91
+ const version = '0.3.0';
92
+ return {
93
+ jsonrpc: '2.0',
94
+ id: reqId,
95
+ result: {
96
+ protocolVersion: MCP_PROTOCOL_VERSION,
97
+ capabilities: { tools: {} },
98
+ serverInfo: { name: serverName, version },
99
+ },
100
+ };
101
+ }
102
+
103
+ function handleToolsList(reqId: unknown, tools: Record<string, Record<string, unknown>>): Record<string, unknown> {
104
+ return { jsonrpc: '2.0', id: reqId, result: { tools: Object.values(tools) } };
105
+ }
106
+
107
+ function handleToolsCall(
108
+ reqId: unknown,
109
+ params: Record<string, unknown>,
110
+ tools: Record<string, Record<string, unknown>>,
111
+ argSpecs: Record<string, Record<string, unknown>>,
112
+ binDir: string,
113
+ ): Record<string, unknown> {
114
+ const name = (params['name'] as string) ?? '';
115
+ const args = (params['arguments'] as Record<string, unknown>) ?? {};
116
+
117
+ if (!(name in tools)) {
118
+ return { jsonrpc: '2.0', id: reqId, error: { code: ERR_INVALID_PARAMS, message: `Unknown tool: ${name}` } };
119
+ }
120
+
121
+ const cmd = findScript(name, binDir);
122
+ if (!cmd) {
123
+ return {
124
+ jsonrpc: '2.0',
125
+ id: reqId,
126
+ result: {
127
+ content: [{ type: 'text', text: `Script not found in ${binDir}: ${name}` }],
128
+ isError: true,
129
+ },
130
+ };
131
+ }
132
+
133
+ const argv = argsToArgv(args, argSpecs[name] ?? {});
134
+ const env = { ...process.env, RUNSPEC_AGENT: '1' };
135
+
136
+ const result = spawnSync(cmd, argv, { encoding: 'utf-8', env });
137
+
138
+ if (result.status === 0) {
139
+ return {
140
+ jsonrpc: '2.0',
141
+ id: reqId,
142
+ result: { content: [{ type: 'text', text: result.stdout ?? '' }], isError: false },
143
+ };
144
+ }
145
+
146
+ const parts = [`exit_code: ${result.status ?? 'unknown'}`];
147
+ if (result.stdout) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
148
+ if (result.stderr) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
149
+
150
+ return {
151
+ jsonrpc: '2.0',
152
+ id: reqId,
153
+ result: { content: [{ type: 'text', text: parts.join('\n') }], isError: true },
154
+ };
155
+ }
156
+
157
+ function argsToArgv(args: Record<string, unknown>, argSpecs: Record<string, unknown>): string[] {
158
+ const argv: string[] = [];
159
+
160
+ for (const [argName, spec] of Object.entries(argSpecs)) {
161
+ const s = spec as Record<string, unknown>;
162
+ let value = args[argName] ?? args[argName.replace(/-/g, '_')];
163
+ if (value === null || value === undefined) continue;
164
+
165
+ const flag = `--${argName}`;
166
+ const argType = (s['type'] as string) ?? 'str';
167
+
168
+ if (argType === 'flag') {
169
+ if (value) argv.push(flag);
170
+ } else if (s['multiple'] && Array.isArray(value)) {
171
+ for (const item of value) argv.push(flag, String(item));
172
+ } else {
173
+ argv.push(flag, String(value));
174
+ }
175
+ }
176
+
177
+ return argv;
178
+ }
179
+
180
+ function findScript(name: string, binDir: string): string | null {
181
+ const candidate = path.join(binDir, name);
182
+ if (fs.existsSync(candidate)) return candidate;
183
+ const candidateExe = path.join(binDir, name + '.exe');
184
+ if (fs.existsSync(candidateExe)) return candidateExe;
185
+ return null;
186
+ }
187
+
188
+ function serverNameFromConfig(config: Record<string, unknown>): string {
189
+ const name = config['name'];
190
+ if (name && typeof name === 'string') return name;
191
+ return path.basename(path.dirname(process.execPath));
192
+ }
193
+
194
+ function writeMsg(response: Record<string, unknown>): void {
195
+ process.stdout.write(JSON.stringify(response) + '\n');
196
+ }