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/finder.ts CHANGED
@@ -1,19 +1,13 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { parse as parseTOML } from 'smol-toml';
4
3
 
5
- export function findConfig(start?: string): { configPath: string; format: 'pyproject' | 'runspec' } {
4
+ export function findConfig(start?: string): { configPath: string } {
6
5
  let dir = path.resolve(start ?? process.cwd());
7
6
 
8
7
  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
8
  const runspecToml = path.join(dir, 'runspec.toml');
15
9
  if (fs.existsSync(runspecToml)) {
16
- return { configPath: runspecToml, format: 'runspec' };
10
+ return { configPath: runspecToml };
17
11
  }
18
12
 
19
13
  const parent = path.dirname(dir);
@@ -22,38 +16,6 @@ export function findConfig(start?: string): { configPath: string; format: 'pypro
22
16
  }
23
17
 
24
18
  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.",
19
+ "No runspec configuration found.\nExpected runspec.toml inside your package directory.\n\nRun 'runspec init' to create one.",
26
20
  );
27
21
  }
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/inference.ts CHANGED
@@ -25,7 +25,10 @@ export function inferArg(raw: ArgSpec): ArgSpec {
25
25
  }
26
26
  }
27
27
 
28
- if (result.required === undefined) {
28
+ if (result.type === 'rest') {
29
+ if (result.default === undefined || result.default === null) result.default = [];
30
+ if (result.required === undefined) result.required = false;
31
+ } else if (result.required === undefined) {
29
32
  const hasNoDefault = def === undefined || def === null;
30
33
  result.required = hasNoDefault && result.type !== 'flag';
31
34
  }
package/src/jump.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { spawn } from 'child_process';
2
+ import type { ChildProcess } from 'child_process';
3
+ import * as readline from 'readline';
4
+ import * as path from 'path';
5
+ import type { JumpHostConfig } from './models';
6
+
7
+ const VALID_BIN_NAMES = new Set(['runspec', 'runspec.exe']);
8
+
9
+ export function resolveBinRaw(hostCfg: JumpHostConfig): string {
10
+ return hostCfg.bin ?? process.env['RUNSPEC_JUMP_BIN'] ?? 'runspec';
11
+ }
12
+
13
+ function resolveBin(hostCfg: JumpHostConfig): string {
14
+ const binPath = resolveBinRaw(hostCfg);
15
+ validateBinPath(binPath);
16
+ return binPath;
17
+ }
18
+
19
+ function validateBinPath(binPath: string): void {
20
+ const name = path.basename(binPath);
21
+ if (!VALID_BIN_NAMES.has(name)) {
22
+ process.stderr.write(
23
+ `✗ Jump-host \`bin\` must point at a runspec executable.\n` +
24
+ ` Got: ${JSON.stringify(binPath)} (basename ${JSON.stringify(name)})\n` +
25
+ ` Expected basename: 'runspec' (or 'runspec.exe' on Windows).\n` +
26
+ ` This field is locked to the runspec CLI; it cannot be redirected.\n`,
27
+ );
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ export function sshCmd(hostCfg: JumpHostConfig, binPath: string): string[] {
33
+ const cmd = ['ssh', '-o', 'BatchMode=yes'];
34
+
35
+ if (hostCfg.useSshConfig === false) {
36
+ cmd.push('-F', '/dev/null');
37
+ }
38
+
39
+ if (hostCfg.port && hostCfg.port !== 22) {
40
+ cmd.push('-p', String(hostCfg.port));
41
+ }
42
+ if (hostCfg.sshKey) {
43
+ cmd.push('-i', hostCfg.sshKey);
44
+ }
45
+
46
+ for (const opt of hostCfg.sshOptions ?? []) {
47
+ cmd.push('-o', String(opt));
48
+ }
49
+
50
+ const target = hostCfg.user ? `${hostCfg.user}@${hostCfg.host}` : hostCfg.host;
51
+ cmd.push(target, binPath, 'serve');
52
+ return cmd;
53
+ }
54
+
55
+ interface Session {
56
+ send: (msg: Record<string, unknown>) => void;
57
+ recv: () => Promise<Record<string, unknown>>;
58
+ close: () => void;
59
+ proc: ChildProcess;
60
+ binPath: string;
61
+ }
62
+
63
+ function openSession(hostCfg: JumpHostConfig, binPath: string): Session {
64
+ const cmd = sshCmd(hostCfg, binPath);
65
+ const proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] });
66
+
67
+ proc.on('error', (err: NodeJS.ErrnoException) => {
68
+ if (err.code === 'ENOENT') {
69
+ process.stderr.write('✗ ssh not found — install OpenSSH\n');
70
+ } else {
71
+ process.stderr.write(`✗ Failed to launch ssh: ${err.message}\n`);
72
+ }
73
+ process.exit(1);
74
+ });
75
+
76
+ const rl = readline.createInterface({ input: proc.stdout!, crlfDelay: Infinity });
77
+ const iter = rl[Symbol.asyncIterator]();
78
+
79
+ const send = (msg: Record<string, unknown>): void => {
80
+ proc.stdin!.write(JSON.stringify(msg) + '\n');
81
+ };
82
+
83
+ const recv = async (): Promise<Record<string, unknown>> => {
84
+ const { value, done } = await iter.next();
85
+ if (done) {
86
+ await reportRemoteFailure(proc, binPath);
87
+ throw new Error('unreachable');
88
+ }
89
+ return JSON.parse(value as string) as Record<string, unknown>;
90
+ };
91
+
92
+ const close = (): void => {
93
+ rl.close();
94
+ if (proc.stdin) proc.stdin.destroy();
95
+ };
96
+
97
+ return { send, recv, close, proc, binPath };
98
+ }
99
+
100
+ async function reportRemoteFailure(proc: ChildProcess, binPath: string): Promise<never> {
101
+ const exitCode =
102
+ proc.exitCode ??
103
+ (await new Promise<number | null>((resolve) => {
104
+ const timer = setTimeout(() => resolve(null), 1000);
105
+ proc.once('exit', (code) => {
106
+ clearTimeout(timer);
107
+ resolve(code);
108
+ });
109
+ }));
110
+
111
+ if (exitCode === 255) {
112
+ process.stderr.write('✗ SSH connection failed (see error above for details).\n');
113
+ } else if (exitCode !== null && exitCode !== 0) {
114
+ const prefix = `✗ Remote command failed (exit ${exitCode}) before the MCP handshake completed.\n`;
115
+ if (binPath.includes('/')) {
116
+ process.stderr.write(
117
+ prefix +
118
+ ' If the error above doesn\'t explain it, verify the path exists on the remote:\n' +
119
+ ` ${binPath}\n` +
120
+ ' Common causes:\n' +
121
+ ' - the venv path differs between local and remote\n' +
122
+ " - runspec isn't installed in that venv on the remote\n" +
123
+ ' - typo in the bin / RUNSPEC_JUMP_BIN value\n',
124
+ );
125
+ } else {
126
+ process.stderr.write(
127
+ prefix +
128
+ ` \`${binPath}\` is not on the remote shell's PATH.\n` +
129
+ ' Fix: set `bin = "/full/path/to/runspec"` in [config.jump-hosts.<alias>],\n' +
130
+ ' or export RUNSPEC_JUMP_BIN in your local shell.\n' +
131
+ " (SSH commands run in a non-login shell and don't source ~/.bashrc / ~/.profile.)\n",
132
+ );
133
+ }
134
+ } else {
135
+ process.stderr.write('✗ Remote MCP server closed stdout unexpectedly\n');
136
+ }
137
+ process.exit(1);
138
+ }
139
+
140
+ async function initialize(session: Session): Promise<void> {
141
+ session.send({
142
+ jsonrpc: '2.0',
143
+ id: 1,
144
+ method: 'initialize',
145
+ params: {
146
+ protocolVersion: '2024-11-05',
147
+ capabilities: {},
148
+ clientInfo: { name: 'runspec-jump', version: '1.0' },
149
+ },
150
+ });
151
+ await session.recv();
152
+ session.send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
153
+ }
154
+
155
+ export async function listTools(hostCfg: JumpHostConfig): Promise<Array<Record<string, unknown>>> {
156
+ const binPath = resolveBin(hostCfg);
157
+ const session = openSession(hostCfg, binPath);
158
+ try {
159
+ await initialize(session);
160
+ session.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
161
+ const resp = await session.recv();
162
+ return ((resp['result'] as Record<string, unknown>)?.['tools'] as Array<Record<string, unknown>>) ?? [];
163
+ } finally {
164
+ session.close();
165
+ }
166
+ }
167
+
168
+ export async function callTool(hostCfg: JumpHostConfig, toolName: string, toolArgv: string[]): Promise<void> {
169
+ const binPath = resolveBin(hostCfg);
170
+ const session = openSession(hostCfg, binPath);
171
+ try {
172
+ await initialize(session);
173
+ session.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
174
+ const toolsResp = await session.recv();
175
+ const tools =
176
+ ((toolsResp['result'] as Record<string, unknown>)?.['tools'] as Array<Record<string, unknown>>) ?? [];
177
+ const schema = tools.find((t) => t['name'] === toolName);
178
+ if (!schema) {
179
+ process.stderr.write(`✗ Tool '${toolName}' not found on remote\n`);
180
+ process.exit(1);
181
+ }
182
+
183
+ const arguments_ = parseToolArgv(toolArgv, schema);
184
+ session.send({
185
+ jsonrpc: '2.0',
186
+ id: 3,
187
+ method: 'tools/call',
188
+ params: { name: toolName, arguments: arguments_ },
189
+ });
190
+ const callResp = await session.recv();
191
+
192
+ if ('error' in callResp) {
193
+ const err = callResp['error'] as Record<string, unknown>;
194
+ process.stderr.write(`✗ ${(err['message'] as string | undefined) ?? 'Remote error'}\n`);
195
+ process.exit(1);
196
+ }
197
+
198
+ const result = (callResp['result'] as Record<string, unknown>) ?? {};
199
+ for (const block of (result['content'] as Array<Record<string, unknown>>) ?? []) {
200
+ if (block['type'] === 'text') {
201
+ const text = block['text'] as string;
202
+ process.stdout.write(text);
203
+ if (!text.endsWith('\n')) process.stdout.write('\n');
204
+ }
205
+ }
206
+
207
+ if (result['isError']) {
208
+ process.exit(1);
209
+ }
210
+ } finally {
211
+ session.close();
212
+ }
213
+ }
214
+
215
+ export function parseToolArgv(argv: string[], schema: Record<string, unknown>): Record<string, unknown> {
216
+ const props =
217
+ ((schema['inputSchema'] as Record<string, unknown>)?.['properties'] as Record<
218
+ string,
219
+ Record<string, unknown>
220
+ >) ?? {};
221
+ const result: Record<string, unknown> = {};
222
+ let i = 0;
223
+ while (i < argv.length) {
224
+ const token = argv[i];
225
+ if (!token.startsWith('--')) {
226
+ i++;
227
+ continue;
228
+ }
229
+ const argName = token.slice(2);
230
+ const prop = props[argName] ?? {};
231
+ if (prop['type'] === 'boolean') {
232
+ result[argName] = true;
233
+ i++;
234
+ } else if (i + 1 < argv.length) {
235
+ result[argName] = argv[i + 1];
236
+ i += 2;
237
+ } else {
238
+ process.stderr.write(`✗ --${argName} requires a value\n`);
239
+ process.exit(1);
240
+ }
241
+ }
242
+ return result;
243
+ }
package/src/loader.ts CHANGED
@@ -1,20 +1,10 @@
1
1
  import * as fs from 'fs';
