badgr-cli 1.0.48 → 1.1.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 (68) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/src/api.js +16 -2
  4. package/src/artifactDownload.js +55 -0
  5. package/src/badgr.js +104 -0
  6. package/src/batch.js +22 -4
  7. package/src/browser.js +23 -0
  8. package/src/commands/artifacts.js +75 -0
  9. package/src/commands/batch.js +221 -28
  10. package/src/commands/billing.js +1 -12
  11. package/src/commands/capacity.js +9 -4
  12. package/src/commands/comfyui.js +3 -3
  13. package/src/commands/connect.js +83 -0
  14. package/src/commands/doctor.js +127 -0
  15. package/src/commands/down.js +29 -6
  16. package/src/commands/launch.js +431 -0
  17. package/src/commands/pull.js +137 -0
  18. package/src/commands/run.js +253 -37
  19. package/src/commands/sbatch.js +232 -0
  20. package/src/commands/serve.js +3 -3
  21. package/src/commands/status.js +12 -4
  22. package/src/commands/task.js +25 -0
  23. package/src/commands/test-run.js +4 -2
  24. package/src/credentials.js +65 -0
  25. package/src/fallback.js +7 -2
  26. package/src/fanout.js +70 -0
  27. package/src/gpuDoctor/diskInfo.js +42 -0
  28. package/src/gpuDoctor/doctor.js +451 -0
  29. package/src/gpuDoctor/gpuInfo.js +70 -0
  30. package/src/gpuDoctor/healthCheck.js +63 -0
  31. package/src/gpuDoctor/logClassifier.js +138 -0
  32. package/src/gpuDoctor/modelFit.js +107 -0
  33. package/src/gpuDoctor/probeCache.js +38 -0
  34. package/src/gpuDoctor/redact.js +29 -0
  35. package/src/gpuDoctor/torchInfo.js +61 -0
  36. package/src/gpuDoctor/workflowDoctor.js +96 -0
  37. package/src/onboarding.js +124 -0
  38. package/src/slurm.js +193 -0
  39. package/src/spec.js +59 -2
  40. package/src/store.js +16 -0
  41. package/tests/agent-images.test.js +17 -0
  42. package/tests/artifactDownload.test.js +113 -0
  43. package/tests/artifacts.test.js +168 -0
  44. package/tests/batch.test.js +312 -0
  45. package/tests/browser.test.js +51 -0
  46. package/tests/capacity.test.js +68 -0
  47. package/tests/commands.test.js +44 -0
  48. package/tests/connect.test.js +83 -0
  49. package/tests/down.test.js +23 -1
  50. package/tests/fallback-timeout.test.js +41 -0
  51. package/tests/fanout.test.js +124 -0
  52. package/tests/gpu-doctor-classifiers.test.js +402 -0
  53. package/tests/gpu-doctor-doctor.test.js +304 -0
  54. package/tests/gpu-doctor-probe-cache.test.js +110 -0
  55. package/tests/gpu-doctor-probes.test.js +257 -0
  56. package/tests/launch-command-argv.test.js +93 -0
  57. package/tests/launch-readiness.test.js +1 -0
  58. package/tests/launch.test.js +440 -0
  59. package/tests/onboarding.test.js +134 -0
  60. package/tests/pull.test.js +266 -0
  61. package/tests/run-lifecycle.test.js +405 -6
  62. package/tests/sbatch.test.js +190 -0
  63. package/tests/secrets.test.js +16 -0
  64. package/tests/slurm.test.js +77 -0
  65. package/tests/spec.test.js +59 -1
  66. package/tests/status.test.js +73 -0
  67. package/tests/task.test.js +109 -0
  68. package/tests/template.test.js +7 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * badgr launch — the task must reach the agent as a real argument array
