runspec-node 0.3.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.
Files changed (64) hide show
  1. package/bin/runspec.js +4 -0
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +384 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/errors.d.ts +25 -0
  7. package/dist/errors.d.ts.map +1 -0
  8. package/dist/errors.js +146 -0
  9. package/dist/errors.js.map +1 -0
  10. package/dist/finder.d.ts +6 -0
  11. package/dist/finder.d.ts.map +1 -0
  12. package/dist/finder.js +91 -0
  13. package/dist/finder.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +22 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/inference.d.ts +10 -0
  19. package/dist/inference.d.ts.map +1 -0
  20. package/dist/inference.js +69 -0
  21. package/dist/inference.js.map +1 -0
  22. package/dist/loader.d.ts +3 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +142 -0
  25. package/dist/loader.js.map +1 -0
  26. package/dist/models.d.ts +57 -0
  27. package/dist/models.d.ts.map +1 -0
  28. package/dist/models.js +3 -0
  29. package/dist/models.js.map +1 -0
  30. package/dist/parser.d.ts +10 -0
  31. package/dist/parser.d.ts.map +1 -0
  32. package/dist/parser.js +251 -0
  33. package/dist/parser.js.map +1 -0
  34. package/dist/serve.d.ts +2 -0
  35. package/dist/serve.d.ts.map +1 -0
  36. package/dist/serve.js +199 -0
  37. package/dist/serve.js.map +1 -0
  38. package/dist/types.d.ts +6 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +121 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/validator.d.ts +5 -0
  43. package/dist/validator.d.ts.map +1 -0
  44. package/dist/validator.js +56 -0
  45. package/dist/validator.js.map +1 -0
  46. package/jest.config.js +8 -0
  47. package/package.json +36 -0
  48. package/src/cli.ts +378 -0
  49. package/src/errors.ts +126 -0
  50. package/src/finder.ts +59 -0
  51. package/src/index.ts +6 -0
  52. package/src/inference.ts +77 -0
  53. package/src/loader.ts +120 -0
  54. package/src/models.ts +61 -0
  55. package/src/parser.ts +239 -0
  56. package/src/serve.ts +196 -0
  57. package/src/types.ts +96 -0
  58. package/src/validator.ts +64 -0
  59. package/tests/test_inference.test.ts +153 -0
  60. package/tests/test_integration.test.ts +197 -0
  61. package/tests/test_loader.test.ts +169 -0
  62. package/tests/test_types.test.ts +110 -0
  63. package/tests/test_validator.test.ts +120 -0
  64. package/tsconfig.json +18 -0
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateArgs = validateArgs;
4
+ exports.validateGroups = validateGroups;
5
+ exports.raiseIfErrors = raiseIfErrors;
6
+ const errors_1 = require("./errors");
7
+ function validateArgs(parsedValues, argSpecs) {
8
+ const errors = [];
9
+ for (const [name, spec] of Object.entries(argSpecs)) {
10
+ const value = parsedValues[name];
11
+ const missing = value === null || value === undefined;
12
+ if (spec.required && missing) {
13
+ errors.push((0, errors_1.formatMissingRequired)(name, spec));
14
+ continue;
15
+ }
16
+ if (!missing && spec.deprecated) {
17
+ process.stderr.write((0, errors_1.formatDeprecated)(name, spec.deprecated) + '\n');
18
+ }
19
+ }
20
+ return errors;
21
+ }
22
+ function validateGroups(parsedValues, groupSpecs) {
23
+ const errors = [];
24
+ for (const [groupName, group] of Object.entries(groupSpecs)) {
25
+ const groupArgs = group.args ?? [];
26
+ const provided = groupArgs.filter((a) => parsedValues[a] !== null && parsedValues[a] !== undefined);
27
+ if (group.exclusive && provided.length > 1) {
28
+ errors.push((0, errors_1.formatGroupExclusive)(groupName, provided));
29
+ }
30
+ else if (group.inclusive && provided.length > 0 && provided.length < groupArgs.length) {
31
+ errors.push((0, errors_1.formatGroupInclusive)(groupName, groupArgs.filter((a) => !provided.includes(a))));
32
+ }
33
+ else if (group.atLeastOne && provided.length === 0) {
34
+ errors.push((0, errors_1.formatGroupAtLeastOne)(groupName, groupArgs));
35
+ }
36
+ else if (group.exactlyOne && provided.length !== 1) {
37
+ errors.push((0, errors_1.formatGroupExactlyOne)(groupName, groupArgs, provided));
38
+ }
39
+ else if (group.condition) {
40
+ const condVal = parsedValues[group.condition];
41
+ if (condVal !== null && condVal !== undefined) {
42
+ const requires = group.requires ?? [];
43
+ const missing = requires.filter((a) => parsedValues[a] === null || parsedValues[a] === undefined);
44
+ if (missing.length > 0)
45
+ errors.push((0, errors_1.formatGroupInclusive)(groupName, missing));
46
+ }
47
+ }
48
+ }
49
+ return errors;
50
+ }
51
+ function raiseIfErrors(errorMessages) {
52
+ if (errorMessages.length > 0) {
53
+ throw new errors_1.RunSpecError(errorMessages.join('\n\n'));
54
+ }
55
+ }
56
+ //# sourceMappingURL=validator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validator.js","sourceRoot":"","sources":["../src/validator.ts"],"names":[],"mappings":"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"}
package/jest.config.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ roots: ['<rootDir>/tests'],
6
+ testMatch: ['**/*.test.ts'],
7
+ collectCoverageFrom: ['src/**/*.ts'],
8
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "runspec-node",
3
+ "version": "0.3.0",
4
+ "description": "Node/TypeScript language pack for runspec",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "runspec": "./bin/runspec.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "jest",
13
+ "typecheck": "tsc --noEmit",
14
+ "clean": "rm -rf dist"
15
+ },
16
+ "keywords": ["runspec", "cli", "arguments", "agents", "mcp"],
17
+ "author": "Jason Finestone",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/JasonFinestone/runspec"
22
+ },
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/jest": "^29.0.0",
28
+ "@types/node": "^20.0.0",
29
+ "jest": "^29.0.0",
30
+ "ts-jest": "^29.0.0",
31
+ "typescript": "^5.0.0"
32
+ },
33
+ "dependencies": {
34
+ "smol-toml": "^1.0.0"
35
+ }
36
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,378 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { findConfig } from './finder';
4
+ import { loadRaw } from './loader';
5
+ import { inferScript } from './inference';
6
+ import type { ScriptSpec, ArgSpec } from './models';
7
+
8
+ // ── Entry point ───────────────────────────────────────────────────────────────
9
+
10
+ export function main(): void {
11
+ const args = process.argv.slice(2);
12
+
13
+ if (!args.length || args[0] === '-h' || args[0] === '--help') {
14
+ printHelp();
15
+ return;
16
+ }
17
+
18
+ const command = args[0];
19
+ const rest = args.slice(1);
20
+
21
+ const commands: Record<string, (args: string[]) => void> = {
22
+ init: cmdInit,
23
+ discover: cmdDiscover,
24
+ check: cmdCheck,
25
+ emit: cmdEmit,
26
+ serve: cmdServe,
27
+ };
28
+
29
+ if (!(command in commands)) {
30
+ console.log(`✗ Unknown command: ${command}`);
31
+ console.log(` Available commands: ${Object.keys(commands).join(', ')}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ commands[command](rest);
36
+ }
37
+
38
+ // ── Commands ──────────────────────────────────────────────────────────────────
39
+
40
+ function cmdInit(args: string[]): void {
41
+ const nameFlag = getFlag(args, '--name');
42
+ const fileFlag = getFlag(args, '--file');
43
+
44
+ const cwd = process.cwd();
45
+ const runnableName = nameFlag ?? sanitizeName(path.basename(cwd));
46
+
47
+ const pyproject = path.join(cwd, 'pyproject.toml');
48
+ const runspecToml = path.join(cwd, 'runspec.toml');
49
+
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);
56
+ }
57
+ }
58
+
59
+ function cmdDiscover(args: string[]): void {
60
+ const fmt = getFlag(args, '--format') ?? 'text';
61
+
62
+ const discovered = discoverLocal();
63
+
64
+ if (!discovered.length) {
65
+ 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');
67
+ return;
68
+ }
69
+
70
+ if (fmt === 'text') {
71
+ printDiscoverText(discovered);
72
+ } else if (fmt === 'json') {
73
+ console.log(JSON.stringify(discovered, null, 2));
74
+ } else if (['mcp', 'openai', 'anthropic'].includes(fmt)) {
75
+ console.log(JSON.stringify(emitAll(discovered, fmt), null, 2));
76
+ } else {
77
+ console.log(`✗ Unknown format: ${fmt}`);
78
+ console.log(' Available formats: text, json, mcp, openai, anthropic');
79
+ process.exit(1);
80
+ }
81
+ }
82
+
83
+ function cmdCheck(args: string[]): void {
84
+ let configPath: string;
85
+ let format: 'pyproject' | 'runspec';
86
+
87
+ try {
88
+ ({ configPath, format } = findConfig(process.cwd()));
89
+ } catch (e) {
90
+ console.log((e as Error).message);
91
+ process.exit(1);
92
+ }
93
+
94
+ const raw = loadRaw(configPath, format);
95
+ const errors: string[] = [];
96
+ const warnings: string[] = [];
97
+ const ok: string[] = [];
98
+
99
+ ok.push(`Config found: ${configPath}`);
100
+
101
+ if (format === 'pyproject') {
102
+ const eps = raw.entryPoints;
103
+ if (Object.keys(eps).length > 0) {
104
+ ok.push(`[project.scripts] found — ${Object.keys(eps).length} entry point(s)`);
105
+ } else {
106
+ warnings.push('No [project.scripts] found — agents may not discover runnables automatically\n Add entry points to pyproject.toml or use runspec.toml');
107
+ }
108
+ }
109
+
110
+ if ('config' in raw.runnables) {
111
+ errors.push("'config' is a reserved name — rename your runnable to something else");
112
+ }
113
+
114
+ for (const [name, runnable] of Object.entries(raw.runnables)) {
115
+ if (!runnable.description) {
116
+ warnings.push(`'${name}' has no description — agents won't know what it does`);
117
+ } else {
118
+ ok.push(`'${name}' — description present`);
119
+ }
120
+
121
+ if (!runnable.autonomy) {
122
+ warnings.push(`'${name}' autonomy not declared — will default to '${raw.config.autonomyDefault}'`);
123
+ } else {
124
+ ok.push(`'${name}' — autonomy: ${runnable.autonomy}`);
125
+ }
126
+
127
+ for (const [argName, arg] of Object.entries(runnable.args ?? {})) {
128
+ if (!arg.description && arg.required) {
129
+ warnings.push(`'${name}.${argName}' is required but has no description`);
130
+ }
131
+ }
132
+ }
133
+
134
+ for (const msg of ok) console.log(` ✓ ${msg}`);
135
+ for (const msg of warnings) console.log(` ℹ ${msg}`);
136
+ for (const msg of errors) console.log(` ✗ ${msg}`);
137
+
138
+ if (errors.length) process.exit(1);
139
+ else if (!warnings.length) console.log('\n All checks passed.');
140
+ }
141
+
142
+ function cmdEmit(args: string[]): void {
143
+ const scriptName = getFlag(args, '--script');
144
+ const fmt = getFlag(args, '--format') ?? 'mcp';
145
+
146
+ let configPath: string;
147
+ let format: 'pyproject' | 'runspec';
148
+
149
+ try {
150
+ ({ configPath, format } = findConfig(process.cwd()));
151
+ } catch (e) {
152
+ console.log((e as Error).message);
153
+ process.exit(1);
154
+ }
155
+
156
+ const raw = loadRaw(configPath, format);
157
+ const config = raw.config;
158
+
159
+ let runnables = raw.runnables;
160
+ if (scriptName) {
161
+ if (!(scriptName in runnables)) {
162
+ console.log(`✗ Runnable '${scriptName}' not found`);
163
+ process.exit(1);
164
+ }
165
+ runnables = { [scriptName]: runnables[scriptName] };
166
+ }
167
+
168
+ const schema: Record<string, unknown> = {};
169
+ for (const [name, runnable] of Object.entries(runnables)) {
170
+ const inferred = inferScript(runnable, config.autonomyDefault);
171
+ schema[name] = buildSchema(name, inferred, fmt);
172
+ }
173
+
174
+ const output = fmt === 'mcp' ? { tools: Object.values(schema) } : schema;
175
+ console.log(JSON.stringify(output, null, 2));
176
+ }
177
+
178
+ function cmdServe(_args: string[]): void {
179
+ const { serve } = require('./serve') as { serve: () => void };
180
+ serve();
181
+ }
182
+
183
+ // ── Schema builder ────────────────────────────────────────────────────────────
184
+
185
+ export function buildSchema(name: string, script: ScriptSpec, fmt: string): Record<string, unknown> {
186
+ const properties: Record<string, unknown> = {};
187
+ const requiredArgs: string[] = [];
188
+
189
+ for (const [argName, arg] of Object.entries(script.args ?? {})) {
190
+ properties[argName] = argToJsonSchema(arg);
191
+ if (arg.required) requiredArgs.push(argName);
192
+ }
193
+
194
+ const schema: Record<string, unknown> = {
195
+ name,
196
+ description: script.description ?? '',
197
+ 'x-autonomy': script.autonomy ?? 'confirm',
198
+ 'x-output': script.output ?? 'text',
199
+ inputSchema: { type: 'object', properties },
200
+ };
201
+
202
+ if (requiredArgs.length) (schema['inputSchema'] as Record<string, unknown>)['required'] = requiredArgs;
203
+ if (script.autonomyReason) schema['x-autonomy-reason'] = script.autonomyReason;
204
+
205
+ return schema;
206
+ }
207
+
208
+ function argToJsonSchema(arg: ArgSpec): Record<string, unknown> {
209
+ const typeMap: Record<string, string> = {
210
+ str: 'string',
211
+ int: 'integer',
212
+ float: 'number',
213
+ bool: 'boolean',
214
+ flag: 'boolean',
215
+ path: 'string',
216
+ choice: 'string',
217
+ };
218
+
219
+ let prop: Record<string, unknown> = { type: typeMap[arg.type ?? 'str'] ?? 'string' };
220
+ if (arg.description) prop['description'] = arg.description;
221
+ if (arg.default !== undefined && arg.default !== null) prop['default'] = arg.default;
222
+ if (arg.options) prop['enum'] = arg.options;
223
+ if (arg.range) {
224
+ prop['minimum'] = arg.range[0];
225
+ prop['maximum'] = arg.range[1];
226
+ }
227
+ if (arg.multiple) prop = { type: 'array', items: prop };
228
+
229
+ return prop;
230
+ }
231
+
232
+ // ── Discovery ─────────────────────────────────────────────────────────────────
233
+
234
+ function discoverLocal(): Array<{ source: string; runnable: string; spec: ScriptSpec }> {
235
+ try {
236
+ const { configPath, format } = findConfig(process.cwd());
237
+ const raw = loadRaw(configPath, format);
238
+ return Object.entries(raw.runnables).map(([name, spec]) => ({ source: configPath, runnable: name, spec }));
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ function emitAll(discovered: Array<{ source: string; runnable: string; spec: ScriptSpec }>, fmt: string): Record<string, unknown> {
245
+ const tools = discovered.map((item) => buildSchema(item.runnable, item.spec, fmt));
246
+ if (fmt === 'mcp') return { tools };
247
+ return Object.fromEntries(tools.map((t) => [t['name'], t]));
248
+ }
249
+
250
+ function printDiscoverText(discovered: Array<{ source: string; runnable: string; spec: ScriptSpec }>): void {
251
+ const bySource: Record<string, string[]> = {};
252
+ for (const item of discovered) {
253
+ (bySource[item.source] ??= []).push(item.runnable);
254
+ }
255
+ console.log(`Found ${discovered.length} runspec-aware runnable(s):\n`);
256
+ for (const [source, runnables] of Object.entries(bySource)) {
257
+ console.log(` ${source}`);
258
+ for (const r of runnables) console.log(` • ${r}`);
259
+ }
260
+ console.log();
261
+ console.log("Run 'runspec discover --format mcp' to emit MCP tool schemas.");
262
+ }
263
+
264
+ // ── Init helpers ──────────────────────────────────────────────────────────────
265
+
266
+ function sanitizeName(raw: string): string {
267
+ const s = raw.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
268
+ return s || 'myscript';
269
+ }
270
+
271
+ function initPyproject(filePath: string, name: string): void {
272
+ 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`);
299
+ }
300
+ console.log(" Run 'runspec check' to validate.");
301
+ }
302
+
303
+ function initRunspecToml(filePath: string, name: string): void {
304
+ if (fs.existsSync(filePath)) {
305
+ console.log(`✗ ${path.basename(filePath)} already exists — already initialized`);
306
+ console.log(` Edit ${path.basename(filePath)} directly to add more runnables.`);
307
+ process.exit(1);
308
+ }
309
+ writeAndVerify(filePath, runspecTomlBlock(name), null);
310
+ console.log(` ✓ Created ${path.basename(filePath)} with [${name}] runnable`);
311
+ console.log(" Run 'runspec check' to validate.");
312
+ }
313
+
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`;
316
+ }
317
+
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`;
320
+ }
321
+
322
+ function writeAndVerify(filePath: string, content: string, original: string | null): void {
323
+ fs.writeFileSync(filePath, content, 'utf-8');
324
+ try {
325
+ const { parse } = require('smol-toml') as { parse: (s: string) => unknown };
326
+ parse(content);
327
+ } catch (e) {
328
+ if (original !== null) fs.writeFileSync(filePath, original, 'utf-8');
329
+ else fs.unlinkSync(filePath);
330
+ console.log('✗ Generated invalid TOML — this is a bug, please report it');
331
+ console.log(` ${(e as Error).message}`);
332
+ process.exit(1);
333
+ }
334
+ }
335
+
336
+ // ── Arg parser helper ─────────────────────────────────────────────────────────
337
+
338
+ function getFlag(args: string[], flag: string): string | undefined {
339
+ const idx = args.indexOf(flag);
340
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
341
+ return undefined;
342
+ }
343
+
344
+ // ── Help ──────────────────────────────────────────────────────────────────────
345
+
346
+ function printHelp(): void {
347
+ console.log(`runspec — interface specification for anything runnable
348
+
349
+ Usage:
350
+ runspec <command> [options]
351
+
352
+ 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
358
+
359
+ Options for init:
360
+ --name Runnable name (default: current directory name)
361
+ --file Target file: pyproject or runspec (auto-detected if omitted)
362
+
363
+ Options for discover:
364
+ --format Output format: text (default), json, mcp, openai, anthropic
365
+
366
+ Options for emit:
367
+ --script Runnable name to emit (all runnables if omitted)
368
+ --format Output format: mcp (default), openai, anthropic
369
+
370
+ Examples:
371
+ runspec init
372
+ 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`);
378
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,126 @@
1
+ export class RunSpecError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = 'RunSpecError';
5
+ }
6
+ }
7
+
8
+ export class MissingRequiredArg extends RunSpecError {}
9
+ export class InvalidChoice extends RunSpecError {}
10
+ export class OutOfRange extends RunSpecError {}
11
+ export class UnknownArg extends RunSpecError {}
12
+ export class GroupViolation extends RunSpecError {}
13
+ export class AutonomyViolation extends RunSpecError {}
14
+
15
+ export function formatMissingRequired(name: string, spec: Record<string, unknown>): string {
16
+ const lines = [
17
+ `✗ Missing required argument: --${name}`,
18
+ ` Type: ${spec['type'] ?? 'str'}`,
19
+ ];
20
+ if (spec['description']) lines.push(` Description: ${spec['description']}`);
21
+ if (spec['env']) lines.push(` Tip: set environment variable ${spec['env']} as an alternative`);
22
+ return lines.join('\n');
23
+ }
24
+
25
+ export function formatInvalidChoice(value: string, options: string[], name: string): string {
26
+ const lines = [
27
+ `✗ Invalid value for --${name}: ${JSON.stringify(value)}`,
28
+ ` Expected one of: ${options.join(', ')}`,
29
+ ` Got: ${JSON.stringify(value)}`,
30
+ ];
31
+ const s = suggest(value, options);
32
+ if (s) lines.push(`\n Did you mean: ${s}?`);
33
+ return lines.join('\n');
34
+ }
35
+
36
+ export function formatOutOfRange(value: number, range: [number, number], name: string): string {
37
+ return [
38
+ `✗ Value out of range for --${name}: ${value}`,
39
+ ` Expected: between ${range[0]} and ${range[1]}`,
40
+ ` Got: ${value}`,
41
+ ].join('\n');
42
+ }
43
+
44
+ export function formatUnknownArg(name: string, knownArgs: string[]): string {
45
+ const lines = [
46
+ `✗ Unknown argument: --${name}`,
47
+ ` Known arguments: ${[...knownArgs].sort().map((a) => `--${a}`).join(', ')}`,
48
+ ];
49
+ const s = suggest(name, knownArgs);
50
+ if (s) lines.push(`\n Did you mean: --${s}?`);
51
+ return lines.join('\n');
52
+ }
53
+
54
+ export function formatGroupExclusive(groupName: string, provided: string[]): string {
55
+ return [
56
+ `✗ Conflicting arguments in group '${groupName}'`,
57
+ ` --${provided[0]} and --${provided[1]} cannot be used together`,
58
+ ` Choose one or the other`,
59
+ ].join('\n');
60
+ }
61
+
62
+ export function formatGroupInclusive(groupName: string, missing: string[]): string {
63
+ return [
64
+ `✗ Incomplete argument group '${groupName}'`,
65
+ ` Providing one of these args requires all of them`,
66
+ ` Also provide: ${missing.map((m) => `--${m}`).join(' and ')}`,
67
+ ].join('\n');
68
+ }
69
+
70
+ export function formatGroupAtLeastOne(groupName: string, args: string[]): string {
71
+ return [
72
+ `✗ Group '${groupName}' requires at least one argument`,
73
+ ` Provide at least one of: ${args.map((a) => `--${a}`).join(', ')}`,
74
+ ].join('\n');
75
+ }
76
+
77
+ export function formatGroupExactlyOne(groupName: string, args: string[], provided: string[]): string {
78
+ if (!provided.length) {
79
+ return [
80
+ `✗ Group '${groupName}' requires exactly one argument`,
81
+ ` Provide exactly one of: ${args.map((a) => `--${a}`).join(', ')}`,
82
+ ].join('\n');
83
+ }
84
+ return [
85
+ `✗ Group '${groupName}' requires exactly one argument`,
86
+ ` Got ${provided.length}: ${provided.map((a) => `--${a}`).join(', ')}`,
87
+ ` Provide exactly one of: ${args.map((a) => `--${a}`).join(', ')}`,
88
+ ].join('\n');
89
+ }
90
+
91
+ export function formatDeprecated(name: string, message: string): string {
92
+ return `⚠ --${name} is deprecated: ${message}`;
93
+ }
94
+
95
+ function suggest(value: string, candidates: string[]): string | undefined {
96
+ let best: string | undefined;
97
+ let bestScore = 0;
98
+ for (const c of candidates) {
99
+ const score = diceSimilarity(value, c);
100
+ if (score > bestScore && score >= 0.6) {
101
+ bestScore = score;
102
+ best = c;
103
+ }
104
+ }
105
+ return best;
106
+ }
107
+
108
+ function diceSimilarity(a: string, b: string): number {
109
+ if (a === b) return 1;
110
+ if (a.length < 2 || b.length < 2) return 0;
111
+ const bigrams = new Map<string, number>();
112
+ for (let i = 0; i < a.length - 1; i++) {
113
+ const bg = a.slice(i, i + 2);
114
+ bigrams.set(bg, (bigrams.get(bg) ?? 0) + 1);
115
+ }
116
+ let intersect = 0;
117
+ for (let i = 0; i < b.length - 1; i++) {
118
+ const bg = b.slice(i, i + 2);
119
+ const count = bigrams.get(bg) ?? 0;
120
+ if (count > 0) {
121
+ bigrams.set(bg, count - 1);
122
+ intersect++;
123
+ }
124
+ }
125
+ return (2 * intersect) / (a.length + b.length - 2);
126
+ }
package/src/finder.ts ADDED
@@ -0,0 +1,59 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { parse as parseTOML } from 'smol-toml';
4
+
5
+ export function findConfig(start?: string): { configPath: string; format: 'pyproject' | 'runspec' } {
6
+ let dir = path.resolve(start ?? process.cwd());
7
+
8
+ while (true) {
9
+ const pyproject = path.join(dir, 'pyproject.toml');
10
+ if (fs.existsSync(pyproject) && hasRunspecSection(pyproject)) {
11
+ return { configPath: pyproject, format: 'pyproject' };
12
+ }
13
+
14
+ const runspecToml = path.join(dir, 'runspec.toml');
15
+ if (fs.existsSync(runspecToml)) {
16
+ return { configPath: runspecToml, format: 'runspec' };
17
+ }
18
+
19
+ const parent = path.dirname(dir);
20
+ if (parent === dir) break;
21
+ dir = parent;
22
+ }
23
+
24
+ throw new Error(
25
+ "No runspec configuration found.\nExpected one of:\n - pyproject.toml with [tool.runspec] section\n - runspec.toml\n\nRun 'runspec check' to validate your project setup.",
26
+ );
27
+ }
28
+
29
+ export function findScriptName(configPath: string, format: 'pyproject' | 'runspec'): string | undefined {
30
+ if (format !== 'pyproject') return undefined;
31
+
32
+ try {
33
+ const content = fs.readFileSync(configPath, 'utf-8');
34
+ const data = parseTOML(content) as Record<string, unknown>;
35
+ const argv1 = process.argv[1] ?? '';
36
+ const caller = path.basename(argv1, path.extname(argv1));
37
+ if (!caller) return undefined;
38
+
39
+ const projectScripts = (data as any)?.project?.scripts ?? {};
40
+ if (caller in projectScripts) return caller;
41
+
42
+ const poetryScripts = (data as any)?.tool?.poetry?.scripts ?? {};
43
+ if (caller in poetryScripts) return caller;
44
+
45
+ return caller;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ function hasRunspecSection(pyprojectPath: string): boolean {
52
+ try {
53
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
54
+ const data = parseTOML(content) as Record<string, unknown>;
55
+ return 'runspec' in ((data as any)?.tool ?? {});
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { parse, loadSpec } from './parser';
2
+ export { registerType, listTypes } from './types';
3
+ export { RunSpecError, MissingRequiredArg, InvalidChoice, OutOfRange, UnknownArg, GroupViolation, AutonomyViolation } from './errors';
4
+ export { findConfig } from './finder';
5
+ export { loadRaw } from './loader';
6
+ export type { ParsedArgs, ScriptSpec, ArgSpec, GroupSpec, RawSpec, RawConfig } from './models';