runspec-node 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +427 -172
- package/dist/cli.js.map +1 -1
- package/dist/finder.d.ts +0 -2
- package/dist/finder.d.ts.map +1 -1
- package/dist/finder.js +2 -40
- package/dist/finder.js.map +1 -1
- package/dist/loader.d.ts +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -21
- package/dist/loader.js.map +1 -1
- package/dist/models.d.ts +0 -1
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.js +3 -3
- package/dist/parser.js.map +1 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +58 -22
- package/dist/serve.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +448 -180
- package/src/finder.ts +3 -41
- package/src/loader.ts +2 -20
- package/src/models.ts +0 -1
- package/src/parser.ts +4 -4
- package/src/serve.ts +61 -20
- package/tests/test_cli_init.test.ts +210 -0
- package/tests/test_integration.test.ts +21 -21
- package/tests/test_loader.test.ts +8 -43
package/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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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 (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
109
|
+
printLocalText(filtered);
|
|
94
110
|
}
|
|
95
111
|
else if (fmt === 'json') {
|
|
96
|
-
console.log(JSON.stringify(
|
|
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(
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
249
|
-
const raw = (0, loader_1.loadRaw)(configPath
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
246
|
+
function initRunspecToml(filePath, name) {
|
|
282
247
|
if (fs.existsSync(filePath)) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
322
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
372
|
-
--
|
|
373
|
-
--
|
|
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
|
|
379
|
-
runspec
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|