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/cli.ts
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
3
4
|
import { findConfig } from './finder';
|
|
4
5
|
import { loadRaw } from './loader';
|
|
5
6
|
import { inferScript } from './inference';
|
|
6
|
-
import
|
|
7
|
+
import { parse } from './parser';
|
|
8
|
+
import type { ScriptSpec, ArgSpec, JumpHostConfig } from './models';
|
|
9
|
+
|
|
10
|
+
const _CLI_CONFIG = path.join(__dirname, 'runspec.toml');
|
|
7
11
|
|
|
8
12
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
9
13
|
|
|
10
14
|
export function main(): void {
|
|
11
15
|
const args = process.argv.slice(2);
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
const command = args[0];
|
|
18
|
+
const rest = args.slice(1);
|
|
19
|
+
|
|
20
|
+
// Per-subcommand help
|
|
21
|
+
const preHelpArgs = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
|
|
22
|
+
if (command && command !== '-h' && command !== '--help' && (preHelpArgs.includes('-h') || preHelpArgs.includes('--help'))) {
|
|
23
|
+
printCommandHelp(command);
|
|
15
24
|
return;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
if (!args.length || command === '-h' || command === '--help') {
|
|
28
|
+
printHelp();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
20
31
|
|
|
21
|
-
const commands: Record<string, (args: string[]) => void
|
|
32
|
+
const commands: Record<string, (args: string[]) => void | Promise<void>> = {
|
|
22
33
|
init: cmdInit,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
emit: cmdEmit,
|
|
34
|
+
local: cmdLocal,
|
|
35
|
+
jump: cmdJump,
|
|
26
36
|
serve: cmdServe,
|
|
27
37
|
};
|
|
28
38
|
|
|
@@ -32,47 +42,69 @@ export function main(): void {
|
|
|
32
42
|
process.exit(1);
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
commands[command](rest);
|
|
45
|
+
const result = commands[command](rest);
|
|
46
|
+
if (result instanceof Promise) {
|
|
47
|
+
result.catch((err: unknown) => {
|
|
48
|
+
process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
39
55
|
|
|
40
56
|
function cmdInit(args: string[]): void {
|
|
41
57
|
const nameFlag = getFlag(args, '--name');
|
|
42
|
-
const
|
|
58
|
+
const langFlag = getFlag(args, '--lang') ?? 'typescript';
|
|
59
|
+
const example = args.includes('--example');
|
|
43
60
|
|
|
44
61
|
const cwd = process.cwd();
|
|
45
|
-
const runnableName = nameFlag ?? sanitizeName(path.basename(cwd));
|
|
46
|
-
|
|
47
|
-
const pyproject = path.join(cwd, 'pyproject.toml');
|
|
48
62
|
const runspecToml = path.join(cwd, 'runspec.toml');
|
|
49
63
|
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
if (example) {
|
|
65
|
+
if (nameFlag) {
|
|
66
|
+
console.log(' ℹ --name is ignored with --example (runnables are always clean and scan)');
|
|
67
|
+
}
|
|
68
|
+
initExampleToml(runspecToml);
|
|
69
|
+
initExampleStubs(cwd, langFlag);
|
|
70
|
+
printNextSteps(cwd, true);
|
|
71
|
+
return;
|
|
56
72
|
}
|
|
73
|
+
|
|
74
|
+
const runnableName = nameFlag ?? sanitizeName(path.basename(cwd));
|
|
75
|
+
initRunspecToml(runspecToml, runnableName);
|
|
76
|
+
initCodeStub(cwd, runnableName, langFlag);
|
|
77
|
+
printNextSteps(cwd, false);
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
function
|
|
80
|
+
function cmdLocal(args: string[]): void {
|
|
60
81
|
const fmt = getFlag(args, '--format') ?? 'text';
|
|
82
|
+
const scriptName = getFlag(args, '--script');
|
|
61
83
|
|
|
62
84
|
const discovered = discoverLocal();
|
|
63
85
|
|
|
64
86
|
if (!discovered.length) {
|
|
65
87
|
console.log('No runspec-aware runnables found in this environment.');
|
|
66
|
-
console.log(
|
|
88
|
+
console.log("Create a runspec.toml inside your package directory and run 'runspec init' to get started.");
|
|
67
89
|
return;
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
// Filter to single runnable if --script given
|
|
93
|
+
const filtered = scriptName
|
|
94
|
+
? discovered.filter((d) => d.runnable === scriptName)
|
|
95
|
+
: discovered;
|
|
96
|
+
|
|
97
|
+
if (scriptName && !filtered.length) {
|
|
98
|
+
console.log(`✗ Runnable '${scriptName}' not found`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
if (fmt === 'text') {
|
|
71
|
-
|
|
103
|
+
printLocalText(filtered);
|
|
72
104
|
} else if (fmt === 'json') {
|
|
73
|
-
console.log(JSON.stringify(
|
|
105
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
74
106
|
} else if (['mcp', 'openai', 'anthropic'].includes(fmt)) {
|
|
75
|
-
console.log(JSON.stringify(emitAll(
|
|
107
|
+
console.log(JSON.stringify(emitAll(filtered, fmt), null, 2));
|
|
76
108
|
} else {
|
|
77
109
|
console.log(`✗ Unknown format: ${fmt}`);
|
|
78
110
|
console.log(' Available formats: text, json, mcp, openai, anthropic');
|
|
@@ -80,102 +112,116 @@ function cmdDiscover(args: string[]): void {
|
|
|
80
112
|
}
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
async function cmdJump(args: string[]): Promise<void> {
|
|
116
|
+
const parsed = parse({ scriptName: 'runspec', argv: ['jump', ...args], configPath: _CLI_CONFIG });
|
|
117
|
+
const listHosts = Boolean(parsed['list_jump_hosts']);
|
|
118
|
+
const fmt = String(parsed['format'] ?? 'text');
|
|
86
119
|
|
|
87
|
-
|
|
88
|
-
(
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
if (listHosts) {
|
|
121
|
+
cmdListJumpHosts(fmt);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hostName = parsed['jump_host'] as string | undefined;
|
|
126
|
+
if (!hostName) {
|
|
127
|
+
process.stderr.write('✗ A jump host name is required\n');
|
|
128
|
+
process.stderr.write(' Usage: runspec jump <jump-host> [<tool>] [-- tool-args...]\n');
|
|
129
|
+
process.stderr.write(" Run 'runspec jump --list-jump-hosts' to see configured jump hosts\n");
|
|
91
130
|
process.exit(1);
|
|
92
131
|
}
|
|
93
132
|
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const warnings: string[] = [];
|
|
97
|
-
const ok: string[] = [];
|
|
133
|
+
const hostCfg = loadJumpHost(hostName);
|
|
134
|
+
const toolName = parsed['tool'] as string | undefined;
|
|
98
135
|
|
|
99
|
-
|
|
136
|
+
const { listTools, callTool } = await import('./jump');
|
|
100
137
|
|
|
101
|
-
if (
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
138
|
+
if (!toolName) {
|
|
139
|
+
const tools = await listTools(hostCfg);
|
|
140
|
+
if (!tools.length) {
|
|
141
|
+
console.log(`No tools found on ${hostName}.`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(`Tools on ${hostName}:\n`);
|
|
145
|
+
for (const tool of tools) {
|
|
146
|
+
const name = String(tool['name'] ?? '').padEnd(24);
|
|
147
|
+
const desc = String(tool['description'] ?? '');
|
|
148
|
+
console.log(` ${name} ${desc}`);
|
|
107
149
|
}
|
|
150
|
+
return;
|
|
108
151
|
}
|
|
109
152
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
const toolArgv = (parsed['tool_args'] as string[] | undefined) ?? [];
|
|
154
|
+
await callTool(hostCfg, toolName, toolArgv);
|
|
155
|
+
}
|
|
113
156
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
157
|
+
function cmdListJumpHosts(fmt: string): void {
|
|
158
|
+
let configPath: string;
|
|
159
|
+
try {
|
|
160
|
+
({ configPath } = findConfig(process.cwd()));
|
|
161
|
+
} catch {
|
|
162
|
+
process.stderr.write('✗ No runspec.toml found — cannot look up jump hosts\n');
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
120
165
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
} else {
|
|
124
|
-
ok.push(`'${name}' — autonomy: ${runnable.autonomy}`);
|
|
125
|
-
}
|
|
166
|
+
const raw = loadRaw(configPath);
|
|
167
|
+
const hosts = raw.config.jumpHosts ?? {};
|
|
126
168
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
169
|
+
if (!Object.keys(hosts).length) {
|
|
170
|
+
console.log('No jump hosts configured.');
|
|
171
|
+
console.log('Add [config.jump-hosts.<name>] sections to your runspec.toml.');
|
|
172
|
+
return;
|
|
132
173
|
}
|
|
133
174
|
|
|
134
|
-
|
|
135
|
-
for (const msg of warnings) console.log(` ℹ ${msg}`);
|
|
136
|
-
for (const msg of errors) console.log(` ✗ ${msg}`);
|
|
175
|
+
const { resolveBinRaw } = require('./jump') as { resolveBinRaw: (h: JumpHostConfig) => string };
|
|
137
176
|
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
if (fmt === 'json') {
|
|
178
|
+
const effective = Object.entries(hosts).map(([name, cfg]) => ({
|
|
179
|
+
name,
|
|
180
|
+
...cfg,
|
|
181
|
+
bin: resolveBinRaw(cfg),
|
|
182
|
+
}));
|
|
183
|
+
console.log(JSON.stringify(effective, null, 2));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
141
186
|
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
187
|
+
console.log('Configured jump hosts:\n');
|
|
188
|
+
for (const [name, cfg] of Object.entries(hosts)) {
|
|
189
|
+
const effectiveBin = resolveBinRaw(cfg);
|
|
190
|
+
console.log(` ${name.padEnd(20)} ${cfg.host} (bin: ${effectiveBin})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
145
193
|
|
|
194
|
+
function loadJumpHost(name: string): JumpHostConfig {
|
|
146
195
|
let configPath: string;
|
|
147
|
-
let format: 'pyproject' | 'runspec';
|
|
148
|
-
|
|
149
196
|
try {
|
|
150
|
-
({ configPath
|
|
151
|
-
} catch
|
|
152
|
-
|
|
197
|
+
({ configPath } = findConfig(process.cwd()));
|
|
198
|
+
} catch {
|
|
199
|
+
process.stderr.write(`✗ No runspec.toml found — cannot look up jump host '${name}'\n`);
|
|
153
200
|
process.exit(1);
|
|
154
201
|
}
|
|
155
202
|
|
|
156
|
-
const raw = loadRaw(configPath
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
let runnables = raw.runnables;
|
|
160
|
-
if (scriptName) {
|
|
161
|
-
if (!(scriptName in runnables)) {
|
|
162
|
-
console.log(`✗ Runnable '${scriptName}' not found`);
|
|
163
|
-
process.exit(1);
|
|
164
|
-
}
|
|
165
|
-
runnables = { [scriptName]: runnables[scriptName] };
|
|
166
|
-
}
|
|
203
|
+
const raw = loadRaw(configPath!);
|
|
204
|
+
const hosts = raw.config.jumpHosts ?? {};
|
|
167
205
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
if (!(name in hosts)) {
|
|
207
|
+
const available = Object.keys(hosts).join(', ') || '(none)';
|
|
208
|
+
process.stderr.write(`✗ Jump host '${name}' not found in runspec.toml\n`);
|
|
209
|
+
process.stderr.write(` Configured jump hosts: ${available}\n`);
|
|
210
|
+
process.exit(1);
|
|
172
211
|
}
|
|
173
212
|
|
|
174
|
-
|
|
175
|
-
console.log(JSON.stringify(output, null, 2));
|
|
213
|
+
return hosts[name]!;
|
|
176
214
|
}
|
|
177
215
|
|
|
178
216
|
function cmdServe(_args: string[]): void {
|
|
217
|
+
if (process.stdin.isTTY) {
|
|
218
|
+
console.log('runspec serve is an MCP stdio server — it is not run directly from a terminal.');
|
|
219
|
+
console.log('Configure it as an MCP server in your MCP host (Claude Desktop, VS Code, PyCharm, etc.)');
|
|
220
|
+
console.log();
|
|
221
|
+
console.log('To test manually:');
|
|
222
|
+
console.log(' echo \'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}\' | runspec serve');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
179
225
|
const { serve } = require('./serve') as { serve: () => void };
|
|
180
226
|
serve();
|
|
181
227
|
}
|
|
@@ -233,8 +279,8 @@ function argToJsonSchema(arg: ArgSpec): Record<string, unknown> {
|
|
|
233
279
|
|
|
234
280
|
function discoverLocal(): Array<{ source: string; runnable: string; spec: ScriptSpec }> {
|
|
235
281
|
try {
|
|
236
|
-
const { configPath
|
|
237
|
-
const raw = loadRaw(configPath
|
|
282
|
+
const { configPath } = findConfig(process.cwd());
|
|
283
|
+
const raw = loadRaw(configPath);
|
|
238
284
|
return Object.entries(raw.runnables).map(([name, spec]) => ({ source: configPath, runnable: name, spec }));
|
|
239
285
|
} catch {
|
|
240
286
|
return [];
|
|
@@ -247,18 +293,43 @@ function emitAll(discovered: Array<{ source: string; runnable: string; spec: Scr
|
|
|
247
293
|
return Object.fromEntries(tools.map((t) => [t['name'], t]));
|
|
248
294
|
}
|
|
249
295
|
|
|
250
|
-
function
|
|
251
|
-
const bySource: Record<string, string
|
|
296
|
+
function printLocalText(discovered: Array<{ source: string; runnable: string; spec: ScriptSpec }>): void {
|
|
297
|
+
const bySource: Record<string, Array<{ runnable: string; spec: ScriptSpec }>> = {};
|
|
252
298
|
for (const item of discovered) {
|
|
253
|
-
(bySource[item.source] ??= []).push(item.runnable);
|
|
299
|
+
(bySource[item.source] ??= []).push({ runnable: item.runnable, spec: item.spec });
|
|
254
300
|
}
|
|
255
|
-
|
|
256
|
-
|
|
301
|
+
|
|
302
|
+
const warnings: string[] = [];
|
|
303
|
+
const errors: string[] = [];
|
|
304
|
+
|
|
305
|
+
console.log(`Found ${discovered.length} runspec runnable(s):\n`);
|
|
306
|
+
for (const [source, items] of Object.entries(bySource)) {
|
|
257
307
|
console.log(` ${source}`);
|
|
258
|
-
for (const
|
|
308
|
+
for (const { runnable: name, spec } of items) {
|
|
309
|
+
const desc = spec.description ?? '';
|
|
310
|
+
const autonomy = spec.autonomy ?? 'confirm';
|
|
311
|
+
const truncated = desc.length > 48 ? desc.slice(0, 48) : desc;
|
|
312
|
+
console.log(` ${name.padEnd(24)} ${truncated.padEnd(50)} [${autonomy}]`);
|
|
313
|
+
|
|
314
|
+
if (!spec.description) warnings.push(`'${name}' has no description — agents won't know what it does`);
|
|
315
|
+
if (!spec.autonomy) warnings.push(`'${name}' autonomy not declared — defaulting to 'confirm'`);
|
|
316
|
+
for (const [argName, arg] of Object.entries(spec.args ?? {})) {
|
|
317
|
+
if (!arg.description && arg.required) {
|
|
318
|
+
warnings.push(`'${name}.${argName}' is required but has no description`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
console.log();
|
|
259
323
|
}
|
|
260
|
-
|
|
261
|
-
|
|
324
|
+
|
|
325
|
+
if (warnings.length || errors.length) {
|
|
326
|
+
console.log('Issues:\n');
|
|
327
|
+
for (const msg of warnings) console.log(` ℹ ${msg}`);
|
|
328
|
+
for (const msg of errors) console.log(` ✗ ${msg}`);
|
|
329
|
+
console.log();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log("Run 'runspec local --format mcp' to emit MCP tool schemas.");
|
|
262
333
|
}
|
|
263
334
|
|
|
264
335
|
// ── Init helpers ──────────────────────────────────────────────────────────────
|
|
@@ -268,55 +339,117 @@ function sanitizeName(raw: string): string {
|
|
|
268
339
|
return s || 'myscript';
|
|
269
340
|
}
|
|
270
341
|
|
|
271
|
-
function
|
|
342
|
+
function initRunspecToml(filePath: string, name: string): void {
|
|
272
343
|
if (fs.existsSync(filePath)) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const { parse } = require('smol-toml') as { parse: (s: string) => unknown };
|
|
277
|
-
data = parse(original) as Record<string, unknown>;
|
|
278
|
-
} catch (e) {
|
|
279
|
-
console.log(`✗ Could not read ${path.basename(filePath)}: ${(e as Error).message}`);
|
|
280
|
-
process.exit(1);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if ('runspec' in ((data as any)?.tool ?? {})) {
|
|
284
|
-
const existing = Object.keys((data as any).tool.runspec).filter(
|
|
285
|
-
(k) => k !== 'config' && typeof (data as any).tool.runspec[k] === 'object',
|
|
286
|
-
);
|
|
287
|
-
console.log(`✗ ${path.basename(filePath)} already has [tool.runspec] — already initialized`);
|
|
288
|
-
if (existing.length) console.log(` Existing runnables: ${existing.join(', ')}`);
|
|
289
|
-
process.exit(1);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const content = original.trimEnd() + '\n\n' + pyprojectBlock(name);
|
|
293
|
-
writeAndVerify(filePath, content, original);
|
|
294
|
-
console.log(` ✓ Updated ${path.basename(filePath)} with [${name}] runnable`);
|
|
295
|
-
} else {
|
|
296
|
-
const content = pyprojectBlock(name);
|
|
297
|
-
writeAndVerify(filePath, content, null);
|
|
298
|
-
console.log(` ✓ Created ${path.basename(filePath)} with [${name}] runnable`);
|
|
344
|
+
console.log(`✗ ${path.basename(filePath)} already exists — already initialized`);
|
|
345
|
+
console.log(` Edit ${path.basename(filePath)} directly to add more runnables.`);
|
|
346
|
+
process.exit(1);
|
|
299
347
|
}
|
|
300
|
-
|
|
348
|
+
const content = `#:schema https://raw.githubusercontent.com/JasonFinestone/runspec/main/schema/runspec.schema.json\n\n[${name}]\ndescription = "Describe what ${name} does"\nautonomy = "confirm"\n\n[${name}.args]\n# example = {type = "str", description = "An example argument"}\n`;
|
|
349
|
+
writeAndVerify(filePath, content, null);
|
|
350
|
+
console.log(` ✓ Created runspec.toml with [${name}] runnable`);
|
|
301
351
|
}
|
|
302
352
|
|
|
303
|
-
function
|
|
353
|
+
function initExampleToml(filePath: string): void {
|
|
304
354
|
if (fs.existsSync(filePath)) {
|
|
305
355
|
console.log(`✗ ${path.basename(filePath)} already exists — already initialized`);
|
|
306
356
|
console.log(` Edit ${path.basename(filePath)} directly to add more runnables.`);
|
|
307
357
|
process.exit(1);
|
|
308
358
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
359
|
+
const content = [
|
|
360
|
+
'#:schema https://raw.githubusercontent.com/JasonFinestone/runspec/main/schema/runspec.schema.json',
|
|
361
|
+
'',
|
|
362
|
+
'[clean]',
|
|
363
|
+
'description = "Find and optionally delete stale temporary files in a directory"',
|
|
364
|
+
'autonomy = "confirm"',
|
|
365
|
+
'',
|
|
366
|
+
'[clean.args]',
|
|
367
|
+
'directory = {type = "path", description = "Directory to scan", default = "."}',
|
|
368
|
+
'pattern = {type = "str", description = "Glob pattern to match", default = "*.tmp"}',
|
|
369
|
+
'older_than = {type = "int", description = "Only match files older than N days", default = 7}',
|
|
370
|
+
'format = {type = "choice", description = "Output format", options = ["text", "json"], default = "text"}',
|
|
371
|
+
'delete = {type = "flag", description = "Delete matched files (asks for confirmation)", default = false}',
|
|
372
|
+
'',
|
|
373
|
+
'[scan]',
|
|
374
|
+
'description = "Scan for stale temporary files and report what clean would delete"',
|
|
375
|
+
'autonomy = "autonomous"',
|
|
376
|
+
'output = "json"',
|
|
377
|
+
'',
|
|
378
|
+
'[scan.args]',
|
|
379
|
+
'directory = {type = "path", description = "Directory to scan", default = "."}',
|
|
380
|
+
'pattern = {type = "str", description = "Glob pattern to match", default = "*.tmp"}',
|
|
381
|
+
'older_than = {type = "int", description = "Only match files older than N days", default = 7}',
|
|
382
|
+
'',
|
|
383
|
+
].join('\n');
|
|
384
|
+
writeAndVerify(filePath, content, null);
|
|
385
|
+
console.log(' ✓ Created runspec.toml with [clean] and [scan] runnables');
|
|
312
386
|
}
|
|
313
387
|
|
|
314
|
-
function
|
|
315
|
-
|
|
388
|
+
function initExampleStubs(dir: string, lang: string): void {
|
|
389
|
+
if (lang === 'typescript') {
|
|
390
|
+
writeStubIfMissing(path.join(dir, 'clean.ts'), CLEAN_TS_STUB);
|
|
391
|
+
writeStubIfMissing(path.join(dir, 'scan.ts'), SCAN_TS_STUB);
|
|
392
|
+
} else if (lang === 'javascript') {
|
|
393
|
+
writeStubIfMissing(path.join(dir, 'clean.js'), CLEAN_JS_STUB);
|
|
394
|
+
writeStubIfMissing(path.join(dir, 'scan.js'), SCAN_JS_STUB);
|
|
395
|
+
} else if (lang === 'python') {
|
|
396
|
+
writeStubIfMissing(path.join(dir, 'clean.py'), CLEAN_PY_STUB);
|
|
397
|
+
writeStubIfMissing(path.join(dir, 'scan.py'), SCAN_PY_STUB);
|
|
398
|
+
} else {
|
|
399
|
+
console.log(`✗ Unknown --lang: ${lang}`);
|
|
400
|
+
console.log(' Supported: typescript (default), javascript, python');
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function writeStubIfMissing(filePath: string, content: string): void {
|
|
406
|
+
if (fs.existsSync(filePath)) {
|
|
407
|
+
console.log(` ℹ ${path.basename(filePath)} already exists — skipped`);
|
|
408
|
+
} else {
|
|
409
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
410
|
+
console.log(` ✓ Created ${path.basename(filePath)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function initCodeStub(dir: string, name: string, lang: string): void {
|
|
415
|
+
const templates: Record<string, { ext: string; content: string }> = {
|
|
416
|
+
typescript: { ext: '.ts', content: `import { parse } from 'runspec-node';\n\nfunction main(): void {\n const args = parse();\n // your logic here\n}\n\nmain();\n` },
|
|
417
|
+
javascript: { ext: '.js', content: `const { parse } = require('runspec-node');\n\nfunction main() {\n const args = parse();\n // your logic here\n}\n\nmain();\n` },
|
|
418
|
+
python: { ext: '.py', content: `from runspec import parse\n\n\ndef main():\n args = parse()\n # your logic here\n\n\nif __name__ == "__main__":\n main()\n` },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const template = templates[lang];
|
|
422
|
+
if (!template) {
|
|
423
|
+
console.log(`✗ Unknown --lang: ${lang}`);
|
|
424
|
+
console.log(' Supported: typescript, javascript, python');
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const filePath = path.join(dir, name + template.ext);
|
|
429
|
+
writeStubIfMissing(filePath, template.content);
|
|
316
430
|
}
|
|
317
431
|
|
|
318
|
-
function
|
|
319
|
-
|
|
432
|
+
function printNextSteps(cwd: string, example: boolean): void {
|
|
433
|
+
const pkgJson = path.join(cwd, 'package.json');
|
|
434
|
+
const hasPkg = fs.existsSync(pkgJson);
|
|
435
|
+
|
|
436
|
+
console.log('\nNext steps:\n');
|
|
437
|
+
if (!hasPkg) {
|
|
438
|
+
console.log(' 1. npm init -y');
|
|
439
|
+
console.log(' 2. npm install runspec-node');
|
|
440
|
+
} else {
|
|
441
|
+
console.log(' 1. npm install runspec-node');
|
|
442
|
+
}
|
|
443
|
+
console.log(` ${hasPkg ? '2' : '3'}. runspec local`);
|
|
444
|
+
|
|
445
|
+
if (example) {
|
|
446
|
+
console.log('\nDemo prep — pre-date some files to trigger the example:');
|
|
447
|
+
console.log(' touch -t 202401010000 report.tmp cache.tmp session.tmp');
|
|
448
|
+
console.log('\nThen try:');
|
|
449
|
+
console.log(' npx ts-node scan.ts # agent-ready JSON output');
|
|
450
|
+
console.log(' npx ts-node clean.ts --delete # prompts for confirmation');
|
|
451
|
+
console.log(' npx ts-node clean.ts --format json # structured output');
|
|
452
|
+
}
|
|
320
453
|
}
|
|
321
454
|
|
|
322
455
|
function writeAndVerify(filePath: string, content: string, original: string | null): void {
|
|
@@ -333,6 +466,210 @@ function writeAndVerify(filePath: string, content: string, original: string | nu
|
|
|
333
466
|
}
|
|
334
467
|
}
|
|
335
468
|
|
|
469
|
+
// ── Example stubs ─────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
const CLEAN_TS_STUB = `import * as fs from 'fs';
|
|
472
|
+
import * as path from 'path';
|
|
473
|
+
import * as readline from 'readline';
|
|
474
|
+
import { parse } from 'runspec-node';
|
|
475
|
+
|
|
476
|
+
async function main(): Promise<void> {
|
|
477
|
+
const args = parse();
|
|
478
|
+
|
|
479
|
+
const cutoff = Date.now() - Number(args.older_than) * 86400 * 1000;
|
|
480
|
+
const dir = String(args.directory);
|
|
481
|
+
const pattern = String(args.pattern).replace('*', '');
|
|
482
|
+
const matches = fs.readdirSync(dir)
|
|
483
|
+
.map((f) => path.join(dir, f))
|
|
484
|
+
.filter((f) => f.endsWith(pattern) && fs.statSync(f).isFile() && fs.statSync(f).mtimeMs < cutoff);
|
|
485
|
+
|
|
486
|
+
if (!matches.length) {
|
|
487
|
+
console.log(\`No '\${args.pattern}' files older than \${args.older_than} days found in \${args.directory}.\`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (String(args.format) === 'json') {
|
|
492
|
+
const data = matches.map((f) => ({ path: f, size: fs.statSync(f).size, days_old: Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000) }));
|
|
493
|
+
console.log(JSON.stringify(data, null, 2));
|
|
494
|
+
} else {
|
|
495
|
+
console.log(\`Found \${matches.length} file(s) matching '\${args.pattern}' older than \${args.older_than} days:\`);
|
|
496
|
+
console.log();
|
|
497
|
+
for (const f of matches) {
|
|
498
|
+
const days = Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000);
|
|
499
|
+
console.log(\` \${f} (\${fs.statSync(f).size.toLocaleString()} bytes, \${days}d old)\`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (args.delete) {
|
|
504
|
+
if (!args.__runspec_agent__) {
|
|
505
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
506
|
+
const answer = await new Promise<string>((resolve) => rl.question(\`\\nDelete \${matches.length} file(s)? [y/N] \`, resolve));
|
|
507
|
+
rl.close();
|
|
508
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
509
|
+
console.log('Aborted.');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const f of matches) fs.unlinkSync(f);
|
|
514
|
+
console.log(\`\\nDeleted \${matches.length} file(s).\`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
main();
|
|
519
|
+
`;
|
|
520
|
+
|
|
521
|
+
const SCAN_TS_STUB = `import * as fs from 'fs';
|
|
522
|
+
import * as path from 'path';
|
|
523
|
+
import { parse } from 'runspec-node';
|
|
524
|
+
|
|
525
|
+
function main(): void {
|
|
526
|
+
const args = parse();
|
|
527
|
+
|
|
528
|
+
const cutoff = Date.now() - Number(args.older_than) * 86400 * 1000;
|
|
529
|
+
const dir = String(args.directory);
|
|
530
|
+
const pattern = String(args.pattern).replace('*', '');
|
|
531
|
+
const matches = fs.readdirSync(dir)
|
|
532
|
+
.map((f) => path.join(dir, f))
|
|
533
|
+
.filter((f) => f.endsWith(pattern) && fs.statSync(f).isFile() && fs.statSync(f).mtimeMs < cutoff);
|
|
534
|
+
|
|
535
|
+
const data = matches.map((f) => ({ path: f, size: fs.statSync(f).size, days_old: Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000) }));
|
|
536
|
+
console.log(JSON.stringify(data, null, 2));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
main();
|
|
540
|
+
`;
|
|
541
|
+
|
|
542
|
+
const CLEAN_JS_STUB = `const fs = require('fs');
|
|
543
|
+
const path = require('path');
|
|
544
|
+
const readline = require('readline');
|
|
545
|
+
const { parse } = require('runspec-node');
|
|
546
|
+
|
|
547
|
+
async function main() {
|
|
548
|
+
const args = parse();
|
|
549
|
+
|
|
550
|
+
const cutoff = Date.now() - Number(args.older_than) * 86400 * 1000;
|
|
551
|
+
const dir = String(args.directory);
|
|
552
|
+
const pattern = String(args.pattern).replace('*', '');
|
|
553
|
+
const matches = fs.readdirSync(dir)
|
|
554
|
+
.map((f) => path.join(dir, f))
|
|
555
|
+
.filter((f) => f.endsWith(pattern) && fs.statSync(f).isFile() && fs.statSync(f).mtimeMs < cutoff);
|
|
556
|
+
|
|
557
|
+
if (!matches.length) {
|
|
558
|
+
console.log(\`No '\${args.pattern}' files older than \${args.older_than} days found in \${args.directory}.\`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (String(args.format) === 'json') {
|
|
563
|
+
const data = matches.map((f) => ({ path: f, size: fs.statSync(f).size, days_old: Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000) }));
|
|
564
|
+
console.log(JSON.stringify(data, null, 2));
|
|
565
|
+
} else {
|
|
566
|
+
console.log(\`Found \${matches.length} file(s) matching '\${args.pattern}' older than \${args.older_than} days:\`);
|
|
567
|
+
for (const f of matches) {
|
|
568
|
+
const days = Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000);
|
|
569
|
+
console.log(\` \${f} (\${fs.statSync(f).size.toLocaleString()} bytes, \${days}d old)\`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (args.delete) {
|
|
574
|
+
if (!args.__runspec_agent__) {
|
|
575
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
576
|
+
const answer = await new Promise((resolve) => rl.question(\`\\nDelete \${matches.length} file(s)? [y/N] \`, resolve));
|
|
577
|
+
rl.close();
|
|
578
|
+
if (answer.trim().toLowerCase() !== 'y') { console.log('Aborted.'); return; }
|
|
579
|
+
}
|
|
580
|
+
for (const f of matches) fs.unlinkSync(f);
|
|
581
|
+
console.log(\`\\nDeleted \${matches.length} file(s).\`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
main();
|
|
586
|
+
`;
|
|
587
|
+
|
|
588
|
+
const SCAN_JS_STUB = `const fs = require('fs');
|
|
589
|
+
const path = require('path');
|
|
590
|
+
const { parse } = require('runspec-node');
|
|
591
|
+
|
|
592
|
+
function main() {
|
|
593
|
+
const args = parse();
|
|
594
|
+
|
|
595
|
+
const cutoff = Date.now() - Number(args.older_than) * 86400 * 1000;
|
|
596
|
+
const dir = String(args.directory);
|
|
597
|
+
const pattern = String(args.pattern).replace('*', '');
|
|
598
|
+
const matches = fs.readdirSync(dir)
|
|
599
|
+
.map((f) => path.join(dir, f))
|
|
600
|
+
.filter((f) => f.endsWith(pattern) && fs.statSync(f).isFile() && fs.statSync(f).mtimeMs < cutoff);
|
|
601
|
+
|
|
602
|
+
const data = matches.map((f) => ({ path: f, size: fs.statSync(f).size, days_old: Math.floor((Date.now() - fs.statSync(f).mtimeMs) / 86400000) }));
|
|
603
|
+
console.log(JSON.stringify(data, null, 2));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
main();
|
|
607
|
+
`;
|
|
608
|
+
|
|
609
|
+
const CLEAN_PY_STUB = `import json
|
|
610
|
+
import sys
|
|
611
|
+
import time
|
|
612
|
+
|
|
613
|
+
from runspec import parse
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def main():
|
|
617
|
+
args = parse()
|
|
618
|
+
|
|
619
|
+
cutoff = time.time() - args.older_than * 86400
|
|
620
|
+
matches = [p for p in args.directory.glob(args.pattern) if p.is_file() and p.stat().st_mtime < cutoff]
|
|
621
|
+
|
|
622
|
+
if not matches:
|
|
623
|
+
print(f"No '{args.pattern}' files older than {args.older_than} days found in {args.directory}.")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
if args.format == "json":
|
|
627
|
+
data = [{"path": str(p), "size": p.stat().st_size, "days_old": int((time.time() - p.stat().st_mtime) / 86400)} for p in matches]
|
|
628
|
+
print(json.dumps(data, indent=2))
|
|
629
|
+
else:
|
|
630
|
+
print(f"Found {len(matches)} file(s) matching '{args.pattern}' older than {args.older_than} days:")
|
|
631
|
+
print()
|
|
632
|
+
for p in matches:
|
|
633
|
+
days = int((time.time() - p.stat().st_mtime) / 86400)
|
|
634
|
+
print(f" {p} ({p.stat().st_size:,} bytes, {days}d old)")
|
|
635
|
+
|
|
636
|
+
if args.delete:
|
|
637
|
+
if not args.__runspec_agent__:
|
|
638
|
+
print()
|
|
639
|
+
confirm = input(f"Delete {len(matches)} file(s)? [y/N] ")
|
|
640
|
+
if confirm.strip().lower() != "y":
|
|
641
|
+
print("Aborted.")
|
|
642
|
+
return
|
|
643
|
+
for p in matches:
|
|
644
|
+
p.unlink()
|
|
645
|
+
print()
|
|
646
|
+
print(f"Deleted {len(matches)} file(s).")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
if __name__ == "__main__":
|
|
650
|
+
main()
|
|
651
|
+
`;
|
|
652
|
+
|
|
653
|
+
const SCAN_PY_STUB = `import json
|
|
654
|
+
import time
|
|
655
|
+
|
|
656
|
+
from runspec import parse
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def main():
|
|
660
|
+
args = parse()
|
|
661
|
+
|
|
662
|
+
cutoff = time.time() - args.older_than * 86400
|
|
663
|
+
matches = [p for p in args.directory.glob(args.pattern) if p.is_file() and p.stat().st_mtime < cutoff]
|
|
664
|
+
|
|
665
|
+
data = [{"path": str(p), "size": p.stat().st_size, "days_old": int((time.time() - p.stat().st_mtime) / 86400)} for p in matches]
|
|
666
|
+
print(json.dumps(data, indent=2))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
if __name__ == "__main__":
|
|
670
|
+
main()
|
|
671
|
+
`;
|
|
672
|
+
|
|
336
673
|
// ── Arg parser helper ─────────────────────────────────────────────────────────
|
|
337
674
|
|
|
338
675
|
function getFlag(args: string[], flag: string): string | undefined {
|
|
@@ -350,29 +687,66 @@ Usage:
|
|
|
350
687
|
runspec <command> [options]
|
|
351
688
|
|
|
352
689
|
Commands:
|
|
353
|
-
init Create
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
serve Start the MCP stdio server for this environment
|
|
690
|
+
init Create runspec.toml and a code stub
|
|
691
|
+
local List runnables and emit tool schemas
|
|
692
|
+
jump Execute a runnable on a remote host via SSH
|
|
693
|
+
serve Start the MCP stdio server for local runnables
|
|
358
694
|
|
|
359
|
-
|
|
360
|
-
--name Runnable name (default: current directory name)
|
|
361
|
-
--file Target file: pyproject or runspec (auto-detected if omitted)
|
|
695
|
+
Run 'runspec <command> --help' for focused help on each command.
|
|
362
696
|
|
|
363
|
-
|
|
364
|
-
|
|
697
|
+
Examples:
|
|
698
|
+
runspec init
|
|
699
|
+
runspec init --example
|
|
700
|
+
runspec local
|
|
701
|
+
runspec local --format mcp
|
|
702
|
+
runspec serve`);
|
|
703
|
+
}
|
|
365
704
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
705
|
+
function printCommandHelp(command: string): void {
|
|
706
|
+
const help: Record<string, string> = {
|
|
707
|
+
init: `runspec init — Create runspec.toml and a code stub
|
|
708
|
+
|
|
709
|
+
Options:
|
|
710
|
+
--name Runnable name (default: current directory name)
|
|
711
|
+
--lang Language for stub: typescript (default), javascript, python
|
|
712
|
+
--example Generate dual clean + scan example runnables
|
|
369
713
|
|
|
370
714
|
Examples:
|
|
371
715
|
runspec init
|
|
372
716
|
runspec init --name myapp
|
|
373
|
-
runspec
|
|
374
|
-
runspec
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
717
|
+
runspec init --name myapp --lang javascript
|
|
718
|
+
runspec init --example`,
|
|
719
|
+
|
|
720
|
+
local: `runspec local — List runnables and emit tool schemas
|
|
721
|
+
|
|
722
|
+
Options:
|
|
723
|
+
--format Output format: text (default), json, mcp, openai, anthropic
|
|
724
|
+
--script Target a single runnable by name (use with --format)
|
|
725
|
+
|
|
726
|
+
Examples:
|
|
727
|
+
runspec local
|
|
728
|
+
runspec local --format mcp
|
|
729
|
+
runspec local --format mcp --script deploy
|
|
730
|
+
runspec local --format json`,
|
|
731
|
+
|
|
732
|
+
jump: `runspec jump — Execute a runnable on a remote host via SSH
|
|
733
|
+
|
|
734
|
+
Not yet implemented in the Node package.
|
|
735
|
+
Use the Python package (pip install runspec) for SSH execution.`,
|
|
736
|
+
|
|
737
|
+
serve: `runspec serve — Start the MCP stdio server for local runnables
|
|
738
|
+
|
|
739
|
+
Reads JSON-RPC messages from stdin, writes responses to stdout.
|
|
740
|
+
Configure as an MCP server in Claude Desktop, VS Code, PyCharm, etc.
|
|
741
|
+
|
|
742
|
+
To test manually:
|
|
743
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' | runspec serve`,
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
if (command in help) {
|
|
747
|
+
console.log(help[command]);
|
|
748
|
+
} else {
|
|
749
|
+
console.log(`No help available for '${command}'.`);
|
|
750
|
+
printHelp();
|
|
751
|
+
}
|
|
378
752
|
}
|