runspec-node 0.3.0 → 0.8.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +504 -158
- package/dist/cli.js.map +1 -1
- package/dist/finder.d.ts +0 -2
- package/dist/finder.d.ts.map +1 -1
- package/dist/finder.js +2 -40
- package/dist/finder.js.map +1 -1
- package/dist/inference.d.ts.map +1 -1
- package/dist/inference.js +7 -1
- package/dist/inference.js.map +1 -1
- package/dist/jump.d.ts +7 -0
- package/dist/jump.d.ts.map +1 -0
- package/dist/jump.js +246 -0
- package/dist/jump.js.map +1 -0
- package/dist/loader.d.ts +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +20 -21
- package/dist/loader.js.map +1 -1
- package/dist/models.d.ts +19 -7
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +33 -13
- package/dist/parser.js.map +1 -1
- package/dist/runspec.toml +81 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +58 -22
- package/dist/serve.js.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +531 -157
- package/src/finder.ts +3 -41
- package/src/inference.ts +4 -1
- package/src/jump.ts +243 -0
- package/src/loader.ts +22 -21
- package/src/models.ts +20 -7
- package/src/parser.ts +37 -14
- package/src/runspec.toml +81 -0
- package/src/serve.ts +61 -20
- package/src/types.ts +1 -0
- package/tests/test_cli_init.test.ts +210 -0
- package/tests/test_integration.test.ts +21 -21
- package/tests/test_loader.test.ts +8 -43
package/src/serve.ts
CHANGED
|
@@ -12,39 +12,43 @@ const ERR_PARSE = -32700;
|
|
|
12
12
|
const ERR_METHOD_NOT_FOUND = -32601;
|
|
13
13
|
const ERR_INVALID_PARAMS = -32602;
|
|
14
14
|
|
|
15
|
+
const SHELL_EXTS = ['', '.sh', '.ksh', '.bash', '.zsh'];
|
|
16
|
+
|
|
15
17
|
export function serve(): void {
|
|
16
18
|
let configPath: string;
|
|
17
|
-
let format: 'pyproject' | 'runspec';
|
|
18
19
|
|
|
19
20
|
try {
|
|
20
|
-
({ configPath
|
|
21
|
+
({ configPath } = findConfig(process.cwd()));
|
|
21
22
|
} catch (e) {
|
|
22
23
|
process.stderr.write(`runspec serve: ${(e as Error).message}\n`);
|
|
23
24
|
process.exit(1);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const raw = loadRaw(configPath
|
|
27
|
+
const raw = loadRaw(configPath);
|
|
27
28
|
const config = raw.config;
|
|
28
29
|
|
|
29
30
|
const tools: Record<string, Record<string, unknown>> = {};
|
|
30
31
|
const argSpecs: Record<string, Record<string, unknown>> = {};
|
|
32
|
+
const execSpecs: Record<string, { command: string | null }> = {};
|
|
33
|
+
|
|
34
|
+
const binDir = path.join(path.dirname(configPath), 'node_modules', '.bin');
|
|
31
35
|
|
|
32
36
|
for (const [name, runnable] of Object.entries(raw.runnables)) {
|
|
33
37
|
const inferred = inferScript(runnable, config.autonomyDefault);
|
|
34
38
|
tools[name] = buildSchema(name, inferred, 'mcp');
|
|
35
39
|
argSpecs[name] = inferred.args ?? {};
|
|
40
|
+
execSpecs[name] = { command: findScript(name, binDir) };
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
const serverName = serverNameFromConfig(config as unknown as Record<string, unknown>);
|
|
39
|
-
const binDir = path.join(path.dirname(configPath), 'node_modules', '.bin');
|
|
40
44
|
|
|
41
|
-
mcpLoop(tools, argSpecs,
|
|
45
|
+
mcpLoop(tools, argSpecs, execSpecs, serverName);
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
function mcpLoop(
|
|
45
49
|
tools: Record<string, Record<string, unknown>>,
|
|
46
50
|
argSpecs: Record<string, Record<string, unknown>>,
|
|
47
|
-
|
|
51
|
+
execSpecs: Record<string, { command: string | null }>,
|
|
48
52
|
serverName: string,
|
|
49
53
|
): void {
|
|
50
54
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
@@ -61,7 +65,7 @@ function mcpLoop(
|
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const response = dispatch(request, tools, argSpecs,
|
|
68
|
+
const response = dispatch(request, tools, argSpecs, execSpecs, serverName);
|
|
65
69
|
if (response !== null) writeMsg(response);
|
|
66
70
|
});
|
|
67
71
|
}
|
|
@@ -70,7 +74,7 @@ function dispatch(
|
|
|
70
74
|
request: Record<string, unknown>,
|
|
71
75
|
tools: Record<string, Record<string, unknown>>,
|
|
72
76
|
argSpecs: Record<string, Record<string, unknown>>,
|
|
73
|
-
|
|
77
|
+
execSpecs: Record<string, { command: string | null }>,
|
|
74
78
|
serverName: string,
|
|
75
79
|
): Record<string, unknown> | null {
|
|
76
80
|
const method = (request['method'] as string) ?? '';
|
|
@@ -81,14 +85,14 @@ function dispatch(
|
|
|
81
85
|
if (method === 'initialize') return handleInitialize(reqId, serverName);
|
|
82
86
|
if (method === 'tools/list') return handleToolsList(reqId, tools);
|
|
83
87
|
if (method === 'tools/call') {
|
|
84
|
-
return handleToolsCall(reqId, (request['params'] ?? {}) as Record<string, unknown>, tools, argSpecs,
|
|
88
|
+
return handleToolsCall(reqId, (request['params'] ?? {}) as Record<string, unknown>, tools, argSpecs, execSpecs);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
return { jsonrpc: '2.0', id: reqId, error: { code: ERR_METHOD_NOT_FOUND, message: `Method not found: ${method}` } };
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
function handleInitialize(reqId: unknown, serverName: string): Record<string, unknown> {
|
|
91
|
-
const version = '0.
|
|
95
|
+
const version = '0.6.0';
|
|
92
96
|
return {
|
|
93
97
|
jsonrpc: '2.0',
|
|
94
98
|
id: reqId,
|
|
@@ -109,7 +113,7 @@ function handleToolsCall(
|
|
|
109
113
|
params: Record<string, unknown>,
|
|
110
114
|
tools: Record<string, Record<string, unknown>>,
|
|
111
115
|
argSpecs: Record<string, Record<string, unknown>>,
|
|
112
|
-
|
|
116
|
+
execSpecs: Record<string, { command: string | null }>,
|
|
113
117
|
): Record<string, unknown> {
|
|
114
118
|
const name = (params['name'] as string) ?? '';
|
|
115
119
|
const args = (params['arguments'] as Record<string, unknown>) ?? {};
|
|
@@ -118,20 +122,22 @@ function handleToolsCall(
|
|
|
118
122
|
return { jsonrpc: '2.0', id: reqId, error: { code: ERR_INVALID_PARAMS, message: `Unknown tool: ${name}` } };
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
const cmd =
|
|
125
|
+
const cmd = execSpecs[name]?.command ?? null;
|
|
122
126
|
if (!cmd) {
|
|
123
127
|
return {
|
|
124
128
|
jsonrpc: '2.0',
|
|
125
129
|
id: reqId,
|
|
126
130
|
result: {
|
|
127
|
-
content: [{ type: 'text', text: `Script not found in
|
|
131
|
+
content: [{ type: 'text', text: `Script '${name}' not found. Place it alongside runspec.toml or in a bin/ subdirectory.` }],
|
|
128
132
|
isError: true,
|
|
129
133
|
},
|
|
130
134
|
};
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
const
|
|
134
|
-
const
|
|
137
|
+
const toolArgSpecs = argSpecs[name] ?? {};
|
|
138
|
+
const argv = argsToArgv(args, toolArgSpecs);
|
|
139
|
+
const runspecEnv = argsToRunspecEnv(args, toolArgSpecs);
|
|
140
|
+
const env = { ...process.env, RUNSPEC_AGENT: '1', ...runspecEnv };
|
|
135
141
|
|
|
136
142
|
const result = spawnSync(cmd, argv, { encoding: 'utf-8', env });
|
|
137
143
|
|
|
@@ -159,7 +165,7 @@ function argsToArgv(args: Record<string, unknown>, argSpecs: Record<string, unkn
|
|
|
159
165
|
|
|
160
166
|
for (const [argName, spec] of Object.entries(argSpecs)) {
|
|
161
167
|
const s = spec as Record<string, unknown>;
|
|
162
|
-
|
|
168
|
+
const value = args[argName] ?? args[argName.replace(/-/g, '_')];
|
|
163
169
|
if (value === null || value === undefined) continue;
|
|
164
170
|
|
|
165
171
|
const flag = `--${argName}`;
|
|
@@ -177,11 +183,46 @@ function argsToArgv(args: Record<string, unknown>, argSpecs: Record<string, unkn
|
|
|
177
183
|
return argv;
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string, unknown>): Record<string, string> {
|
|
187
|
+
const env: Record<string, string> = {};
|
|
188
|
+
|
|
189
|
+
for (const [argName, spec] of Object.entries(argSpecs)) {
|
|
190
|
+
const s = spec as Record<string, unknown>;
|
|
191
|
+
let value = args[argName] ?? args[argName.replace(/-/g, '_')];
|
|
192
|
+
if (value === null || value === undefined) value = s['default'];
|
|
193
|
+
if (value === null || value === undefined) continue;
|
|
194
|
+
|
|
195
|
+
const envKey = 'RUNSPEC_' + argName.toUpperCase().replace(/-/g, '_').replace(/\./g, '_');
|
|
196
|
+
const argType = (s['type'] as string) ?? 'str';
|
|
197
|
+
|
|
198
|
+
if (argType === 'flag' || argType === 'bool') {
|
|
199
|
+
env[envKey] = value ? '1' : '0';
|
|
200
|
+
} else if (s['multiple'] && Array.isArray(value)) {
|
|
201
|
+
env[envKey] = (value as unknown[]).map(String).join('\n');
|
|
202
|
+
} else {
|
|
203
|
+
env[envKey] = String(value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return env;
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
function findScript(name: string, binDir: string): string | null {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
// 1. node_modules/.bin (Node entry points; also .exe on Windows)
|
|
212
|
+
for (const ext of [...SHELL_EXTS, '.exe']) {
|
|
213
|
+
const candidate = path.join(binDir, name + ext);
|
|
214
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 2. cwd/ and cwd/bin/
|
|
218
|
+
const cwd = process.cwd();
|
|
219
|
+
for (const dir of [cwd, path.join(cwd, 'bin')]) {
|
|
220
|
+
for (const ext of SHELL_EXTS) {
|
|
221
|
+
const candidate = path.join(dir, name + ext);
|
|
222
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
185
226
|
return null;
|
|
186
227
|
}
|
|
187
228
|
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
const CLI = path.resolve(__dirname, '../bin/runspec.js');
|
|
7
|
+
|
|
8
|
+
function tmpDir(): string {
|
|
9
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-init-test-'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function runCLI(dir: string, cliArgs: string[]): { stdout: string; stderr: string } {
|
|
13
|
+
try {
|
|
14
|
+
const stdout = execFileSync('node', [CLI, ...cliArgs], { cwd: dir, encoding: 'utf-8' });
|
|
15
|
+
return { stdout, stderr: '' };
|
|
16
|
+
} catch (e: any) {
|
|
17
|
+
return { stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function runInit(dir: string, extraArgs: string[] = []): { stdout: string; stderr: string } {
|
|
22
|
+
return runCLI(dir, ['init', ...extraArgs]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── init (basic) ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
test('creates runspec.toml and .ts stub by default', () => {
|
|
28
|
+
const dir = tmpDir();
|
|
29
|
+
const { stdout } = runInit(dir, ['--name', 'greet']);
|
|
30
|
+
expect(fs.existsSync(path.join(dir, 'runspec.toml'))).toBe(true);
|
|
31
|
+
expect(fs.existsSync(path.join(dir, 'greet.ts'))).toBe(true);
|
|
32
|
+
expect(stdout).toContain('Created runspec.toml');
|
|
33
|
+
expect(stdout).toContain('Created greet.ts');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('runspec.toml contains the runnable name', () => {
|
|
37
|
+
const dir = tmpDir();
|
|
38
|
+
runInit(dir, ['--name', 'deploy']);
|
|
39
|
+
const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
|
|
40
|
+
expect(toml).toContain('[deploy]');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('typescript stub imports parse from runspec-node', () => {
|
|
44
|
+
const dir = tmpDir();
|
|
45
|
+
runInit(dir, ['--name', 'greet']);
|
|
46
|
+
const ts = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
|
|
47
|
+
expect(ts).toContain("import { parse } from 'runspec-node'");
|
|
48
|
+
expect(ts).toContain('function main()');
|
|
49
|
+
expect(ts).toContain('main();');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('--lang javascript generates .js stub', () => {
|
|
53
|
+
const dir = tmpDir();
|
|
54
|
+
runInit(dir, ['--name', 'greet', '--lang', 'javascript']);
|
|
55
|
+
expect(fs.existsSync(path.join(dir, 'greet.js'))).toBe(true);
|
|
56
|
+
const js = fs.readFileSync(path.join(dir, 'greet.js'), 'utf-8');
|
|
57
|
+
expect(js).toContain("require('runspec-node')");
|
|
58
|
+
expect(js).toContain('main();');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('--lang python generates .py stub', () => {
|
|
62
|
+
const dir = tmpDir();
|
|
63
|
+
runInit(dir, ['--name', 'greet', '--lang', 'python']);
|
|
64
|
+
expect(fs.existsSync(path.join(dir, 'greet.py'))).toBe(true);
|
|
65
|
+
const py = fs.readFileSync(path.join(dir, 'greet.py'), 'utf-8');
|
|
66
|
+
expect(py).toContain('from runspec import parse');
|
|
67
|
+
expect(py).toContain('if __name__ == "__main__"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does not overwrite existing stub', () => {
|
|
71
|
+
const dir = tmpDir();
|
|
72
|
+
const stubPath = path.join(dir, 'greet.ts');
|
|
73
|
+
fs.writeFileSync(stubPath, '// existing\n', 'utf-8');
|
|
74
|
+
const { stdout } = runInit(dir, ['--name', 'greet']);
|
|
75
|
+
expect(fs.readFileSync(stubPath, 'utf-8')).toBe('// existing\n');
|
|
76
|
+
expect(stdout).toContain('already exists — skipped');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('fails if runspec.toml already exists', () => {
|
|
80
|
+
const dir = tmpDir();
|
|
81
|
+
fs.writeFileSync(path.join(dir, 'runspec.toml'), '[greet]\n', 'utf-8');
|
|
82
|
+
const { stdout } = runInit(dir, ['--name', 'greet']);
|
|
83
|
+
expect(stdout).toContain('already exists');
|
|
84
|
+
expect(fs.existsSync(path.join(dir, 'greet.ts'))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('unknown --lang exits with error', () => {
|
|
88
|
+
const dir = tmpDir();
|
|
89
|
+
const { stdout } = runInit(dir, ['--name', 'greet', '--lang', 'ruby']);
|
|
90
|
+
expect(stdout).toContain('Unknown --lang');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── init --example ────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
test('--example creates runspec.toml with clean and scan', () => {
|
|
96
|
+
const dir = tmpDir();
|
|
97
|
+
runInit(dir, ['--example']);
|
|
98
|
+
const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
|
|
99
|
+
expect(toml).toContain('[clean]');
|
|
100
|
+
expect(toml).toContain('[scan]');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('--example creates clean.ts and scan.ts by default', () => {
|
|
104
|
+
const dir = tmpDir();
|
|
105
|
+
runInit(dir, ['--example']);
|
|
106
|
+
expect(fs.existsSync(path.join(dir, 'clean.ts'))).toBe(true);
|
|
107
|
+
expect(fs.existsSync(path.join(dir, 'scan.ts'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('--example clean stub uses __runspec_agent__ check', () => {
|
|
111
|
+
const dir = tmpDir();
|
|
112
|
+
runInit(dir, ['--example']);
|
|
113
|
+
const ts = fs.readFileSync(path.join(dir, 'clean.ts'), 'utf-8');
|
|
114
|
+
expect(ts).toContain('__runspec_agent__');
|
|
115
|
+
expect(ts).toContain('delete');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('--example scan stub always outputs JSON', () => {
|
|
119
|
+
const dir = tmpDir();
|
|
120
|
+
runInit(dir, ['--example']);
|
|
121
|
+
const ts = fs.readFileSync(path.join(dir, 'scan.ts'), 'utf-8');
|
|
122
|
+
expect(ts).toContain('JSON.stringify');
|
|
123
|
+
expect(ts).not.toContain('format');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('--example scan toml has no format or delete arg', () => {
|
|
127
|
+
const dir = tmpDir();
|
|
128
|
+
runInit(dir, ['--example']);
|
|
129
|
+
const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
|
|
130
|
+
const scanArgsSection = toml.split('[scan.args]')[1] ?? '';
|
|
131
|
+
expect(scanArgsSection).not.toContain('format');
|
|
132
|
+
expect(scanArgsSection).not.toContain('delete');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('--example scan toml declares output = json', () => {
|
|
136
|
+
const dir = tmpDir();
|
|
137
|
+
runInit(dir, ['--example']);
|
|
138
|
+
const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
|
|
139
|
+
expect(toml).toContain('output = "json"');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('--example shows demo prep commands', () => {
|
|
143
|
+
const dir = tmpDir();
|
|
144
|
+
const { stdout } = runInit(dir, ['--example']);
|
|
145
|
+
expect(stdout).toContain('touch -t 202401010000 report.tmp cache.tmp session.tmp');
|
|
146
|
+
expect(stdout).toContain('scan');
|
|
147
|
+
expect(stdout).toContain('clean');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('--example with --name warns name is ignored', () => {
|
|
151
|
+
const dir = tmpDir();
|
|
152
|
+
const { stdout } = runInit(dir, ['--example', '--name', 'myapp']);
|
|
153
|
+
expect(stdout).toContain('--name is ignored');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('--example --lang javascript creates clean.js and scan.js', () => {
|
|
157
|
+
const dir = tmpDir();
|
|
158
|
+
runInit(dir, ['--example', '--lang', 'javascript']);
|
|
159
|
+
expect(fs.existsSync(path.join(dir, 'clean.js'))).toBe(true);
|
|
160
|
+
expect(fs.existsSync(path.join(dir, 'scan.js'))).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── local command ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
test('local command is recognized', () => {
|
|
166
|
+
const dir = tmpDir();
|
|
167
|
+
const { stdout } = runCLI(dir, ['local']);
|
|
168
|
+
expect(stdout).not.toContain('Unknown command');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('discover command is no longer available', () => {
|
|
172
|
+
const dir = tmpDir();
|
|
173
|
+
const { stdout } = runCLI(dir, ['discover']);
|
|
174
|
+
expect(stdout).toContain('Unknown command');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('check command is no longer available', () => {
|
|
178
|
+
const dir = tmpDir();
|
|
179
|
+
const { stdout } = runCLI(dir, ['check']);
|
|
180
|
+
expect(stdout).toContain('Unknown command');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('emit command is no longer available', () => {
|
|
184
|
+
const dir = tmpDir();
|
|
185
|
+
const { stdout } = runCLI(dir, ['emit']);
|
|
186
|
+
expect(stdout).toContain('Unknown command');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('jump command requires a host name', () => {
|
|
190
|
+
const dir = tmpDir();
|
|
191
|
+
const { stderr } = runCLI(dir, ['jump']);
|
|
192
|
+
expect(stderr).toContain('jump host name is required');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── help ──────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
test('top-level help mentions local and jump', () => {
|
|
198
|
+
const dir = tmpDir();
|
|
199
|
+
const { stdout } = runCLI(dir, ['--help']);
|
|
200
|
+
expect(stdout).toContain('local');
|
|
201
|
+
expect(stdout).toContain('jump');
|
|
202
|
+
expect(stdout).not.toContain('discover');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('local --help shows focused help', () => {
|
|
206
|
+
const dir = tmpDir();
|
|
207
|
+
const { stdout } = runCLI(dir, ['local', '--help']);
|
|
208
|
+
expect(stdout).toContain('--format');
|
|
209
|
+
expect(stdout).toContain('--script');
|
|
210
|
+
});
|
|
@@ -11,26 +11,26 @@ const COMPLEX = path.join(FIXTURES, 'complex.toml');
|
|
|
11
11
|
|
|
12
12
|
describe('simple.toml', () => {
|
|
13
13
|
test('loads config section', () => {
|
|
14
|
-
const raw = loadRaw(SIMPLE
|
|
14
|
+
const raw = loadRaw(SIMPLE);
|
|
15
15
|
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
test('greet runnable present', () => {
|
|
19
|
-
const raw = loadRaw(SIMPLE
|
|
19
|
+
const raw = loadRaw(SIMPLE);
|
|
20
20
|
expect(raw.runnables['greet']).toBeDefined();
|
|
21
21
|
expect(raw.runnables['greet'].description).toBe('Greet someone from the command line');
|
|
22
22
|
expect(raw.runnables['greet'].autonomy).toBe('autonomous');
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test('greet args: name is str and required', () => {
|
|
26
|
-
const raw = loadRaw(SIMPLE
|
|
26
|
+
const raw = loadRaw(SIMPLE);
|
|
27
27
|
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
28
28
|
expect(inferred.args['name'].type).toBe('str');
|
|
29
29
|
expect(inferred.args['name'].required).toBe(true);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test('greet args: loud inferred as flag', () => {
|
|
33
|
-
const raw = loadRaw(SIMPLE
|
|
33
|
+
const raw = loadRaw(SIMPLE);
|
|
34
34
|
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
35
35
|
expect(inferred.args['loud'].type).toBe('flag');
|
|
36
36
|
expect(inferred.args['loud'].required).toBe(false);
|
|
@@ -38,7 +38,7 @@ describe('simple.toml', () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test('greet args: times inferred as int', () => {
|
|
41
|
-
const raw = loadRaw(SIMPLE
|
|
41
|
+
const raw = loadRaw(SIMPLE);
|
|
42
42
|
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
43
43
|
expect(inferred.args['times'].type).toBe('int');
|
|
44
44
|
expect(inferred.args['times'].default).toBe(1);
|
|
@@ -49,27 +49,27 @@ describe('simple.toml', () => {
|
|
|
49
49
|
|
|
50
50
|
describe('complex.toml', () => {
|
|
51
51
|
test('loads config section', () => {
|
|
52
|
-
const raw = loadRaw(COMPLEX
|
|
52
|
+
const raw = loadRaw(COMPLEX);
|
|
53
53
|
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
54
54
|
expect(raw.config.lang).toBe('python');
|
|
55
55
|
expect(raw.config.version).toBe('1');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
test('pipeline runnable present', () => {
|
|
59
|
-
const raw = loadRaw(COMPLEX
|
|
59
|
+
const raw = loadRaw(COMPLEX);
|
|
60
60
|
expect(raw.runnables['pipeline']).toBeDefined();
|
|
61
61
|
expect(raw.runnables['pipeline'].description).toBe('Process and validate data pipeline files');
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test('pipeline has run and validate subcommands', () => {
|
|
65
|
-
const raw = loadRaw(COMPLEX
|
|
65
|
+
const raw = loadRaw(COMPLEX);
|
|
66
66
|
const cmds = raw.runnables['pipeline'].commands;
|
|
67
67
|
expect(cmds['run']).toBeDefined();
|
|
68
68
|
expect(cmds['validate']).toBeDefined();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
test('run subcommand: input is path and required', () => {
|
|
72
|
-
const raw = loadRaw(COMPLEX
|
|
72
|
+
const raw = loadRaw(COMPLEX);
|
|
73
73
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
74
74
|
const run = inferred.commands['run'];
|
|
75
75
|
expect(run.args['input'].type).toBe('path');
|
|
@@ -77,7 +77,7 @@ describe('complex.toml', () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
test('run subcommand: format is choice with default', () => {
|
|
80
|
-
const raw = loadRaw(COMPLEX
|
|
80
|
+
const raw = loadRaw(COMPLEX);
|
|
81
81
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
82
82
|
const run = inferred.commands['run'];
|
|
83
83
|
expect(run.args['format'].type).toBe('choice');
|
|
@@ -87,7 +87,7 @@ describe('complex.toml', () => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
test('run subcommand: workers inferred as int with range', () => {
|
|
90
|
-
const raw = loadRaw(COMPLEX
|
|
90
|
+
const raw = loadRaw(COMPLEX);
|
|
91
91
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
92
92
|
const run = inferred.commands['run'];
|
|
93
93
|
expect(run.args['workers'].type).toBe('int');
|
|
@@ -96,7 +96,7 @@ describe('complex.toml', () => {
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
test('run subcommand: dry-run inferred as flag', () => {
|
|
99
|
-
const raw = loadRaw(COMPLEX
|
|
99
|
+
const raw = loadRaw(COMPLEX);
|
|
100
100
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
101
101
|
const run = inferred.commands['run'];
|
|
102
102
|
expect(run.args['dry-run'].type).toBe('flag');
|
|
@@ -104,7 +104,7 @@ describe('complex.toml', () => {
|
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
test('run subcommand: tag is multiple', () => {
|
|
107
|
-
const raw = loadRaw(COMPLEX
|
|
107
|
+
const raw = loadRaw(COMPLEX);
|
|
108
108
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
109
109
|
const run = inferred.commands['run'];
|
|
110
110
|
expect(run.args['tag'].multiple).toBe(true);
|
|
@@ -112,7 +112,7 @@ describe('complex.toml', () => {
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
test('run subcommand: fields has delimiter', () => {
|
|
115
|
-
const raw = loadRaw(COMPLEX
|
|
115
|
+
const raw = loadRaw(COMPLEX);
|
|
116
116
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
117
117
|
const run = inferred.commands['run'];
|
|
118
118
|
expect(run.args['fields'].delimiter).toBe(',');
|
|
@@ -120,7 +120,7 @@ describe('complex.toml', () => {
|
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
test('run subcommand: api-key has env and autonomy', () => {
|
|
123
|
-
const raw = loadRaw(COMPLEX
|
|
123
|
+
const raw = loadRaw(COMPLEX);
|
|
124
124
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
125
125
|
const run = inferred.commands['run'];
|
|
126
126
|
expect(run.args['api-key'].env).toBe('PIPELINE_API_KEY');
|
|
@@ -128,21 +128,21 @@ describe('complex.toml', () => {
|
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
test('run subcommand: verbose has short flag', () => {
|
|
131
|
-
const raw = loadRaw(COMPLEX
|
|
131
|
+
const raw = loadRaw(COMPLEX);
|
|
132
132
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
133
133
|
const run = inferred.commands['run'];
|
|
134
134
|
expect(run.args['verbose'].short).toBe('-v');
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
test('run subcommand: threads has deprecated field', () => {
|
|
138
|
-
const raw = loadRaw(COMPLEX
|
|
138
|
+
const raw = loadRaw(COMPLEX);
|
|
139
139
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
140
140
|
const run = inferred.commands['run'];
|
|
141
141
|
expect(run.args['threads'].deprecated).toBe('use --workers instead');
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
test('run subcommand: groups defined', () => {
|
|
145
|
-
const raw = loadRaw(COMPLEX
|
|
145
|
+
const raw = loadRaw(COMPLEX);
|
|
146
146
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
147
147
|
const run = inferred.commands['run'];
|
|
148
148
|
expect(run.groups['input-format']).toBeDefined();
|
|
@@ -152,21 +152,21 @@ describe('complex.toml', () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
test('validate subcommand: is autonomous', () => {
|
|
155
|
-
const raw = loadRaw(COMPLEX
|
|
155
|
+
const raw = loadRaw(COMPLEX);
|
|
156
156
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
157
157
|
const validate = inferred.commands['validate'];
|
|
158
158
|
expect(validate.autonomy).toBe('autonomous');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
test('validate subcommand: format is choice', () => {
|
|
162
|
-
const raw = loadRaw(COMPLEX
|
|
162
|
+
const raw = loadRaw(COMPLEX);
|
|
163
163
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
164
164
|
const validate = inferred.commands['validate'];
|
|
165
165
|
expect(validate.args['format'].type).toBe('choice');
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
test('run subcommand: autonomy-reason preserved', () => {
|
|
169
|
-
const raw = loadRaw(COMPLEX
|
|
169
|
+
const raw = loadRaw(COMPLEX);
|
|
170
170
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
171
171
|
const run = inferred.commands['run'];
|
|
172
172
|
expect(run.autonomyReason).toBe('Writes output files and may call external APIs');
|
|
@@ -25,7 +25,7 @@ autonomy = "autonomous"
|
|
|
25
25
|
name = {type = "str"}
|
|
26
26
|
loud = {default = false}
|
|
27
27
|
`);
|
|
28
|
-
const raw = loadRaw(file
|
|
28
|
+
const raw = loadRaw(file);
|
|
29
29
|
expect(raw.runnables['greet']).toBeDefined();
|
|
30
30
|
expect(raw.runnables['greet'].description).toBe('Greet someone');
|
|
31
31
|
expect(raw.runnables['greet'].args['name'].type).toBe('str');
|
|
@@ -39,7 +39,7 @@ test('normalises hyphenated field names', () => {
|
|
|
39
39
|
[deploy]
|
|
40
40
|
autonomy-reason = "Irreversible"
|
|
41
41
|
`);
|
|
42
|
-
const raw = loadRaw(file
|
|
42
|
+
const raw = loadRaw(file);
|
|
43
43
|
expect(raw.runnables['deploy'].autonomyReason).toBe('Irreversible');
|
|
44
44
|
});
|
|
45
45
|
|
|
@@ -54,7 +54,7 @@ version = "1"
|
|
|
54
54
|
[greet]
|
|
55
55
|
description = "hi"
|
|
56
56
|
`);
|
|
57
|
-
const raw = loadRaw(file
|
|
57
|
+
const raw = loadRaw(file);
|
|
58
58
|
expect(raw.config.autonomyDefault).toBe('autonomous');
|
|
59
59
|
expect(raw.config.version).toBe('1');
|
|
60
60
|
});
|
|
@@ -69,46 +69,11 @@ autonomy-default = "confirm"
|
|
|
69
69
|
[greet]
|
|
70
70
|
description = "hi"
|
|
71
71
|
`);
|
|
72
|
-
const raw = loadRaw(file
|
|
72
|
+
const raw = loadRaw(file);
|
|
73
73
|
expect('config' in raw.runnables).toBe(false);
|
|
74
74
|
expect('greet' in raw.runnables).toBe(true);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
// ── pyproject.toml format ─────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
test('loads pyproject.toml format', () => {
|
|
80
|
-
const dir = tmpDir();
|
|
81
|
-
const file = path.join(dir, 'pyproject.toml');
|
|
82
|
-
fs.writeFileSync(file, `
|
|
83
|
-
[project]
|
|
84
|
-
name = "myproject"
|
|
85
|
-
|
|
86
|
-
[tool.runspec.greet]
|
|
87
|
-
description = "Greet"
|
|
88
|
-
autonomy = "confirm"
|
|
89
|
-
|
|
90
|
-
[tool.runspec.greet.args]
|
|
91
|
-
name = {type = "str"}
|
|
92
|
-
`);
|
|
93
|
-
const raw = loadRaw(file, 'pyproject');
|
|
94
|
-
expect(raw.runnables['greet']).toBeDefined();
|
|
95
|
-
expect(raw.runnables['greet'].args['name'].type).toBe('str');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('reads entry points from pyproject.toml', () => {
|
|
99
|
-
const dir = tmpDir();
|
|
100
|
-
const file = path.join(dir, 'pyproject.toml');
|
|
101
|
-
fs.writeFileSync(file, `
|
|
102
|
-
[project.scripts]
|
|
103
|
-
greet = "myapp.greet:main"
|
|
104
|
-
|
|
105
|
-
[tool.runspec.greet]
|
|
106
|
-
description = "Greet"
|
|
107
|
-
`);
|
|
108
|
-
const raw = loadRaw(file, 'pyproject');
|
|
109
|
-
expect(raw.entryPoints['greet']).toBe('myapp.greet:main');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
77
|
// ── arg normalisation ─────────────────────────────────────────────────────────
|
|
113
78
|
|
|
114
79
|
test('normalises bare value shorthand', () => {
|
|
@@ -122,7 +87,7 @@ description = "hi"
|
|
|
122
87
|
loud = false
|
|
123
88
|
times = 1
|
|
124
89
|
`);
|
|
125
|
-
const raw = loadRaw(file
|
|
90
|
+
const raw = loadRaw(file);
|
|
126
91
|
expect(raw.runnables['greet'].args['loud'].default).toBe(false);
|
|
127
92
|
expect(raw.runnables['greet'].args['times'].default).toBe(1);
|
|
128
93
|
});
|
|
@@ -137,7 +102,7 @@ description = "hi"
|
|
|
137
102
|
[greet.args]
|
|
138
103
|
workers = {default = 4, range = [1, 32]}
|
|
139
104
|
`);
|
|
140
|
-
const raw = loadRaw(file
|
|
105
|
+
const raw = loadRaw(file);
|
|
141
106
|
expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
|
|
142
107
|
});
|
|
143
108
|
|
|
@@ -152,7 +117,7 @@ description = "hi"
|
|
|
152
117
|
exclusive = true
|
|
153
118
|
args = ["json", "csv"]
|
|
154
119
|
`);
|
|
155
|
-
const raw = loadRaw(file
|
|
120
|
+
const raw = loadRaw(file);
|
|
156
121
|
const group = raw.runnables['pipeline'].groups['formats'];
|
|
157
122
|
expect(group.exclusive).toBe(true);
|
|
158
123
|
expect(group.args).toEqual(['json', 'csv']);
|
|
@@ -164,6 +129,6 @@ test('autonomy-default falls back to confirm', () => {
|
|
|
164
129
|
const dir = tmpDir();
|
|
165
130
|
const file = path.join(dir, 'runspec.toml');
|
|
166
131
|
fs.writeFileSync(file, `[greet]\ndescription = "hi"\n`);
|
|
167
|
-
const raw = loadRaw(file
|
|
132
|
+
const raw = loadRaw(file);
|
|
168
133
|
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
169
134
|
});
|