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/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/loader.ts CHANGED
@@ -2,19 +2,9 @@ import * as fs from 'fs';
2
2
  import { parse as parseTOML } from 'smol-toml';
3
3
  import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec } 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,7 +16,6 @@ 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
 
@@ -111,10 +100,3 @@ function normaliseCommands(raw: Record<string, Record<string, unknown>>): Record
111
100
  return Object.fromEntries(Object.entries(raw).map(([name, data]) => [name, normaliseScript(name, data)]));
112
101
  }
113
102
 
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
@@ -47,7 +47,6 @@ export interface ScriptSpec {
47
47
  export interface RawSpec {
48
48
  config: RawConfig;
49
49
  runnables: Record<string, ScriptSpec>;
50
- entryPoints: Record<string, string>;
51
50
  }
52
51
 
53
52
  export interface ParsedArgs {
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';
@@ -16,11 +16,11 @@ export interface ParseOptions {
16
16
  export function parse(opts: ParseOptions = {}): ParsedArgs {
17
17
  const { scriptName, argv: argvOverride, cwd } = opts;
18
18
 
19
- const { configPath, format } = findConfig(cwd);
20
- const raw = loadRaw(configPath, format);
19
+ const { configPath } = findConfig(cwd);
20
+ const raw = loadRaw(configPath);
21
21
  const config = raw.config;
22
22
 
23
- const name = scriptName ?? findScriptName(configPath, format) ?? inferFromArgv();
23
+ const name = scriptName ?? inferFromArgv();
24
24
  if (!name) throw new RunSpecError('✗ Could not determine runnable name. Pass scriptName option.');
25
25
  if (name === 'config') throw new RunSpecError("✗ 'config' is a reserved name in runspec.\n Rename your runnable.");
26
26
 
package/src/serve.ts CHANGED
@@ -12,39 +12,43 @@ const ERR_PARSE = -32700;
12
12
  const ERR_METHOD_NOT_FOUND = -32601;
13
13
  const ERR_INVALID_PARAMS = -32602;
14
14
 
15
+ const SHELL_EXTS = ['', '.sh', '.ksh', '.bash', '.zsh'];
16
+
15
17
  export function serve(): void {
16
18
  let configPath: string;
17
- let format: 'pyproject' | 'runspec';
18
19
 
19
20
  try {
20
- ({ configPath, format } = findConfig(process.cwd()));
21
+ ({ configPath } = findConfig(process.cwd()));
21
22
  } catch (e) {
22
23
  process.stderr.write(`runspec serve: ${(e as Error).message}\n`);
23
24
  process.exit(1);
24
25
  }
25
26
 
26
- const raw = loadRaw(configPath, format);
27
+ const raw = loadRaw(configPath);
27
28
  const config = raw.config;
28
29
 
29
30
  const tools: Record<string, Record<string, unknown>> = {};
30
31
  const argSpecs: Record<string, Record<string, unknown>> = {};
32
+ const execSpecs: Record<string, { command: string | null }> = {};
33
+
34
+ const binDir = path.join(path.dirname(configPath), 'node_modules', '.bin');
31
35
 
32
36
  for (const [name, runnable] of Object.entries(raw.runnables)) {
33
37
  const inferred = inferScript(runnable, config.autonomyDefault);
34
38
  tools[name] = buildSchema(name, inferred, 'mcp');
35
39
  argSpecs[name] = inferred.args ?? {};
40
+ execSpecs[name] = { command: findScript(name, binDir) };
36
41
  }
37
42
 
38
43
  const serverName = serverNameFromConfig(config as unknown as Record<string, unknown>);
39
- const binDir = path.join(path.dirname(configPath), 'node_modules', '.bin');
40
44
 
41
- mcpLoop(tools, argSpecs, binDir, serverName);
45
+ mcpLoop(tools, argSpecs, execSpecs, serverName);
42
46
  }
43
47
 
44
48
  function mcpLoop(
45
49
  tools: Record<string, Record<string, unknown>>,
46
50
  argSpecs: Record<string, Record<string, unknown>>,
47
- binDir: string,
51
+ execSpecs: Record<string, { command: string | null }>,
48
52
  serverName: string,
49
53
  ): void {
50
54
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -61,7 +65,7 @@ function mcpLoop(
61
65
  return;
62
66
  }
63
67
 
64
- const response = dispatch(request, tools, argSpecs, binDir, serverName);
68
+ const response = dispatch(request, tools, argSpecs, execSpecs, serverName);
65
69
  if (response !== null) writeMsg(response);
66
70
  });
67
71
  }
@@ -70,7 +74,7 @@ function dispatch(
70
74
  request: Record<string, unknown>,
71
75
  tools: Record<string, Record<string, unknown>>,
72
76
  argSpecs: Record<string, Record<string, unknown>>,
73
- binDir: string,
77
+ execSpecs: Record<string, { command: string | null }>,
74
78
  serverName: string,
75
79
  ): Record<string, unknown> | null {
76
80
  const method = (request['method'] as string) ?? '';
@@ -81,14 +85,14 @@ function dispatch(
81
85
  if (method === 'initialize') return handleInitialize(reqId, serverName);
82
86
  if (method === 'tools/list') return handleToolsList(reqId, tools);
83
87
  if (method === 'tools/call') {
84
- return handleToolsCall(reqId, (request['params'] ?? {}) as Record<string, unknown>, tools, argSpecs, binDir);
88
+ return handleToolsCall(reqId, (request['params'] ?? {}) as Record<string, unknown>, tools, argSpecs, execSpecs);
85
89
  }
86
90
 
87
91
  return { jsonrpc: '2.0', id: reqId, error: { code: ERR_METHOD_NOT_FOUND, message: `Method not found: ${method}` } };
88
92
  }
89
93
 
90
94
  function handleInitialize(reqId: unknown, serverName: string): Record<string, unknown> {
91
- const version = '0.3.0';
95
+ const version = '0.6.0';
92
96
  return {
93
97
  jsonrpc: '2.0',
94
98
  id: reqId,
@@ -109,7 +113,7 @@ function handleToolsCall(
109
113
  params: Record<string, unknown>,
110
114
  tools: Record<string, Record<string, unknown>>,
111
115
  argSpecs: Record<string, Record<string, unknown>>,
112
- binDir: string,
116
+ execSpecs: Record<string, { command: string | null }>,
113
117
  ): Record<string, unknown> {
114
118
  const name = (params['name'] as string) ?? '';
115
119
  const args = (params['arguments'] as Record<string, unknown>) ?? {};
@@ -118,20 +122,22 @@ function handleToolsCall(
118
122
  return { jsonrpc: '2.0', id: reqId, error: { code: ERR_INVALID_PARAMS, message: `Unknown tool: ${name}` } };
119
123
  }
120
124
 
121
- const cmd = findScript(name, binDir);
125
+ const cmd = execSpecs[name]?.command ?? null;
122
126
  if (!cmd) {
123
127
  return {
124
128
  jsonrpc: '2.0',
125
129
  id: reqId,
126
130
  result: {
127
- content: [{ type: 'text', text: `Script not found in ${binDir}: ${name}` }],
131
+ content: [{ type: 'text', text: `Script '${name}' not found. Place it alongside runspec.toml or in a bin/ subdirectory.` }],
128
132
  isError: true,
129
133
  },
130
134
  };
131
135
  }
132
136
 
133
- const argv = argsToArgv(args, argSpecs[name] ?? {});
134
- const env = { ...process.env, RUNSPEC_AGENT: '1' };
137
+ const toolArgSpecs = argSpecs[name] ?? {};
138
+ const argv = argsToArgv(args, toolArgSpecs);
139
+ const runspecEnv = argsToRunspecEnv(args, toolArgSpecs);
140
+ const env = { ...process.env, RUNSPEC_AGENT: '1', ...runspecEnv };
135
141
 
136
142
  const result = spawnSync(cmd, argv, { encoding: 'utf-8', env });
137
143
 
@@ -159,7 +165,7 @@ function argsToArgv(args: Record<string, unknown>, argSpecs: Record<string, unkn
159
165
 
160
166
  for (const [argName, spec] of Object.entries(argSpecs)) {
161
167
  const s = spec as Record<string, unknown>;
162
- let value = args[argName] ?? args[argName.replace(/-/g, '_')];
168
+ const value = args[argName] ?? args[argName.replace(/-/g, '_')];
163
169
  if (value === null || value === undefined) continue;
164
170
 
165
171
  const flag = `--${argName}`;
@@ -177,11 +183,46 @@ function argsToArgv(args: Record<string, unknown>, argSpecs: Record<string, unkn
177
183
  return argv;
178
184
  }
179
185
 
186
+ function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string, unknown>): Record<string, string> {
187
+ const env: Record<string, string> = {};
188
+
189
+ for (const [argName, spec] of Object.entries(argSpecs)) {
190
+ const s = spec as Record<string, unknown>;
191
+ let value = args[argName] ?? args[argName.replace(/-/g, '_')];
192
+ if (value === null || value === undefined) value = s['default'];
193
+ if (value === null || value === undefined) continue;
194
+
195
+ const envKey = 'RUNSPEC_' + argName.toUpperCase().replace(/-/g, '_').replace(/\./g, '_');
196
+ const argType = (s['type'] as string) ?? 'str';
197
+
198
+ if (argType === 'flag' || argType === 'bool') {
199
+ env[envKey] = value ? '1' : '0';
200
+ } else if (s['multiple'] && Array.isArray(value)) {
201
+ env[envKey] = (value as unknown[]).map(String).join('\n');
202
+ } else {
203
+ env[envKey] = String(value);
204
+ }
205
+ }
206
+
207
+ return env;
208
+ }
209
+
180
210
  function findScript(name: string, binDir: string): string | null {
181
- const candidate = path.join(binDir, name);
182
- if (fs.existsSync(candidate)) return candidate;
183
- const candidateExe = path.join(binDir, name + '.exe');
184
- if (fs.existsSync(candidateExe)) return candidateExe;
211
+ // 1. node_modules/.bin (Node entry points; also .exe on Windows)
212
+ for (const ext of [...SHELL_EXTS, '.exe']) {
213
+ const candidate = path.join(binDir, name + ext);
214
+ if (fs.existsSync(candidate)) return candidate;
215
+ }
216
+
217
+ // 2. cwd/ and cwd/bin/
218
+ const cwd = process.cwd();
219
+ for (const dir of [cwd, path.join(cwd, 'bin')]) {
220
+ for (const ext of SHELL_EXTS) {
221
+ const candidate = path.join(dir, name + ext);
222
+ if (fs.existsSync(candidate)) return candidate;
223
+ }
224
+ }
225
+
185
226
  return null;
186
227
  }
187
228
 
@@ -0,0 +1,210 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ const CLI = path.resolve(__dirname, '../bin/runspec.js');
7
+
8
+ function tmpDir(): string {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-init-test-'));
10
+ }
11
+
12
+ function runCLI(dir: string, cliArgs: string[]): { stdout: string; stderr: string } {
13
+ try {
14
+ const stdout = execFileSync('node', [CLI, ...cliArgs], { cwd: dir, encoding: 'utf-8' });
15
+ return { stdout, stderr: '' };
16
+ } catch (e: any) {
17
+ return { stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
18
+ }
19
+ }
20
+
21
+ function runInit(dir: string, extraArgs: string[] = []): { stdout: string; stderr: string } {
22
+ return runCLI(dir, ['init', ...extraArgs]);
23
+ }
24
+
25
+ // ── init (basic) ──────────────────────────────────────────────────────────────
26
+
27
+ test('creates runspec.toml and .ts stub by default', () => {
28
+ const dir = tmpDir();
29
+ const { stdout } = runInit(dir, ['--name', 'greet']);
30
+ expect(fs.existsSync(path.join(dir, 'runspec.toml'))).toBe(true);
31
+ expect(fs.existsSync(path.join(dir, 'greet.ts'))).toBe(true);
32
+ expect(stdout).toContain('Created runspec.toml');
33
+ expect(stdout).toContain('Created greet.ts');
34
+ });
35
+
36
+ test('runspec.toml contains the runnable name', () => {
37
+ const dir = tmpDir();
38
+ runInit(dir, ['--name', 'deploy']);
39
+ const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
40
+ expect(toml).toContain('[deploy]');
41
+ });
42
+
43
+ test('typescript stub imports parse from runspec-node', () => {
44
+ const dir = tmpDir();
45
+ runInit(dir, ['--name', 'greet']);
46
+ const ts = fs.readFileSync(path.join(dir, 'greet.ts'), 'utf-8');
47
+ expect(ts).toContain("import { parse } from 'runspec-node'");
48
+ expect(ts).toContain('function main()');
49
+ expect(ts).toContain('main();');
50
+ });
51
+
52
+ test('--lang javascript generates .js stub', () => {
53
+ const dir = tmpDir();
54
+ runInit(dir, ['--name', 'greet', '--lang', 'javascript']);
55
+ expect(fs.existsSync(path.join(dir, 'greet.js'))).toBe(true);
56
+ const js = fs.readFileSync(path.join(dir, 'greet.js'), 'utf-8');
57
+ expect(js).toContain("require('runspec-node')");
58
+ expect(js).toContain('main();');
59
+ });
60
+
61
+ test('--lang python generates .py stub', () => {
62
+ const dir = tmpDir();
63
+ runInit(dir, ['--name', 'greet', '--lang', 'python']);
64
+ expect(fs.existsSync(path.join(dir, 'greet.py'))).toBe(true);
65
+ const py = fs.readFileSync(path.join(dir, 'greet.py'), 'utf-8');
66
+ expect(py).toContain('from runspec import parse');
67
+ expect(py).toContain('if __name__ == "__main__"');
68
+ });
69
+
70
+ test('does not overwrite existing stub', () => {
71
+ const dir = tmpDir();
72
+ const stubPath = path.join(dir, 'greet.ts');
73
+ fs.writeFileSync(stubPath, '// existing\n', 'utf-8');
74
+ const { stdout } = runInit(dir, ['--name', 'greet']);
75
+ expect(fs.readFileSync(stubPath, 'utf-8')).toBe('// existing\n');
76
+ expect(stdout).toContain('already exists — skipped');
77
+ });
78
+
79
+ test('fails if runspec.toml already exists', () => {
80
+ const dir = tmpDir();
81
+ fs.writeFileSync(path.join(dir, 'runspec.toml'), '[greet]\n', 'utf-8');
82
+ const { stdout } = runInit(dir, ['--name', 'greet']);
83
+ expect(stdout).toContain('already exists');
84
+ expect(fs.existsSync(path.join(dir, 'greet.ts'))).toBe(false);
85
+ });
86
+
87
+ test('unknown --lang exits with error', () => {
88
+ const dir = tmpDir();
89
+ const { stdout } = runInit(dir, ['--name', 'greet', '--lang', 'ruby']);
90
+ expect(stdout).toContain('Unknown --lang');
91
+ });
92
+
93
+ // ── init --example ────────────────────────────────────────────────────────────
94
+
95
+ test('--example creates runspec.toml with clean and scan', () => {
96
+ const dir = tmpDir();
97
+ runInit(dir, ['--example']);
98
+ const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
99
+ expect(toml).toContain('[clean]');
100
+ expect(toml).toContain('[scan]');
101
+ });
102
+
103
+ test('--example creates clean.ts and scan.ts by default', () => {
104
+ const dir = tmpDir();
105
+ runInit(dir, ['--example']);
106
+ expect(fs.existsSync(path.join(dir, 'clean.ts'))).toBe(true);
107
+ expect(fs.existsSync(path.join(dir, 'scan.ts'))).toBe(true);
108
+ });
109
+
110
+ test('--example clean stub uses __agent__ check', () => {
111
+ const dir = tmpDir();
112
+ runInit(dir, ['--example']);
113
+ const ts = fs.readFileSync(path.join(dir, 'clean.ts'), 'utf-8');
114
+ expect(ts).toContain('__agent__');
115
+ expect(ts).toContain('delete');
116
+ });
117
+
118
+ test('--example scan stub always outputs JSON', () => {
119
+ const dir = tmpDir();
120
+ runInit(dir, ['--example']);
121
+ const ts = fs.readFileSync(path.join(dir, 'scan.ts'), 'utf-8');
122
+ expect(ts).toContain('JSON.stringify');
123
+ expect(ts).not.toContain('format');
124
+ });
125
+
126
+ test('--example scan toml has no format or delete arg', () => {
127
+ const dir = tmpDir();
128
+ runInit(dir, ['--example']);
129
+ const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
130
+ const scanArgsSection = toml.split('[scan.args]')[1] ?? '';
131
+ expect(scanArgsSection).not.toContain('format');
132
+ expect(scanArgsSection).not.toContain('delete');
133
+ });
134
+
135
+ test('--example scan toml declares output = json', () => {
136
+ const dir = tmpDir();
137
+ runInit(dir, ['--example']);
138
+ const toml = fs.readFileSync(path.join(dir, 'runspec.toml'), 'utf-8');
139
+ expect(toml).toContain('output = "json"');
140
+ });
141
+
142
+ test('--example shows demo prep commands', () => {
143
+ const dir = tmpDir();
144
+ const { stdout } = runInit(dir, ['--example']);
145
+ expect(stdout).toContain('touch -t 202401010000 report.tmp cache.tmp session.tmp');
146
+ expect(stdout).toContain('scan');
147
+ expect(stdout).toContain('clean');
148
+ });
149
+
150
+ test('--example with --name warns name is ignored', () => {
151
+ const dir = tmpDir();
152
+ const { stdout } = runInit(dir, ['--example', '--name', 'myapp']);
153
+ expect(stdout).toContain('--name is ignored');
154
+ });
155
+
156
+ test('--example --lang javascript creates clean.js and scan.js', () => {
157
+ const dir = tmpDir();
158
+ runInit(dir, ['--example', '--lang', 'javascript']);
159
+ expect(fs.existsSync(path.join(dir, 'clean.js'))).toBe(true);
160
+ expect(fs.existsSync(path.join(dir, 'scan.js'))).toBe(true);
161
+ });
162
+
163
+ // ── local command ─────────────────────────────────────────────────────────────
164
+
165
+ test('local command is recognized', () => {
166
+ const dir = tmpDir();
167
+ const { stdout } = runCLI(dir, ['local']);
168
+ expect(stdout).not.toContain('Unknown command');
169
+ });
170
+
171
+ test('discover command is no longer available', () => {
172
+ const dir = tmpDir();
173
+ const { stdout } = runCLI(dir, ['discover']);
174
+ expect(stdout).toContain('Unknown command');
175
+ });
176
+
177
+ test('check command is no longer available', () => {
178
+ const dir = tmpDir();
179
+ const { stdout } = runCLI(dir, ['check']);
180
+ expect(stdout).toContain('Unknown command');
181
+ });
182
+
183
+ test('emit command is no longer available', () => {
184
+ const dir = tmpDir();
185
+ const { stdout } = runCLI(dir, ['emit']);
186
+ expect(stdout).toContain('Unknown command');
187
+ });
188
+
189
+ test('jump command is recognized', () => {
190
+ const dir = tmpDir();
191
+ const { stdout } = runCLI(dir, ['jump']);
192
+ expect(stdout).toContain('not yet implemented');
193
+ });
194
+
195
+ // ── help ──────────────────────────────────────────────────────────────────────
196
+
197
+ test('top-level help mentions local and jump', () => {
198
+ const dir = tmpDir();
199
+ const { stdout } = runCLI(dir, ['--help']);
200
+ expect(stdout).toContain('local');
201
+ expect(stdout).toContain('jump');
202
+ expect(stdout).not.toContain('discover');
203
+ });
204
+
205
+ test('local --help shows focused help', () => {
206
+ const dir = tmpDir();
207
+ const { stdout } = runCLI(dir, ['local', '--help']);
208
+ expect(stdout).toContain('--format');
209
+ expect(stdout).toContain('--script');
210
+ });