2
2
  import { parse as parseTOML } from 'smol-toml';
3
- import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec } from './models';
3
+ import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec, JumpHostConfig } from './models';
4
4
 
5
- export function loadRaw(configPath: string, format: 'pyproject' | 'runspec'): RawSpec {
5
+ export function loadRaw(configPath: string): RawSpec {
6
6
  const content = fs.readFileSync(configPath, 'utf-8');
7
- const data = parseTOML(content) as Record<string, unknown>;
8
-
9
- let raw: Record<string, unknown>;
10
- let entryPoints: Record<string, string> = {};
11
-
12
- if (format === 'pyproject') {
13
- raw = ((data as any)?.tool?.runspec ?? {}) as Record<string, unknown>;
14
- entryPoints = readEntryPoints(data);
15
- } else {
16
- raw = data;
17
- }
7
+ const raw = parseTOML(content) as Record<string, unknown>;
18
8
 
19
9
  const runnablesRaw: Record<string, Record<string, unknown>> = {};
20
10
  for (const [key, value] of Object.entries(raw)) {
@@ -26,15 +16,32 @@ export function loadRaw(configPath: string, format: 'pyproject' | 'runspec'): Ra
26
16
  return {
27
17
  config: normaliseConfig((raw['config'] ?? {}) as Record<string, unknown>),
28
18
  runnables: normaliseRunnables(runnablesRaw),
29
- entryPoints,
30
19
  };
31
20
  }
32
21
 
33
22
  function normaliseConfig(raw: Record<string, unknown>): RawConfig {
23
+ const rawHosts = (raw['jump-hosts'] ?? {}) as Record<string, Record<string, unknown>>;
24
+ const jumpHosts: Record<string, JumpHostConfig> = {};
25
+ for (const [name, cfg] of Object.entries(rawHosts)) {
26
+ jumpHosts[name] = normaliseJumpHost(cfg);
27
+ }
34
28
  return {
35
29
  autonomyDefault: (raw['autonomy-default'] as string | undefined) ?? 'confirm',
36
30
  lang: raw['lang'] as string | undefined,
37
31
  version: String(raw['version'] ?? '1'),
32
+ jumpHosts,
33
+ };
34
+ }
35
+
36
+ function normaliseJumpHost(raw: Record<string, unknown>): JumpHostConfig {
37
+ return {
38
+ host: raw['host'] as string,
39
+ user: raw['user'] as string | undefined,
40
+ port: raw['port'] as number | undefined,
41
+ sshKey: raw['ssh-key'] as string | undefined,
42
+ bin: raw['bin'] as string | undefined,
43
+ useSshConfig: raw['use-ssh-config'] as boolean | undefined,
44
+ sshOptions: raw['ssh-options'] as string[] | undefined,
38
45
  };
39
46
  }
40
47
 
@@ -78,6 +85,7 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
78
85
  multiple: (raw['multiple'] as boolean | undefined) ?? false,
79
86
  delimiter: raw['delimiter'] as string | undefined,
80
87
  short: raw['short'] as string | undefined,
88
+ position: raw['position'] as number | undefined,
81
89
  env: raw['env'] as string | undefined,
82
90
  deprecated: raw['deprecated'] as string | undefined,
83
91
  autonomy: raw['autonomy'] as string | undefined,
@@ -111,10 +119,3 @@ function normaliseCommands(raw: Record<string, Record<string, unknown>>): Record
111
119
  return Object.fromEntries(Object.entries(raw).map(([name, data]) => [name, normaliseScript(name, data)]));
112
120
  }
113
121
 
114
- function readEntryPoints(data: Record<string, unknown>): Record<string, string> {
115
- const projectScripts = (data as any)?.project?.scripts;
116
- if (projectScripts && typeof projectScripts === 'object') return projectScripts as Record<string, string>;
117
- const poetryScripts = (data as any)?.tool?.poetry?.scripts;
118
- if (poetryScripts && typeof poetryScripts === 'object') return poetryScripts as Record<string, string>;
119
- return {};
120
- }
package/src/models.ts CHANGED
@@ -1,7 +1,18 @@
1
+ export interface JumpHostConfig {
2
+ host: string;
3
+ user?: string;
4
+ port?: number;
5
+ sshKey?: string;
6
+ bin?: string;
7
+ useSshConfig?: boolean;
8
+ sshOptions?: string[];
9
+ }
10
+
1
11
  export interface RawConfig {
2
12
  autonomyDefault: string;
3
13
  lang?: string;
4
14
  version: string;
15
+ jumpHosts: Record<string, JumpHostConfig>;
5
16
  }
6
17
 
7
18
  export interface ArgSpec {
@@ -15,6 +26,7 @@ export interface ArgSpec {
15
26
  multiple?: boolean;
16
27
  delimiter?: string;
17
28
  short?: string;
29
+ position?: number;
18
30
  env?: string;
19
31
  deprecated?: string;
20
32
  autonomy?: string;
@@ -47,15 +59,16 @@ export interface ScriptSpec {
47
59
  export interface RawSpec {
48
60
  config: RawConfig;
49
61
  runnables: Record<string, ScriptSpec>;
50
- entryPoints: Record<string, string>;
51
62
  }
52
63
 
53
64
  export interface ParsedArgs {
54
65
  [key: string]: unknown;
55
- readonly __agent__: boolean;
56
- readonly __script__: string;
57
- readonly __command__: string | undefined;
58
- readonly __autonomy__: string;
59
- readonly __source__: string;
60
- readonly __spec__: ScriptSpec;
66
+ readonly __runspec_agent__: boolean;
67
+ readonly __runspec_script__: string;
68
+ readonly __runspec_command_path__: string[];
69
+ readonly __runspec_autonomy__: string;
70
+ readonly __runspec_source__: string;
71
+ readonly __runspec_spec__: ScriptSpec;
72
+ readonly runspec_command: string | undefined;
73
+ readonly runspec_command_path: string[];
61
74
  }
package/src/parser.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as path from 'path';
2
- import { findConfig, findScriptName } from './finder';
2
+ import { findConfig } from './finder';
3
3
  import { loadRaw } from './loader';
4
4
  import { inferScript, effectiveAutonomy } from './inference';
5
5
  import { coerce } from './types';
@@ -11,16 +11,17 @@ export interface ParseOptions {
11
11
  scriptName?: string;
12
12
  argv?: string[];
13
13
  cwd?: string;
14
+ configPath?: string;
14
15
  }
15
16
 
16
17
  export function parse(opts: ParseOptions = {}): ParsedArgs {
17
- const { scriptName, argv: argvOverride, cwd } = opts;
18
+ const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride } = opts;
18
19
 
19
- const { configPath, format } = findConfig(cwd);
20
- const raw = loadRaw(configPath, format);
20
+ const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
21
+ const raw = loadRaw(configPath);
21
22
  const config = raw.config;
22
23
 
23
- const name = scriptName ?? findScriptName(configPath, format) ?? inferFromArgv();
24
+ const name = scriptName ?? inferFromArgv();
24
25
  if (!name) throw new RunSpecError('✗ Could not determine runnable name. Pass scriptName option.');
25
26
  if (name === 'config') throw new RunSpecError("✗ 'config' is a reserved name in runspec.\n Rename your runnable.");
26
27
 
@@ -33,17 +34,17 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
33
34
 
34
35
  let argv = argvOverride ?? process.argv.slice(2);
35
36
  let activeScript = rawScript;
36
- let activeCommand: string | undefined;
37
+ let commandPath: string[] = [];
37
38
 
38
39
  const commands = rawScript.commands ?? {};
39
40
  if (Object.keys(commands).length > 0 && argv.length > 0 && argv[0] in commands) {
40
- activeCommand = argv[0];
41
+ commandPath = [argv[0]];
41
42
  activeScript = commands[argv[0]];
42
43
  argv = argv.slice(1);
43
44
  }
44
45
 
45
46
  if (argv.includes('--help') || argv.includes('-h')) {
46
- printHelp(name, activeScript, activeCommand);
47
+ printHelp(name, activeScript, commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined);
47
48
  process.exit(0);
48
49
  }
49
50
 
@@ -66,12 +67,14 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
66
67
 
67
68
  return {
68
69
  ...coercedValues,
69
- __agent__: agent,
70
- __script__: name,
71
- __command__: activeCommand,
72
- __autonomy__: autonomy,
73
- __source__: configPath,
74
- __spec__: activeScript,
70
+ __runspec_agent__: agent,
71
+ __runspec_script__: name,
72
+ __runspec_command_path__: commandPath,
73
+ __runspec_autonomy__: autonomy,
74
+ __runspec_source__: configPath,
75
+ __runspec_spec__: activeScript,
76
+ get runspec_command() { return commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined; },
77
+ get runspec_command_path() { return commandPath; },
75
78
  } as ParsedArgs;
76
79
  }
77
80
 
@@ -95,15 +98,29 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
95
98
  if (spec.short) shortMap[spec.short] = norm;
96
99
  }
97
100
 
101
+ // Positional args sorted by position index; rest arg (type='rest') collects post-'--' tokens
102
+ const positionalArgs = Object.entries(argSpecs)
103
+ .filter(([, s]) => s.position !== undefined)
104
+ .sort(([, a], [, b]) => (a.position ?? 0) - (b.position ?? 0))
105
+ .map(([name]) => name.replace(/-/g, '_'));
106
+ const restArgNorm = Object.entries(argSpecs).find(([, s]) => s.type === 'rest')?.[0]?.replace(/-/g, '_');
107
+
98
108
  const result: Record<string, unknown> = {};
99
109
  for (const name of Object.keys(argSpecs)) {
100
110
  result[name.replace(/-/g, '_')] = undefined;
101
111
  }
102
112
 
113
+ let positionalIndex = 0;
103
114
  let i = 0;
104
115
  while (i < argv.length) {
105
116
  const token = argv[i];
106
117
 
118
+ // '--' separator: remaining tokens go to the rest arg
119
+ if (token === '--') {
120
+ if (restArgNorm !== undefined) result[restArgNorm] = argv.slice(i + 1);
121
+ break;
122
+ }
123
+
107
124
  if (token.startsWith('--') && token.includes('=')) {
108
125
  const eqIdx = token.indexOf('=');
109
126
  const key = token.slice(0, eqIdx);
@@ -140,6 +157,12 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
140
157
  continue;
141
158
  }
142
159
 
160
+ // Unrecognized non-flag token: assign to next positional arg
161
+ if (!token.startsWith('-') && positionalIndex < positionalArgs.length) {
162
+ result[positionalArgs[positionalIndex]] = token;
163
+ positionalIndex++;
164
+ }
165
+
143
166
  i++;
144
167
  }
145
168
 
@@ -0,0 +1,81 @@
1
+ #:schema https://raw.githubusercontent.com/JasonFinestone/runspec/main/schema/runspec.schema.json
2
+
3
+ # runspec itself is a developer CLI, not an agent tool. Suppress autonomy
4
+ # inference so --help doesn't display a meaningless autonomy level on the
5
+ # top-level menu or on `serve` (which is a server, not an action).
6
+ [config]
7
+ autonomy-default = ""
8
+
9
+ [runspec]
10
+ description = "Interface specification for anything runnable"
11
+
12
+ examples = [
13
+ {cmd = "runspec init", description = "Scaffold a new runnable in this directory"},
14
+ {cmd = "runspec local", description = "Discover and validate runnables"},
15
+ {cmd = "runspec serve", description = "Start the MCP stdio server"},
16
+ {cmd = "runspec jump --list-jump-hosts", description = "List configured jump hosts"},
17
+ ]
18
+
19
+ [runspec.commands.init]
20
+ description = "Scaffold a new runnable — creates runspec.toml and a code stub"
21
+ autonomy = "confirm"
22
+
23
+ examples = [
24
+ {cmd = "runspec init", description = "Scaffold using current directory name"},
25
+ {cmd = "runspec init --name deploy", description = "Scaffold a runnable called 'deploy'"},
26
+ {cmd = "runspec init --example", description = "Generate worked example (clean + scan)"},
27
+ {cmd = "runspec init --example --write-project", description = "Also generate pyproject.toml in parent dir"},
28
+ {cmd = "runspec init --write-project --project-dir /tmp/myproject", description = "Write project files to a specific path"},
29
+ {cmd = "runspec init --name myapp --lang typescript", description = "Use TypeScript code stub"},
30
+ ]
31
+
32
+ [runspec.commands.init.args]
33
+ name = {type = "str", description = "Runnable name (defaults to package directory name)", short = "-n", required = false}
34
+ lang = {type = "choice", description = "Language for the generated code stub", options = ["python", "typescript", "javascript"], default = "python"}
35
+ example = {type = "flag", description = "Generate a worked example (clean + scan runnables)", short = "-e", default = false}
36
+ write-project = {type = "flag", description = "Generate pyproject.toml, __init__.py, .gitignore, and CLAUDE.md", short = "-w", default = false}
37
+ project-dir = {type = "str", description = "Where to write project files (default: parent directory)", short = "-d", required = false}
38
+ force = {type = "flag", description = "Override the cwd safety check (don't refuse when pyproject.toml is present in cwd)", default = false}
39
+
40
+ [runspec.commands.local]
41
+ description = "List installed runnables or emit their tool schemas"
42
+ autonomy = "autonomous"
43
+ output = "json"
44
+
45
+ examples = [
46
+ {cmd = "runspec local", description = "Discover runnables and validate setup"},
47
+ {cmd = "runspec local --format mcp", description = "Emit MCP tool schemas"},
48
+ {cmd = "runspec local --format mcp --runnable deploy", description = "Emit schema for one runnable"},
49
+ {cmd = "runspec local --format json", description = "Full spec as JSON"},
50
+ ]
51
+
52
+ [runspec.commands.local.args]
53
+ format = {type = "choice", description = "Output format", options = ["text", "json", "mcp", "openai", "anthropic"], short = "-f", default = "text"}
54
+ runnable = {type = "str", description = "Filter output to a single runnable by name", short = "-r", required = false}
55
+
56
+ [runspec.commands.serve]
57
+ description = "Start an MCP stdio server exposing all installed runnables as tools"
58
+
59
+ examples = [
60
+ {cmd = "runspec serve", description = "Serve all runspec-aware runnables installed in this venv"},
61
+ ]
62
+
63
+ # No args — runnables are discovered via importlib.metadata. Install with
64
+ # `pip install -e .` to make a package visible.
65
+
66
+ [runspec.commands.jump]
67
+ description = "List jump hosts, list tools on a jump host, or run a tool via SSH+MCP"
68
+ autonomy = "confirm"
69
+
70
+ examples = [
71
+ {cmd = "runspec jump --list-jump-hosts", description = "List configured jump hosts"},
72
+ {cmd = "runspec jump myserver", description = "List tools available on myserver"},
73
+ {cmd = "runspec jump myserver deploy -- --env prod", description = "Run 'deploy' on myserver with --env prod"},
74
+ ]
75
+
76
+ [runspec.commands.jump.args]
77
+ list-jump-hosts = {type = "flag", description = "List configured jump hosts from runspec.toml", short = "-l", default = false}
78
+ format = {type = "choice", description = "Output format when listing", options = ["text", "json"], short = "-f", default = "text"}
79
+ jump-host = {type = "str", description = "Jump host alias from [config.jump-hosts]", position = 1, required = false}
80
+ tool = {type = "str", description = "Tool to run on the jump host", position = 2, required = false}
81
+ tool-args = {type = "rest", description = "Args passed to the remote tool"}