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