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
@@ -53,6 +53,12 @@ export async function downCommand(config, args, chalk) {
53
53
  const runtimeHr = (stoppedAt - startedAt) / 3600;
54
54
  const finalCost = (dep.cost_per_hour || 0) * runtimeHr;
55
55
 
56
+ // dep.teardown_ok reflects whether the provider resource was actually
57
+ // confirmed gone (not just that the DELETE call didn't throw) — a 200
58
+ // response here can still carry teardown_ok: "failed" if the underlying
59
+ // instance is locked/protected or deletion couldn't be confirmed.
60
+ const teardownConfirmed = dep.teardown_ok === 'ok';
61
+
56
62
  const rcptId = generateReceiptId();
57
63
  addReceipt({
58
64
  receiptId: rcptId,
@@ -61,14 +67,19 @@ export async function downCommand(config, args, chalk) {
61
67
  gpu: dep.gpu_type,
62
68
  runtimeSeconds: Math.round(stoppedAt - startedAt),
63
69
  finalCost,
64
- status: 'terminated',
70
+ status: teardownConfirmed ? 'terminated' : 'teardown_unconfirmed',
65
71
  createdAt: new Date().toISOString(),
66
72
  });
67
73
 
68
74
  removeDeployment(idOrName);
69
75
 
70
- console.log(chalk.green('\n✓ Stopped'));
71
- console.log(chalk.green(' Billing ended\n'));
76
+ if (teardownConfirmed) {
77
+ console.log(chalk.green('\n✓ Stopped'));
78
+ console.log(chalk.green(' Billing ended\n'));
79
+ } else {
80
+ console.log(chalk.yellow('\n⚠ Stop requested but not confirmed'));
81
+ console.log(chalk.yellow(` Resource may still be billing — check \`badgr status\` and retry \`badgr down ${deploymentId}\`\n`));
82
+ }
72
83
  console.log(` ${chalk.bold('Runtime:')} ${formatRuntime(runtimeMin)}`);
73
84
  if (finalCost > 0) console.log(` ${chalk.bold('Final cost:')} $${finalCost.toFixed(4)}`);
74
85
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
@@ -117,6 +128,7 @@ async function _downAll(config, chalk, skipConfirm) {
117
128
  }
118
129
 
119
130
  let stoppedCount = 0;
131
+ let unconfirmedCount = 0;
120
132
  let totalCost = 0;
121
133
 
122
134
  for (const dep of active) {
@@ -127,8 +139,16 @@ async function _downAll(config, chalk, skipConfirm) {
127
139
  const startedAt = stopped.started_at ?? stoppedAt;
128
140
  const cost = (stopped.cost_per_hour || 0) * (stoppedAt - startedAt) / 3600;
129
141
  totalCost += cost;
130
- process.stdout.write(chalk.green(' done\n'));
131
- stoppedCount++;
142
+ // See the single-deployment path above — a 200 response only means
143
+ // deletion was requested, not confirmed.
144
+ const teardownConfirmed = stopped.teardown_ok === 'ok';
145
+ if (teardownConfirmed) {
146
+ process.stdout.write(chalk.green(' done\n'));
147
+ stoppedCount++;
148
+ } else {
149
+ process.stdout.write(chalk.yellow(' not confirmed — may still be billing\n'));
150
+ unconfirmedCount++;
151
+ }
132
152
  removeDeployment(dep.deployment_id);
133
153
  const rcptId = generateReceiptId();
134
154
  addReceipt({
@@ -138,7 +158,7 @@ async function _downAll(config, chalk, skipConfirm) {
138
158
  gpu: dep.gpu_type,
139
159
  runtimeSeconds: Math.round(stoppedAt - startedAt),
140
160
  finalCost: cost,
141
- status: 'terminated',
161
+ status: teardownConfirmed ? 'terminated' : 'teardown_unconfirmed',
142
162
  createdAt: new Date().toISOString(),
143
163
  });
144
164
  } catch (err) {
@@ -147,6 +167,9 @@ async function _downAll(config, chalk, skipConfirm) {
147
167
  }
148
168
 
149
169
  console.log(chalk.green(`\n✓ Stopped ${stoppedCount}/${active.length} deployment(s)`));
170
+ if (unconfirmedCount > 0) {
171
+ console.log(chalk.yellow(` ${unconfirmedCount} deployment(s) not confirmed stopped — may still be billing, check \`badgr status\`\n`));
172
+ }
150
173
  if (totalCost > 0) console.log(` Estimated total: $${totalCost.toFixed(4)}`);
151
174
  console.log(chalk.green(' Billing ended\n'));
152
175
  }
@@ -0,0 +1,431 @@
1
+ import { runCommand, parseRunArgs } from './run.js';
2
+ import { getCredential, setCredential, PROVIDER_ENV_KEYS } from '../credentials.js';
3
+ import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
4
+
5
+ // Phase 1 supports these coding/testing workloads via shorthand. Do not add
6
+ // more here without a real prepared image behind them — see
7
+ // docs/badgr-execution-roadmap.md. Anything else still works through the
8
+ // explicit `badgr launch <source> -- <command>` form (kept as the advanced
9
+ // escape hatch), including --image for a fully custom runtime.
10
+ //
11
+ // `cline` is the Badgr-native default: no user credential required, since
12
+ // Badgr mints a short-lived, job-scoped token pointing at its own
13
+ // OpenAI-compatible coding-model endpoint (see backend jobs_routes.py's
14
+ // `_cline_model_token` / `_build_worker_command`). `claude`/`codex` are
15
+ // bring-your-own-provider lanes — if no credential is stored yet, an
16
+ // interactive terminal prompts for one inline and stores it via `badgr
17
+ // connect`'s own storage, so a missing credential doesn't force a second,
18
+ // separate command; non-interactive contexts still hard-fail with
19
+ // instructions to run `badgr connect` first.
20
+ //
21
+ // `cline`'s real npm CLI binary is `clite`, with no `task` subcommand (the
22
+ // prompt is a positional arg) — verified against the published @cline/cli
23
+ // package. Its build command actually invokes badgr-cline-run, a wrapper
24
+ // script baked into the image (images/badgr-agent-cline/badgr-cline-run)
25
+ // that runs `clite auth openai -b <OPENAI_BASE_URL> ...` first, since a
26
+ // custom OpenAI-compatible endpoint can only be configured that way, then
27
+ // execs `clite "<task>"` — see that script for why a single BADGR_CMD
28
+ // invocation can't just chain the two calls itself. `codex` similarly needs
29
+ // badgr-codex-run (login + --skip-git-repo-check) — see
30
+ // images/badgr-agent-codex/badgr-codex-run.
31
+ //
32
+ // Images live at ghcr.io/michaelmanly/badgr-agent-* — a live smoke test found
33
+ // the previously-hardcoded ghcr.io/aibadgr/* referenced a GitHub org that
34
+ // doesn't exist (confirmed via the GitHub API: 404), so no agent image could
35
+ // ever have been pulled by any of these launches. Override with
36
+ // BADGR_AGENT_IMAGE_<NAME> (or --image) once these move to a permanent home.
37
+ //
38
+ // buildCmd returns an argument array — never a shell string. The task is
39
+ // always exactly one array element, so it reaches the agent unchanged
40
+ // regardless of spaces, quotes, punctuation, or shell metacharacters: there
41
+ // is no shell-string layer left to round-trip it through (see
42
+ // command_argv on the backend and BADGR_COMMAND_JSON in
43
+ // images/badgr-job-runner/entrypoint.py, which subprocess.run executes
44
+ // directly with shell=False — no shlex.split() involved for this path).
45
+ const AGENT_WORKLOADS = {
46
+ cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task], provider: null },
47
+ claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['claude', '-p', task], provider: 'anthropic' },
48
+ codex: { image: process.env.BADGR_AGENT_IMAGE_CODEX || 'ghcr.io/michaelmanly/badgr-agent-codex:latest', buildCmd: task => ['badgr-codex-run', task], provider: 'openai' },
49
+ };
50
+
51
+ // Non-agent workloads: no LLM credential, and the task string (if given) is
52
+ // a display label only — Playwright has no natural-language interface, so
53
+ // unlike the agent workloads above, the task text does not change what
54
+ // command actually runs. `badgr launch playwright` (no task) is equally
55
+ // valid. Declares its own report/results dirs as artifacts automatically
56
+ // unless the user already passed --artifacts explicitly.
57
+ const DIRECT_WORKLOADS = {
58
+ playwright: {
59
+ image: process.env.BADGR_AGENT_IMAGE_PLAYWRIGHT || 'ghcr.io/michaelmanly/badgr-agent-playwright:latest',
60
+ // Explicit reporter + CLI-forced trace mode make output deterministic —
61
+ // a successful test run does not by itself guarantee a report or traces
62
+ // exist unless something asks for them. --reporter=line,html always
63
+ // writes an HTML report (to PLAYWRIGHT_HTML_OUTPUT_DIR, set via env
64
+ // below); --trace is a real Playwright CLI override (unlike
65
+ // screenshot/video, which are use:{} config-only in the target repo's
66
+ // own playwright.config.ts — not something Badgr can force without
67
+ // editing the user's own code, so those are left alone).
68
+ buildCmd: () => ['npx', 'playwright', 'test', '--reporter=line,html', '--trace=retain-on-failure'],
69
+ autoArtifacts: ['playwright-report', 'test-results'],
70
+ autoEnv: ['PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report'],
71
+ },
72
+ };
73
+
74
+ export const KNOWN_WORKLOADS = new Set([...Object.keys(AGENT_WORKLOADS), ...Object.keys(DIRECT_WORKLOADS)]);
75
+
76
+ // No `--max-cost` required for the frictionless workload flow — this default
77
+ // caps spend automatically instead of forcing a flag or an interactive prompt.
78
+ export const DEFAULT_LAUNCH_MAX_COST = 2;
79
+
80
+ // Human-readable display only (dry-run/live banners) — never re-parsed by
81
+ // anything. The real transport is the argv array itself (see buildCmd
82
+ // above and opts.cmdArgv in run.js), not this string.
83
+ function displayCmd(argv) {
84
+ return argv.join(' ');
85
+ }
86
+
87
+ // Flags recognized when parsing `badgr launch <workload> [flags] <task>`.
88
+ // Flags must appear BEFORE the task text; the first token that isn't one of
89
+ // these ends flag-parsing and everything from there to the end (verbatim,
90
+ // space-joined) is the task — it is never re-parsed as a flag. This is what
91
+ // guarantees a task like "Fix the --output bug" reaches the agent whole
92
+ // instead of having "--output bug" silently stolen as a Badgr flag.
93
+ const _BOOL_FLAGS = {
94
+ '--dry-run': 'dryRun', '--detach': 'detach', '--no-detach': 'noDetach',
95
+ '--no-fallback': 'noFallback', '--retry-safe': 'retrySafe',
96
+ };
97
+ const _VALUE_FLAGS = {
98
+ '--image': 'image', '--count': 'count', '--region': 'region', '--tier': 'tier',
99
+ '--max-price': 'maxPrice', '--name': 'name', '--fallback': 'fallback',
100
+ '--max-runtime': 'maxRuntime', '--max-cost': 'maxCost', '--save': 'save',
101
+ '--workspace': 'workspace', '--output': 'output', '--checkpoint': 'checkpoint',
102
+ '--resume-cmd': 'resumeCmd', '--size': 'size',
103
+ };
104
+ const _REPEATABLE_FLAGS = { '--env': 'env', '--artifacts': 'artifacts' };
105
+
106
+ const _PASSTHROUGH_FLAGS = [
107
+ ['--image', 'image'], ['--count', 'count'], ['--region', 'region'],
108
+ ['--tier', 'tier'], ['--max-price', 'maxPrice'], ['--name', 'name'],
109
+ ['--fallback', 'fallback'], ['--max-runtime', 'maxRuntime'],
110
+ ['--max-cost', 'maxCost'], ['--dry-run', 'dryRun'], ['--save', 'save'],
111
+ ['--workspace', 'workspace'], ['--output', 'output'],
112
+ ['--checkpoint', 'checkpoint'], ['--retry-safe', 'retrySafe'],
113
+ ['--resume-cmd', 'resumeCmd'], ['--no-fallback', 'noFallback'],
114
+ ['--detach', 'detach'], ['--no-detach', 'noDetach'], ['--size', 'size'],
115
+ ];
116
+
117
+ // Re-serializes parsed `flags` into a flat argv for `runCommand` (which
118
+ // reparses it via parseRunArgs) — shared by both the agent-workload
119
+ // shorthand and the explicit `<source> -- <command>` form below.
120
+ function buildTranslatedArgs(source, flags) {
121
+ const translatedArgs = [source];
122
+ if (flags.cmd) translatedArgs.push('--cmd', flags.cmd);
123
+ if (flags.agentName) translatedArgs.push('--agent-name', flags.agentName);
124
+ for (const [flag, key] of _PASSTHROUGH_FLAGS) {
125
+ const value = flags[key];
126
+ if (value === undefined) continue;
127
+ if (value === true) translatedArgs.push(flag);
128
+ else translatedArgs.push(flag, String(value));
129
+ }
130
+ if (flags.env) for (const kv of flags.env) translatedArgs.push('--env', kv);
131
+ if (flags.artifacts) for (const p of flags.artifacts) translatedArgs.push('--artifacts', p);
132
+ return translatedArgs;
133
+ }
134
+
135
+ function parseWorkloadShorthandArgs(rawArgs) {
136
+ const flags = {};
137
+ let i = 0;
138
+ while (i < rawArgs.length) {
139
+ const tok = rawArgs[i];
140
+ if (tok === '--gpu') return { flags, task: null, gpuRejected: true };
141
+ if (tok in _BOOL_FLAGS) { flags[_BOOL_FLAGS[tok]] = true; i += 1; continue; }
142
+ if (tok in _VALUE_FLAGS) { flags[_VALUE_FLAGS[tok]] = rawArgs[i + 1]; i += 2; continue; }
143
+ if (tok in _REPEATABLE_FLAGS) {
144
+ const key = _REPEATABLE_FLAGS[tok];
145
+ if (!flags[key]) flags[key] = [];
146
+ flags[key].push(rawArgs[i + 1]);
147
+ i += 2;
148
+ continue;
149
+ }
150
+ break; // first non-flag token — task starts here, verbatim, to the end
151
+ }
152
+ const task = rawArgs.slice(i).join(' ').trim();
153
+ return { flags, task, gpuRejected: false };
154
+ }
155
+
156
+ const _ALL_FLAG_NAMES = new Set([
157
+ ...Object.keys(_BOOL_FLAGS), ...Object.keys(_VALUE_FLAGS), ...Object.keys(_REPEATABLE_FLAGS), '--gpu',
158
+ ]);
159
+
160
+ // The task must never be reinterpreted as a Badgr flag (see
161
+ // parseWorkloadShorthandArgs above) — but a flag typed AFTER the task
162
+ // instead of before it (e.g. `badgr launch cline "task" --dry-run`) silently
163
+ // becomes part of the task text with no error, which can launch for real
164
+ // when the user meant a dry run. This is a non-blocking heads-up, not a
165
+ // block: a genuine task might legitimately contain a token that happens to
166
+ // match a flag name, and the spec requires the task always reach the agent
167
+ // unchanged — so we warn, but never alter or drop anything from it.
168
+ function warnIfTaskMayHaveSwallowedAFlag(task, chalk) {
169
+ const tokens = task.split(' ');
170
+ const suspect = tokens.filter(t => _ALL_FLAG_NAMES.has(t));
171
+ if (suspect.length === 0) return;
172
+ console.error(chalk.yellow(`\n ⚠ "${suspect.join(', ')}" appears inside the task text, not before it — it was sent to the agent as part of the task, not applied as a Badgr flag.`));
173
+ console.error(chalk.dim(' Badgr flags must come before the task: badgr launch <agent> [--flags] "<task>"\n'));
174
+ }
175
+
176
+ /**
177
+ * badgr launch cline "<task>" — Badgr-hosted model, no credential needed
178
+ * badgr launch claude "<task>" — prompts inline for an Anthropic key if not connected
179
+ * badgr launch codex "<task>" — prompts inline for an OpenAI key if not connected
180
+ * badgr launch playwright ["<task>"] — no credential; task is a display label only
181
+ * badgr launch <source> -- <command> — explicit form / advanced escape hatch
182
+ * badgr launch <source> --cmd "<command>"
183
+ *
184
+ * The Phase 1 CPU entry point: runs a coding/testing workload (or, via the
185
+ * explicit form, any other command) on a disposable cloud CPU VM, reusing
186
+ * `badgr run`'s upload/fallback/receipts plumbing under the hood.
187
+ */
188
+ export async function launchCommand(config, args, chalk) {
189
+ const firstArg = args[0] ?? null;
190
+
191
+ if (firstArg === null) {
192
+ console.error(chalk.red('\nUsage:'));
193
+ console.error(chalk.dim(' badgr launch cline "Fix the checkout bug"'));
194
+ console.error(chalk.dim(' badgr launch claude "Fix the checkout bug"'));
195
+ console.error(chalk.dim(' badgr launch codex "Fix the checkout bug"'));
196
+ console.error(chalk.dim(' badgr launch playwright "Test the checkout flow"'));
197
+ console.error(chalk.dim(' badgr launch . --max-cost 1 -- npm test (explicit escape hatch)'));
198
+ console.error('');
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ // Note: a local directory literally named e.g. "cline" or "playwright"
204
+ // (without a `./` prefix) is shadowed by the shorthand below — use
205
+ // `./cline` explicitly to launch such a directory as a source instead.
206
+ if (firstArg in AGENT_WORKLOADS) {
207
+ return launchAgentWorkload(config, firstArg, args.slice(1), chalk);
208
+ }
209
+ if (firstArg in DIRECT_WORKLOADS) {
210
+ return launchDirectWorkload(config, firstArg, args.slice(1), chalk);
211
+ }
212
+
213
+ return launchExplicitForm(config, args, chalk);
214
+ }
215
+
216
+ // Validates an explicit `--size`, or applies the deterministic
217
+ // workload → VM class default (see spec.js's vmClassForWorkload) when the
218
+ // user didn't give one. Returns false (after printing an error) for an
219
+ // unknown size, so the caller can bail out before calling the API.
220
+ function resolveVmSize(flags, workloadName, chalk) {
221
+ if (flags.size === undefined) {
222
+ flags.size = vmClassForWorkload(workloadName);
223
+ return true;
224
+ }
225
+ if (!LAUNCH_VM_SIZES.includes(flags.size)) {
226
+ console.error(chalk.red(`\n ✗ Unknown --size "${flags.size}". Must be one of: ${LAUNCH_VM_SIZES.join(', ')}\n`));
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+
232
+ async function resolveMissingCredential(provider, chalk) {
233
+ const providerLabel = provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
234
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
235
+ console.error(chalk.red(`\n ✗ No ${provider} credential found.\n`));
236
+ console.error(chalk.dim(` Run: badgr connect ${provider}\n`));
237
+ return null;
238
+ }
239
+
240
+ console.log(chalk.yellow(`\n ${providerLabel} is not connected.`));
241
+ console.log(chalk.dim(` ${providerLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${providerLabel} account for model usage.`));
242
+ console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
243
+
244
+ try {
245
+ const { password } = await import('@inquirer/prompts');
246
+ const key = await password({
247
+ message: `Enter your ${providerLabel} API key:`,
248
+ validate: v => v.trim() ? true : 'API key is required',
249
+ });
250
+ const trimmed = key.trim();
251
+ if (!trimmed) return null;
252
+ setCredential(provider, trimmed);
253
+ console.log(chalk.green(` ✓ ${providerLabel} connected\n`));
254
+ return trimmed;
255
+ } catch {
256
+ // Ctrl+C or a non-interactive stdin that lied about isTTY.
257
+ return null;
258
+ }
259
+ }
260
+
261
+ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
262
+ const { flags, task, gpuRejected } = parseWorkloadShorthandArgs(rawArgs);
263
+
264
+ if (gpuRejected) {
265
+ console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+
270
+ if (!task) {
271
+ console.error(chalk.red(`\n ✗ ${agentName} needs a task: badgr launch ${agentName} "Fix the checkout bug"\n`));
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+
276
+ warnIfTaskMayHaveSwallowedAFlag(task, chalk);
277
+
278
+ if (!resolveVmSize(flags, agentName, chalk)) {
279
+ process.exitCode = 1;
280
+ return;
281
+ }
282
+
283
+ const spec = AGENT_WORKLOADS[agentName];
284
+
285
+ if (spec.provider) {
286
+ const envKey = PROVIDER_ENV_KEYS[spec.provider];
287
+ const userSuppliedKey = flags.env?.some(kv => kv.startsWith(`${envKey}=`));
288
+ let credential = userSuppliedKey ? null : getCredential(spec.provider);
289
+ if (!credential && !userSuppliedKey) {
290
+ // Missing credential no longer forces a separate `badgr connect` +
291
+ // rerun in an interactive terminal — prompt inline and continue the
292
+ // same launch. Non-interactive contexts still hard-fail as before.
293
+ credential = await resolveMissingCredential(spec.provider, chalk);
294
+ if (!credential) {
295
+ process.exitCode = 1;
296
+ return;
297
+ }
298
+ }
299
+ // An explicit --env always wins over the stored credential — never
300
+ // append the injected default alongside a user-supplied value for the
301
+ // same key (the last --env wins downstream, so appending here would
302
+ // silently override what the user typed).
303
+ if (credential && !userSuppliedKey) {
304
+ if (!flags.env) flags.env = [];
305
+ flags.env.push(`${envKey}=${credential}`);
306
+ }
307
+ }
308
+ // cline has no `provider` — Badgr mints and injects its own short-lived
309
+ // job-scoped model token server-side (see backend jobs_routes.py); the
310
+ // CLI never handles or displays that token.
311
+
312
+ const cmdArgv = spec.buildCmd(task);
313
+ flags.cmd = displayCmd(cmdArgv);
314
+ if (!flags.image) flags.image = spec.image;
315
+ flags.agentName = agentName;
316
+
317
+ if (flags.maxCost === undefined) {
318
+ flags.maxCost = DEFAULT_LAUNCH_MAX_COST;
319
+ flags.maxCostIsDefault = true;
320
+ }
321
+
322
+ const translatedArgs = buildTranslatedArgs('.', flags);
323
+ return runCommand(config, translatedArgs, chalk, { isLaunch: true, maxCostIsDefault: flags.maxCostIsDefault === true, cmdArgv });
324
+ }
325
+
326
+ async function launchDirectWorkload(config, workloadName, rawArgs, chalk) {
327
+ const { flags, gpuRejected } = parseWorkloadShorthandArgs(rawArgs);
328
+ // The task text (if any) is a display label only for these workloads —
329
+ // there's no natural-language interface to hand it to, unlike the agent
330
+ // workloads above. Read but intentionally not passed to buildCmd().
331
+
332
+ if (gpuRejected) {
333
+ console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+
338
+ if (!resolveVmSize(flags, workloadName, chalk)) {
339
+ process.exitCode = 1;
340
+ return;
341
+ }
342
+
343
+ const spec = DIRECT_WORKLOADS[workloadName];
344
+ const cmdArgv = spec.buildCmd();
345
+ flags.cmd = displayCmd(cmdArgv);
346
+ if (!flags.image) flags.image = spec.image;
347
+ if (!flags.artifacts && spec.autoArtifacts) flags.artifacts = [...spec.autoArtifacts];
348
+ if (spec.autoEnv) {
349
+ if (!flags.env) flags.env = [];
350
+ const existingKeys = new Set(flags.env.map(kv => kv.split('=')[0]));
351
+ for (const kv of spec.autoEnv) {
352
+ if (!existingKeys.has(kv.split('=')[0])) flags.env.push(kv);
353
+ }
354
+ }
355
+ flags.agentName = workloadName;
356
+
357
+ if (flags.maxCost === undefined) {
358
+ flags.maxCost = DEFAULT_LAUNCH_MAX_COST;
359
+ flags.maxCostIsDefault = true;
360
+ }
361
+
362
+ const translatedArgs = buildTranslatedArgs('.', flags);
363
+ return runCommand(config, translatedArgs, chalk, { isLaunch: true, maxCostIsDefault: flags.maxCostIsDefault === true, cmdArgv });
364
+ }
365
+
366
+ /**
367
+ * badgr launch <source> -- <command> (advanced escape hatch)
368
+ * badgr launch <source> --cmd "<command>"
369
+ */
370
+ async function launchExplicitForm(config, args, chalk) {
371
+ const { flags, positional, commandArgv } = parseRunArgs(args);
372
+
373
+ if (positional.length === 0 && commandArgv === null) {
374
+ console.error(chalk.red('\nUsage:'));
375
+ console.error(chalk.dim(' badgr launch . --max-cost 1 -- npm test'));
376
+ console.error(chalk.dim(' badgr launch https://github.com/user/repo --max-cost 1 -- python eval.py'));
377
+ console.error('');
378
+ process.exitCode = 1;
379
+ return;
380
+ }
381
+
382
+ if (flags.gpu) {
383
+ console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
384
+ console.error(chalk.dim(' Use `badgr run` for GPU jobs.\n'));
385
+ process.exitCode = 1;
386
+ return;
387
+ }
388
+
389
+ let cmdArgv;
390
+ if (commandArgv !== null) {
391
+ if (commandArgv.length === 0) {
392
+ console.error(chalk.red('\n ✗ No command after --. Provide a command to launch.\n'));
393
+ console.error(chalk.dim(' Example: badgr launch . -- npm test\n'));
394
+ process.exitCode = 1;
395
+ return;
396
+ }
397
+ if (flags.cmd) {
398
+ console.error(chalk.red('\n ✗ Use either `-- <command>` or --cmd, not both.\n'));
399
+ process.exitCode = 1;
400
+ return;
401
+ }
402
+ // Pass the array straight through as the real transport — joining it
403
+ // into a string here and sending it via the legacy --cmd path would
404
+ // throw away the exact bug this form exists to avoid (a multi-word
405
+ // argument like a quoted task getting torn apart on every space).
406
+ cmdArgv = commandArgv;
407
+ flags.cmd = displayCmd(commandArgv);
408
+ }
409
+
410
+ if (flags.maxCost === undefined) {
411
+ flags.maxCost = DEFAULT_LAUNCH_MAX_COST;
412
+ flags.maxCostIsDefault = true;
413
+ }
414
+
415
+ if (!resolveVmSize(flags, null, chalk)) {
416
+ process.exitCode = 1;
417
+ return;
418
+ }
419
+
420
+ const source = positional[0];
421
+ const rest = positional.slice(1);
422
+ if (rest.length > 0) {
423
+ console.error(chalk.red(`\n ✗ Unexpected extra arguments: ${rest.join(', ')}\n`));
424
+ console.error(chalk.dim(' Put the command after `--`, e.g.: badgr launch . -- npm test\n'));
425
+ process.exitCode = 1;
426
+ return;
427
+ }
428
+
429
+ const translatedArgs = buildTranslatedArgs(source, flags);
430
+ return runCommand(config, translatedArgs, chalk, { isLaunch: true, maxCostIsDefault: flags.maxCostIsDefault === true, cmdArgv });
431
+ }
@@ -0,0 +1,137 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { requireApiKey } from '../config.js';
6
+ import { downloadAndExtractArtifact } from '../artifactDownload.js';
7
+
8
+ function runGit(args, options = {}) {
9
+ return spawnSync('git', args, { encoding: 'utf8', ...options });
10
+ }
11
+
12
+ export function parsePullArgs(args) {
13
+ const flags = { branch: false, diffOnly: false, yes: false };
14
+ const positional = [];
15
+ for (let i = 0; i < args.length; i++) {
16
+ const a = args[i];
17
+ if (a === '--branch') { flags.branch = true; continue; }
18
+ if (a === '--diff-only') { flags.diffOnly = true; continue; }
19
+ if (a === '--yes' || a === '-y') { flags.yes = true; continue; }
20
+ positional.push(a);
21
+ }
22
+ return { deploymentId: positional[0], flags };
23
+ }
24
+
25
+ export function changedFilesFromPatch(patchText) {
26
+ const files = new Set();
27
+ for (const line of patchText.split('\n')) {
28
+ if (line.startsWith('+++ b/')) files.add(line.slice('+++ b/'.length));
29
+ if (line.startsWith('--- a/')) files.add(line.slice('--- a/'.length));
30
+ }
31
+ files.delete('/dev/null');
32
+ return [...files].sort();
33
+ }
34
+
35
+ export function localChangedFiles(git = runGit) {
36
+ const res = git(['status', '--porcelain']);
37
+ if (res.status !== 0) throw new Error(res.stderr || 'git status failed');
38
+ // Porcelain lines are a fixed-width "XY " status prefix (2 status chars,
39
+ // often a literal space, + 1 separator space) followed by the path —
40
+ // trim()ing the line first eats that leading status space and shifts the
41
+ // slice(3), corrupting the filename. Only strip a trailing \r/\n.
42
+ return res.stdout
43
+ .split('\n')
44
+ .map(l => l.replace(/\r$/, ''))
45
+ .filter(l => l.length > 0)
46
+ .map(l => l.slice(3).split(' -> ').pop())
47
+ .filter(Boolean)
48
+ .sort();
49
+ }
50
+
51
+ export function intersectFiles(a, b) {
52
+ const right = new Set(b);
53
+ return a.filter(x => right.has(x));
54
+ }
55
+
56
+ function findPatchFile(dir) {
57
+ const candidates = [];
58
+ function walk(p) {
59
+ for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
60
+ const full = path.join(p, entry.name);
61
+ if (entry.isDirectory()) walk(full);
62
+ else if (entry.name.endsWith('.patch')) candidates.push(full);
63
+ }
64
+ }
65
+ walk(dir);
66
+ return candidates.find(p => path.basename(p) === 'badgr-agent.patch') || candidates[0] || null;
67
+ }
68
+
69
+ export async function pullCommand(config, args, chalk) {
70
+ requireApiKey(config);
71
+ const { deploymentId, flags } = parsePullArgs(args);
72
+ if (!deploymentId) {
73
+ console.error(chalk.red('\n Usage: badgr pull <deployment-id> [--diff-only|--branch|--yes]\n'));
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+
78
+ const inside = runGit(['rev-parse', '--is-inside-work-tree']);
79
+ if (inside.status !== 0 || inside.stdout.trim() !== 'true') {
80
+ console.error(chalk.red('\n ✗ badgr pull must be run inside a git worktree.\n'));
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `badgr-pull-${deploymentId}-`));
86
+ try {
87
+ await downloadAndExtractArtifact(config, deploymentId, tmp);
88
+ const patchFile = findPatchFile(tmp);
89
+ if (!patchFile) {
90
+ // Not every run changes code — a test/eval command with --artifacts
91
+ // paths (or no output capture at all) has nothing for `pull` to apply.
92
+ console.log(chalk.dim(`\n No code-patch artifact for ${deploymentId} — nothing to apply.`));
93
+ console.log(chalk.dim(' This is normal for a run that only produced logs or --artifacts output (test reports, screenshots, etc).'));
94
+ console.log(chalk.dim(` Run \`badgr artifacts ${deploymentId}\` to download any non-patch output instead.\n`));
95
+ return;
96
+ }
97
+ const patch = fs.readFileSync(patchFile, 'utf8');
98
+ const cloudFiles = changedFilesFromPatch(patch);
99
+ const localFiles = localChangedFiles();
100
+ const conflicts = intersectFiles(cloudFiles, localFiles);
101
+
102
+ if (flags.diffOnly) {
103
+ process.stdout.write(patch);
104
+ return;
105
+ }
106
+
107
+ if (conflicts.length > 0 && !flags.branch) {
108
+ console.error(chalk.yellow(`\n Cloud changes touch ${cloudFiles.length} file(s); ${conflicts.length} conflict with local edits.`));
109
+ for (const f of conflicts) console.error(chalk.yellow(` - ${f}`));
110
+ console.error(chalk.dim('\n No files were changed. Re-run with --branch to apply on a new local branch, or --diff-only to inspect.\n'));
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+
115
+ if (flags.branch) {
116
+ const branch = `badgr/${deploymentId}`;
117
+ const checkout = runGit(['checkout', '-b', branch]);
118
+ if (checkout.status !== 0) throw new Error(checkout.stderr || `could not create branch ${branch}`);
119
+ console.log(chalk.dim(` Created branch ${branch}`));
120
+ }
121
+
122
+ const check = runGit(['apply', '--check', patchFile]);
123
+ if (check.status !== 0) throw new Error(check.stderr || 'patch does not apply cleanly');
124
+ const apply = runGit(['apply', patchFile]);
125
+ if (apply.status !== 0) throw new Error(apply.stderr || 'patch apply failed');
126
+
127
+ console.log(chalk.green(`\n Applied cloud changes from ${deploymentId}.`));
128
+ console.log(` Files changed: ${cloudFiles.length}`);
129
+ for (const f of cloudFiles) console.log(` - ${f}`);
130
+ console.log();
131
+ } catch (err) {
132
+ console.error(chalk.red(`\n ✗ Could not pull ${deploymentId}: ${err.message}\n`));
133
+ process.exitCode = 1;
134
+ } finally {
135
+ fs.rmSync(tmp, { recursive: true, force: true });
136
+ }
137
+ }