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