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