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