3
+ * (opts.cmdArgv), never a shell string. Covers the exact tricky-task list
4
+ * from the "fix multi-word tasks permanently" spec: spaces, apostrophes,
5
+ * embedded quotes, `--`, Unicode, and shell metacharacters must all survive
6
+ * as exactly one array element, since there's no shell in this path at all.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
9
+
10
+ vi.mock('../src/credentials.js', () => ({
11
+ getCredential: vi.fn(() => 'stored-credential'),
12
+ setCredential: vi.fn(),
13
+ PROVIDER_ENV_KEYS: { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' },
14
+ }));
15
+
16
+ const runCommandMock = vi.fn();
17
+ vi.mock('../src/commands/run.js', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return { ...actual, runCommand: (...args) => runCommandMock(...args) };
20
+ });
21
+
22
+ const { launchCommand } = await import('../src/commands/launch.js');
23
+
24
+ const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
25
+ const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
26
+
27
+ const TRICKY_TASKS = [
28
+ 'Fix the checkout bug',
29
+ "Fix John's checkout bug",
30
+ 'Fix "checkout" validation',
31
+ 'Fix checkout -- do not modify billing',
32
+ 'Fix café checkout',
33
+ 'Fix $(echo dangerous)',
34
+ 'Fix checkout; rm -rf something',
35
+ ];
36
+
37
+ beforeEach(() => {
38
+ process.exitCode = undefined;
39
+ vi.spyOn(console, 'log').mockImplementation(() => {});
40
+ vi.spyOn(console, 'error').mockImplementation(() => {});
41
+ runCommandMock.mockClear();
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ process.exitCode = undefined;
47
+ });
48
+
49
+ describe('badgr launch <agent> "<task>" — task transport', () => {
50
+ for (const task of TRICKY_TASKS) {
51
+ it(`claude: passes ${JSON.stringify(task)} through as one argv element, not a shell string`, async () => {
52
+ await launchCommand(config, ['claude', task], chalk);
53
+
54
+ expect(runCommandMock).toHaveBeenCalledTimes(1);
55
+ const [, , , opts] = runCommandMock.mock.calls[0];
56
+ expect(opts.cmdArgv).toEqual(['claude', '-p', task]);
57
+ });
58
+
59
+ it(`codex: passes ${JSON.stringify(task)} through as one argv element, not a shell string`, async () => {
60
+ await launchCommand(config, ['codex', task], chalk);
61
+
62
+ const [, , , opts] = runCommandMock.mock.calls[0];
63
+ expect(opts.cmdArgv).toEqual(['badgr-codex-run', task]);
64
+ });
65
+
66
+ it(`cline: passes ${JSON.stringify(task)} through as one argv element, not a shell string`, async () => {
67
+ await launchCommand(config, ['cline', task], chalk);
68
+
69
+ const [, , , opts] = runCommandMock.mock.calls[0];
70
+ expect(opts.cmdArgv).toEqual(['badgr-cline-run', task]);
71
+ });
72
+ }
73
+
74
+ it('single-word tasks continue working', async () => {
75
+ await launchCommand(config, ['claude', 'hello'], chalk);
76
+ const [, , , opts] = runCommandMock.mock.calls[0];
77
+ expect(opts.cmdArgv).toEqual(['claude', '-p', 'hello']);
78
+ });
79
+
80
+ it('explicit `-- <command>` form passes the real array through, not a joined string', async () => {
81
+ await launchCommand(config, ['.', '--', 'codex', 'exec', 'Fix the checkout bug'], chalk);
82
+
83
+ expect(runCommandMock).toHaveBeenCalledTimes(1);
84
+ const [, , , opts] = runCommandMock.mock.calls[0];
85
+ expect(opts.cmdArgv).toEqual(['codex', 'exec', 'Fix the checkout bug']);
86
+ });
87
+
88
+ it('playwright (no natural-language task) still launches via an argv array', async () => {
89
+ await launchCommand(config, ['playwright'], chalk);
90
+ const [, , , opts] = runCommandMock.mock.calls[0];
91
+ expect(opts.cmdArgv).toEqual(['npx', 'playwright', 'test', '--reporter=line,html', '--trace=retain-on-failure']);
92
+ });
93
+ });
@@ -67,6 +67,7 @@ function makeTerminatedDep(overrides = {}) {
67
67
  started_at: STARTED,
68
68
  stopped_at: NOW,
69
69
  status: 'stopped',
70
+ teardown_ok: 'ok',
70
71
  ...overrides,
71
72
  };
