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/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
- if (!args.length || args[0] === '-h' || args[0] === '--help') {
14
- printHelp();
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
- const command = args[0];
19
- const rest = args.slice(1);
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
- discover: cmdDiscover,
24
- check: cmdCheck,
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 fileFlag = getFlag(args, '--file');
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 (fileFlag === 'runspec') {
51
- initRunspecToml(runspecToml, runnableName);
52
- } else if (fileFlag === 'pyproject' || fs.existsSync(pyproject)) {
53
- initPyproject(pyproject, runnableName);
54
- } else {
55
- initRunspecToml(runspecToml, runnableName);
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 cmdDiscover(args: string[]): void {
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('Add a [tool.runspec.yourname] section to pyproject.toml or create runspec.toml');
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
- printDiscoverText(discovered);
94
+ printLocalText(filtered);
72
95
  } else if (fmt === 'json') {
73
- console.log(JSON.stringify(discovered, null, 2));
96
+ console.log(JSON.stringify(filtered, null, 2));
74
97
  } else if (['mcp', 'openai', 'anthropic'].includes(fmt)) {
75
- console.log(JSON.stringify(emitAll(discovered, fmt), null, 2));
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 cmdCheck(args: string[]): void {
84
- let configPath: string;
85
- let format: 'pyproject' | 'runspec';
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, format } = findConfig(process.cwd());
237
- const raw = loadRaw(configPath, format);
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 printDiscoverText(discovered: Array<{ source: string; runnable: string; spec: ScriptSpec }>): void {
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
- console.log(`Found ${discovered.length} runspec-aware runnable(s):\n`);
256
- for (const [source, runnables] of Object.entries(bySource)) {
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 r of runnables) console.log(` • ${r}`);
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
- console.log();
261
- console.log("Run 'runspec discover --format mcp' to emit MCP tool schemas.");
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 initPyproject(filePath: string, name: string): void {
238
+ function initRunspecToml(filePath: string, name: string): void {
272
239
  if (fs.existsSync(filePath)) {
273
- const original = fs.readFileSync(filePath, 'utf-8');
274
- let data: Record<string, unknown>;
275
- try {
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
- console.log(" Run 'runspec check' to validate.");
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 initRunspecToml(filePath: string, name: string): void {
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
- writeAndVerify(filePath, runspecTomlBlock(name), null);
310
- console.log(` ✓ Created ${path.basename(filePath)} with [${name}] runnable`);
311
- console.log(" Run 'runspec check' to validate.");
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 pyprojectBlock(name: string): string {
315
- return `[tool.runspec.${name}]\ndescription = "Describe what ${name} does"\nautonomy = "confirm"\n\n[tool.runspec.${name}.args]\n# example = {type = "str", description = "An example argument"}\n`;
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 runspecTomlBlock(name: string): string {
319
- return `[${name}]\ndescription = "Describe what ${name} does"\nautonomy = "confirm"\n\n[${name}.args]\n# example = {type = "str", description = "An example argument"}\n`;
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 or update pyproject.toml or runspec.toml with a scaffold
354
- discover Find all runspec-aware runnables in this environment
355
- check Validate this project's runspec setup
356
- emit Emit tool schemas for agent frameworks
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
- Options for init:
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
- Options for discover:
364
- --format Output format: text (default), json, mcp, openai, anthropic
591
+ Examples:
592
+ runspec init
593
+ runspec init --example
594
+ runspec local
595
+ runspec local --format mcp
596
+ runspec serve`);
597
+ }
365
598
 
366
- Options for emit:
367
- --script Runnable name to emit (all runnables if omitted)
368
- --format Output format: mcp (default), openai, anthropic
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 discover
374
- runspec discover --format mcp
375
- runspec check
376
- runspec emit --script deploy --format mcp
377
- runspec emit --format openai`);
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
  }