runspec-node 0.3.0 → 0.8.0

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