72
73
  }
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const credentialStore = {};
4
+ vi.mock('../src/credentials.js', () => ({
5
+ getCredential: vi.fn(provider => credentialStore[provider] ?? null),
6
+ setCredential: vi.fn((provider, value) => { credentialStore[provider] = value; }),
7
+ PROVIDER_ENV_KEYS: { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY' },
8
+ }));
9
+
10
+ const mockPassword = vi.fn();
11
+ vi.mock('@inquirer/prompts', () => ({ password: (...args) => mockPassword(...args) }));
12
+
13
+ const { launchCommand, DEFAULT_LAUNCH_MAX_COST } = await import('../src/commands/launch.js');
14
+
15
+ const chalk = { red: s => s, dim: s => s, green: s => s, bold: s => s, yellow: s => s, cyan: s => s };
16
+ const config = { apiKey: 'test-key', baseUrl: 'https://example.test/v1' };
17
+
18
+ describe('launchCommand', () => {
19
+ let originalStdinTTY;
20
+ let originalStdoutTTY;
21
+
22
+ beforeEach(() => {
23
+ process.exitCode = undefined;
24
+ vi.spyOn(console, 'log').mockImplementation(() => {});
25
+ vi.spyOn(console, 'error').mockImplementation(() => {});
26
+ for (const k of Object.keys(credentialStore)) delete credentialStore[k];
27
+ mockPassword.mockReset();
28
+ originalStdinTTY = process.stdin.isTTY;
29
+ originalStdoutTTY = process.stdout.isTTY;
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ process.exitCode = undefined;
35
+ process.stdin.isTTY = originalStdinTTY;
36
+ process.stdout.isTTY = originalStdoutTTY;
37
+ });
38
+
39
+ it('requires an agent, task, or source', async () => {
40
+ await launchCommand(config, [], chalk);
41
+ expect(process.exitCode).toBe(1);
42
+ });
43
+
44
+ describe('cline — Badgr-native, no credential required', () => {
45
+ it('resolves image and command with no connect step', async () => {
46
+ await launchCommand(config, ['cline', '--dry-run', 'Fix the checkout bug'], chalk);
47
+ expect(process.exitCode).toBeUndefined();
48
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
49
+ expect(logged).toContain("badgr-cline-run Fix the checkout bug");
50
+ expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-cline:latest');
51
+ expect(logged).not.toContain('Env:');
52
+ });
53
+
54
+ it('requires a task', async () => {
55
+ await launchCommand(config, ['cline'], chalk);
56
+ expect(process.exitCode).toBe(1);
57
+ });
58
+ });
59
+
60
+ describe('claude — bring-your-own Anthropic', () => {
61
+ it('requires a connected credential', async () => {
62
+ await launchCommand(config, ['claude', 'Fix the checkout bug'], chalk);
63
+ expect(process.exitCode).toBe(1);
64
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
65
+ expect(logged).toContain('badgr connect anthropic');
66
+ });
67
+
68
+ it('resolves image, command, and injects the stored credential once connected', async () => {
69
+ credentialStore.anthropic = 'sk-ant-fake';
70
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
71
+ expect(process.exitCode).toBeUndefined();
72
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
73
+ expect(logged).toContain("claude -p Fix the checkout bug");
74
+ expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-claude:latest');
75
+ });
76
+
77
+ it('never prints the credential value — Env line is redacted', async () => {
78
+ credentialStore.anthropic = 'sk-ant-super-secret';
79
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
80
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
81
+ expect(logged).not.toContain('sk-ant-super-secret');
82
+ expect(logged).toContain('ANTHROPIC_API_KEY=<redacted>');
83
+ });
84
+
85
+ it('an explicit --env wins over a stored credential for the same key (no duplicate, no override)', async () => {
86
+ credentialStore.anthropic = 'sk-ant-stored';
87
+ await launchCommand(config, [
88
+ 'claude', '--dry-run', '--env', 'ANTHROPIC_API_KEY=sk-ant-explicit', 'Fix the checkout bug',
89
+ ], chalk);
90
+ expect(process.exitCode).toBeUndefined();
91
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
92
+ expect((logged.match(/ANTHROPIC_API_KEY=/g) || []).length).toBe(1);
93
+ });
94
+
95
+ it('an already-set --env credential skips the connect requirement', async () => {
96
+ await launchCommand(config, [
97
+ 'claude', '--dry-run', '--env', 'ANTHROPIC_API_KEY=sk-ant-from-env', 'Fix the checkout bug',
98
+ ], chalk);
99
+ expect(process.exitCode).toBeUndefined();
100
+ });
101
+
102
+ it('requires a task', async () => {
103
+ credentialStore.anthropic = 'sk-ant-fake';
104
+ await launchCommand(config, ['claude'], chalk);
105
+ expect(process.exitCode).toBe(1);
106
+ });
107
+ });
108
+
109
+ describe('codex — bring-your-own OpenAI', () => {
110
+ it('requires a connected credential', async () => {
111
+ await launchCommand(config, ['codex', 'Write tests'], chalk);
112
+ expect(process.exitCode).toBe(1);
113
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
114
+ expect(logged).toContain('badgr connect openai');
115
+ });
116
+
117
+ it('resolves image and command once connected', async () => {
118
+ credentialStore.openai = 'sk-fake';
119
+ await launchCommand(config, ['codex', '--dry-run', 'Write tests'], chalk);
120
+ expect(process.exitCode).toBeUndefined();
121
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
122
+ expect(logged).toContain("badgr-codex-run Write tests");
123
+ expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-codex:latest');
124
+ });
125
+ });
126
+
127
+ describe('inline credential prompt — interactive terminal, missing credential', () => {
128
+ beforeEach(() => {
129
+ process.stdin.isTTY = true;
130
+ process.stdout.isTTY = true;
131
+ });
132
+
133
+ it('prompts for the key, stores it, and continues the same launch — no separate badgr connect + rerun needed', async () => {
134
+ mockPassword.mockResolvedValue('sk-ant-typed-inline');
135
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
136
+ expect(process.exitCode).toBeUndefined();
137
+ expect(mockPassword).toHaveBeenCalledTimes(1);
138
+ expect(credentialStore.anthropic).toBe('sk-ant-typed-inline');
139
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
140
+ expect(logged).toContain("claude -p Fix the checkout bug");
141
+ expect(logged).not.toContain('sk-ant-typed-inline'); // redacted, not just stored
142
+ });
143
+
144
+ it('same inline prompt for codex/OpenAI', async () => {
145
+ mockPassword.mockResolvedValue('sk-openai-typed-inline');
146
+ await launchCommand(config, ['codex', '--dry-run', 'Write tests'], chalk);
147
+ expect(process.exitCode).toBeUndefined();
148
+ expect(credentialStore.openai).toBe('sk-openai-typed-inline');
149
+ });
150
+
151
+ it('fails cleanly if the user cancels the prompt (Ctrl+C) instead of hanging', async () => {
152
+ mockPassword.mockRejectedValue(new Error('User force closed the prompt'));
153
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
154
+ expect(process.exitCode).toBe(1);
155
+ expect(credentialStore.anthropic).toBeUndefined();
156
+ });
157
+
158
+ it('fails cleanly on an empty key instead of storing it', async () => {
159
+ mockPassword.mockResolvedValue(' ');
160
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
161
+ expect(process.exitCode).toBe(1);
162
+ expect(credentialStore.anthropic).toBeUndefined();
163
+ });
164
+
165
+ it('does not prompt at all once a credential is already stored', async () => {
166
+ credentialStore.anthropic = 'sk-ant-already-stored';
167
+ await launchCommand(config, ['claude', '--dry-run', 'Fix the checkout bug'], chalk);
168
+ expect(mockPassword).not.toHaveBeenCalled();
169
+ });
170
+ });
171
+
172
+ describe('playwright — no credential, task is a display label only', () => {
173
+ it('resolves image and the default test command with no task given', async () => {
174
+ await launchCommand(config, ['playwright', '--dry-run'], chalk);
175
+ expect(process.exitCode).toBeUndefined();
176
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
177
+ expect(logged).toContain('npx playwright test --reporter=line,html --trace=retain-on-failure');
178
+ expect(logged).toContain('ghcr.io/michaelmanly/badgr-agent-playwright:latest');
179
+ // The only env Badgr sets automatically is the stable HTML report dir
180
+ // (makes report output deterministic) — no credentials involved.
181
+ expect(logged).toContain('PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report');
182
+ });
183
+
184
+ it('accepts a task string but does not change the command it runs', async () => {
185
+ await launchCommand(config, ['playwright', '--dry-run', 'Test the checkout flow'], chalk);
186
+ expect(process.exitCode).toBeUndefined();
187
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
188
+ expect(logged).toContain('npx playwright test');
189
+ expect(logged).not.toContain('Test the checkout flow --');
190
+ });
191
+
192
+ it('automatically declares playwright-report/test-results as artifacts', async () => {
193
+ await launchCommand(config, ['playwright', '--dry-run'], chalk);
194
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
195
+ expect(logged).toContain('Artifacts:');
196
+ expect(logged).toContain('playwright-report, test-results');
197
+ });
198
+
199
+ it('an explicit --artifacts overrides the automatic default instead of merging with it', async () => {
200
+ await launchCommand(config, ['playwright', '--dry-run', '--artifacts', 'custom-report'], chalk);
201
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
202
+ const artifactsLine = logged.split('\n').find(l => l.includes('Artifacts:'));
203
+ expect(logged).toContain('custom-report');
204
+ expect(artifactsLine).not.toContain('playwright-report');
205
+ });
206
+
207
+ it('never requires badgr connect', async () => {
208
+ await launchCommand(config, ['playwright', '--dry-run'], chalk);
209
+ expect(process.exitCode).toBeUndefined();
210
+ });
211
+
212
+ it('applies the default max-cost like the other shorthand workloads', async () => {
213
+ await launchCommand(config, ['playwright', '--dry-run'], chalk);
214
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
215
+ expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
216
+ });
217
+ });
218
+
219
+ describe('VM class — deterministic workload → size default, --size override', () => {
220
+ it('defaults cline/claude/codex to the small VM class', async () => {
221
+ await launchCommand(config, ['cline', '--dry-run', 'Fix the checkout bug'], chalk);
222
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
223
+ expect(logged).toContain('VM class:');
224
+ expect(logged).toContain('small (2 vCPU, 4 GB RAM)');
225
+ });
226
+
227
+ it('defaults playwright to the browser VM class', async () => {
228
+ await launchCommand(config, ['playwright', '--dry-run'], chalk);
229
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
230
+ expect(logged).toContain('browser (4 vCPU, 8 GB RAM)');
231
+ });
232
+
233
+ it('--size overrides the default for an agent workload', async () => {
234
+ credentialStore.anthropic = 'sk-ant-fake';
235
+ await launchCommand(config, ['claude', '--dry-run', '--size', 'medium', 'Run the full suite'], chalk);
236
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
237
+ expect(logged).toContain('medium (4 vCPU, 8 GB RAM)');
238
+ });
239
+
240
+ it('--size overrides the default for playwright', async () => {
241
+ await launchCommand(config, ['playwright', '--dry-run', '--size', 'medium'], chalk);
242
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
243
+ expect(logged).toContain('medium (4 vCPU, 8 GB RAM)');
244
+ expect(logged).not.toContain('browser (');
245
+ });
246
+
247
+ it('rejects an unknown --size value', async () => {
248
+ await launchCommand(config, ['cline', '--dry-run', '--size', 'huge', 'Fix the checkout bug'], chalk);
249
+ expect(process.exitCode).toBe(1);
250
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
251
+ expect(logged).toContain('Unknown --size "huge"');
252
+ });
253
+
254
+ it('defaults the explicit form to the small VM class too', async () => {
255
+ await launchCommand(config, ['.', '--max-cost', '1', '--dry-run', '--', 'npm', 'test'], chalk);
256
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
257
+ expect(logged).toContain('small (2 vCPU, 4 GB RAM)');
258
+ });
259
+ });
260
+
261
+ describe('task-text safety — flags must never be reinterpreted from the task', () => {
262
+ it('a flag-shaped word inside the task reaches the agent unchanged', async () => {
263
+ await launchCommand(config, ['cline', '--dry-run', 'Fix', 'the', '--output', 'bug'], chalk);
264
+ expect(process.exitCode).toBeUndefined();
265
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
266
+ expect(logged).toContain("badgr-cline-run Fix the --output bug");
267
+ expect(logged).not.toContain('Output:');
268
+ });
269
+
270
+ it('$vars, backticks, double quotes, and newlines in the task are preserved verbatim (no embedded single quote — see entrypoint.py tests for the full shlex round-trip including one)', async () => {
271
+ const task = 'Fix the bug with $DANGER and `whoami` and "double" and\nnewline';
272
+ await launchCommand(config, ['cline', '--dry-run', task], chalk);
273
+ expect(process.exitCode).toBeUndefined();
274
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
275
+ expect(logged).toContain(task);
276
+ });
277
+
278
+ it('warns (non-blocking) when a flag-named token appears after the task instead of before it, without disabling a --dry-run that was already recognized', async () => {
279
+ await launchCommand(config, ['cline', '--dry-run', 'Fix checkout', '--max-cost', '5'], chalk);
280
+ expect(process.exitCode).toBeUndefined();
281
+ const errLogged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
282
+ expect(errLogged).toContain('appears inside the task text');
283
+ const out = console.log.mock.calls.map(c => c.join(' ')).join('\n');
284
+ expect(out).toContain("badgr-cline-run Fix checkout --max-cost 5");
285
+ // The --max-cost that got swallowed into the task must NOT apply as the real cap.
286
+ expect(out).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
287
+ });
288
+
289
+ it('flags placed before the task are parsed normally, not swallowed', async () => {
290
+ await launchCommand(config, ['cline', '--max-cost', '9', '--dry-run', 'Fix checkout'], chalk);
291
+ expect(process.exitCode).toBeUndefined();
292
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
293
+ expect(logged).toContain("badgr-cline-run Fix checkout");
294
+ expect(logged).toMatch(/Max cost:\s*\$9/);
295
+ });
296
+ });
297
+
298
+ describe('rejects --gpu (CPU-only)', () => {
299
+ it('for the agent shorthand', async () => {
300
+ await launchCommand(config, ['cline', '--gpu', 'A100', 'Fix checkout'], chalk);
301
+ expect(process.exitCode).toBe(1);
302
+ });
303
+
304
+ it('for the explicit form', async () => {
305
+ await launchCommand(config, ['.', '--gpu', 'A100', '--', 'npm', 'test'], chalk);
306
+ expect(process.exitCode).toBe(1);
307
+ });
308
+ });
309
+
310
+ describe('default max-cost', () => {
311
+ it('applies the $2 default and annotates it as a default', async () => {
312
+ await launchCommand(config, ['cline', '--dry-run', 'Fix checkout'], chalk);
313
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
314
+ expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
315
+ expect(logged).toContain('default — use --max-cost N to override');
316
+ });
317
+
318
+ it('an explicit --max-cost overrides the default', async () => {
319
+ await launchCommand(config, ['cline', '--dry-run', '--max-cost', '9', 'Fix checkout'], chalk);
320
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
321
+ expect(logged).toMatch(/Max cost:\s*\$9/);
322
+ expect(logged).not.toContain('default —');
323
+ });
324
+ });
325
+
326
+ describe('explicit form — badgr launch <source> -- <command> (advanced escape hatch)', () => {
327
+ it('rejects an empty command after --', async () => {
328
+ await launchCommand(config, ['.', '--'], chalk);
329
+ expect(process.exitCode).toBe(1);
330
+ });
331
+
332
+ it('rejects both -- <command> and --cmd together', async () => {
333
+ await launchCommand(config, ['.', '--cmd', 'python x.py', '--', 'npm', 'test'], chalk);
334
+ expect(process.exitCode).toBe(1);
335
+ });
336
+
337
+ it('requires a command via -- or --cmd', async () => {
338
+ await launchCommand(config, ['https://github.com/user/repo'], chalk);
339
+ expect(process.exitCode).toBe(1);
340
+ });
341
+
342
+ it('accepts -- passthrough and forwards a CPU dry run for an arbitrary command', async () => {
343
+ await launchCommand(config, ['.', '--dry-run', '--max-cost', '1', '--', 'npm', 'test'], chalk);
344
+ expect(process.exitCode).toBeUndefined();
345
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
346
+ expect(logged).toContain('Compute:');
347
+ expect(logged).toContain('CPU VM');
348
+ expect(logged).toContain('npm test');
349
+ });
350
+
351
+ it('accepts a quoted --cmd form', async () => {
352
+ await launchCommand(config, ['.', '--dry-run', '--cmd', 'python eval.py'], chalk);
353
+ expect(process.exitCode).toBeUndefined();
354
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
355
+ expect(logged).toContain('python eval.py');
356
+ });
357
+
358
+ it('forwards repeated --artifacts paths through to the dry-run banner', async () => {
359
+ await launchCommand(config, [
360
+ '.', '--dry-run', '--max-cost', '1',
361
+ '--artifacts', 'playwright-report', '--artifacts', 'test-results',
362
+ '--', 'npx', 'playwright', 'test',
363
+ ], chalk);
364
+ expect(process.exitCode).toBeUndefined();
365
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
366
+ expect(logged).toContain('Artifacts:');
367
+ expect(logged).toContain('playwright-report, test-results');
368
+ });
369
+
370
+ it('warns when --env carries a secret-shaped key (shell history exposure) and redacts its value', async () => {
371
+ await launchCommand(config, [
372
+ '.', '--dry-run', '--max-cost', '1',
373
+ '--env', 'ANTHROPIC_API_KEY=sk-ant-fake',
374
+ '--', 'npm', 'test',
375
+ ], chalk);
376
+ expect(process.exitCode).toBeUndefined();
377
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
378
+ expect(logged).toContain('ANTHROPIC_API_KEY=<redacted>');
379
+ expect(logged).not.toContain('sk-ant-fake');
380
+ expect(logged).toContain('shell history');
381
+ });
382
+
383
+ it('accepts a GitHub URL as the source', async () => {
384
+ await launchCommand(config, ['https://github.com/user/repo', '--dry-run', '--max-cost', '1', '--', 'python', 'narrgo.py'], chalk);
385
+ expect(process.exitCode).toBeUndefined();
386
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
387
+ expect(logged).toContain('https://github.com/user/repo');
388
+ });
389
+
390
+ it('rejects unexpected extra positional arguments after the source', async () => {
391
+ await launchCommand(config, ['.', 'extra-arg', '--dry-run', '--max-cost', '1', '--', 'npm', 'test'], chalk);
392
+ expect(process.exitCode).toBe(1);
393
+ const logged = console.error.mock.calls.map(c => c.join(' ')).join('\n');
394
+ expect(logged).toContain('extra-arg');
395
+ });
396
+
397
+ it('forwards --image, --region, and --max-runtime through to the dry-run banner', async () => {
398
+ await launchCommand(config, [
399
+ '.', '--dry-run', '--max-cost', '1',
400
+ '--image', 'ghcr.io/aibadgr/custom:latest', '--region', 'eu', '--max-runtime', '30',
401
+ '--', 'npm', 'test',
402
+ ], chalk);
403
+ expect(process.exitCode).toBeUndefined();
404
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
405
+ expect(logged).toContain('ghcr.io/aibadgr/custom:latest');
406
+ expect(logged).toContain('30min');
407
+ });
408
+
409
+ it('--no-detach is accepted without erroring on a dry run', async () => {
410
+ await launchCommand(config, ['.', '--dry-run', '--max-cost', '1', '--no-detach', '--', 'npm', 'test'], chalk);
411
+ expect(process.exitCode).toBeUndefined();
412
+ });
413
+
414
+ it('applies the default max-cost when not specified', async () => {
415
+ await launchCommand(config, ['.', '--dry-run', '--', 'npm', 'test'], chalk);
416
+ expect(process.exitCode).toBeUndefined();
417
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
418
+ expect(logged).toMatch(new RegExp(`Max cost:\\s*\\$${DEFAULT_LAUNCH_MAX_COST}\\b`));
419
+ });
420
+
421
+ describe('flag order — everything after -- is passthrough, not parsed', () => {
422
+ it('a flag placed AFTER -- is swallowed into the command, not applied as a Badgr flag', async () => {
423
+ await launchCommand(config, ['.', '--dry-run', '--', 'npm', 'test', '--max-cost', '1'], chalk);
424
+ expect(process.exitCode).toBeUndefined();
425
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
426
+ expect(logged).toContain('npm test --max-cost 1');
427
+ expect(logged).not.toMatch(/Max cost:\s*\$1\b/);
428
+ });
429
+
430
+ it('the same flag placed BEFORE -- is correctly applied as the spend cap', async () => {
431
+ await launchCommand(config, ['.', '--max-cost', '1', '--dry-run', '--', 'npm', 'test'], chalk);
432
+ expect(process.exitCode).toBeUndefined();
433
+ const logged = console.log.mock.calls.map(c => c.join(' ')).join('\n');
434
+ expect(logged).toContain('npm test');
435
+ expect(logged).not.toContain('npm test --max-cost 1');
436
+ expect(logged).toMatch(/Max cost:\s*\$1\b/);
437
+ });
438
+ });
439
+ });
440
+ });