badgr-cli 1.1.2 → 1.1.3

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/README.md CHANGED
@@ -103,13 +103,13 @@ Everything before `--` is a `badgr launch` flag; everything after `--` is passed
103
103
  | `--region US\|EU\|AU` | — | Region preference |
104
104
  | `--size small\|medium\|browser` | per-workload default | VM class override |
105
105
 
106
- ### `badgr task` — a labeled launch
106
+ ### `badgr job` — a tracked coding-agent job
107
107
 
108
108
  ```bash
109
- badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium
109
+ badgr job cline "Run the Chromium tests and tell me what failed" --check "npm run test:chromium" --max-cost 1
110
110
  ```
111
111
 
112
- A thin wrapper over `badgr launch . -- <command>` the description is printed for the human and otherwise discarded; the receipt and `badgr status` still key off the deployment ID like any other launch.
112
+ Submits via `POST /v1/jobs` (`type: "agent"`) the same Jobs API used by every other job type. Requires `--check <command>` (verifies success); tracked at `/jobs` with a `job_id`, status, logs, output, cost, and time.
113
113
 
114
114
  ---
115
115
 
@@ -185,7 +185,7 @@ test
185
185
  | `badgr doctor` | Diagnose a GPU workload failure — read-only, no login needed |
186
186
  | `badgr run <command>` | Run a one-off GPU job (any container command) |
187
187
  | `badgr launch cline\|claude\|codex\|playwright "<task>"` | Run a coding/testing agent on a CPU VM — image + command auto-selected |
188
- | `badgr task "<desc>" -- <command>` | Label + launch thin wrapper over `badgr launch . -- <command>` |
188
+ | `badgr job <agent> "<instruction>" --check "<cmd>"` | Tracked coding-agent job via `POST /v1/jobs` (type: agent) |
189
189
  | `badgr serve <model>` | Start a persistent OpenAI-compatible endpoint |
190
190
  | `badgr status` | Show what's running and what's billing |
191
191
  | `badgr logs <id>` | Fetch log output from a deployment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/badgr.js CHANGED
@@ -9,7 +9,7 @@ import { logsCommand } from './commands/logs.js';
9
9
  import { receiptsCommand } from './commands/receipts.js';
10
10
  import { runCommand } from './commands/run.js';
11
11
  import { launchCommand } from './commands/launch.js';
12
- import { taskCommand } from './commands/task.js';
12
+ import { jobCommand } from './commands/job.js';
13
13
  import { artifactsCommand } from './commands/artifacts.js';
14
14
  import { serveCommand } from './commands/serve.js';
15
15
  import { modelsCommand } from './commands/models.js';
@@ -31,6 +31,7 @@ import { rerunCommand } from './commands/rerun.js';
31
31
  import { heartbeatCommand } from './commands/heartbeat.js';
32
32
  import { pullCommand } from './commands/pull.js';
33
33
  import { doctorCommand } from './commands/doctor.js';
34
+ import { diagnoseCommand } from './commands/diagnose.js';
34
35
  import { connectCommand } from './commands/connect.js';
35
36
 
36
37
  const HELP = `
@@ -43,10 +44,11 @@ ${chalk.bold('COMMANDS')}
43
44
  ${chalk.cyan('badgr run <command>')} Run a one-off GPU job
44
45
  ${chalk.cyan('badgr launch cline|claude|codex|playwright "<task>"')} Run a coding/testing workload on a CPU VM — image + command auto-selected
45
46
  ${chalk.cyan('badgr launch <source> -- <command>')} Advanced escape hatch: run any other command on a CPU VM
46
- ${chalk.cyan('badgr task "<desc>" -- <command>')} Label + launch thin wrapper over badgr launch . -- <command>
47
+ ${chalk.cyan('badgr job <agent> "<instruction>" --check "<command>"')} Bounded coding-agent job, tracked at /jobs (POST /v1/jobs, type: agent)
47
48
  ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
48
49
  ${chalk.cyan('badgr serve openwebui')} Serve Open WebUI — chat UI, connects to a model endpoint
49
- ${chalk.cyan('badgr doctor')} Diagnose why a GPU workload is likely failing (read-only, no login needed)
50
+ ${chalk.cyan('badgr diagnose "<input>"')} Diagnose any GPU issue for free GitHub issue, Docker image, log, repo, or text
51
+ ${chalk.cyan('badgr doctor')} Local GPU / model-fit diagnosis (read-only, no login needed)
50
52
  ${chalk.cyan('badgr status')} Show what's running and what's billing
51
53
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
52
54
  ${chalk.cyan('badgr pull <id>')} Safely pull cloud-agent patch artifacts
@@ -108,10 +110,18 @@ ${chalk.bold('EXAMPLES')}
108
110
  badgr launch https://github.com/user/repo --max-cost 1 -- python narrgo.py
109
111
  badgr artifacts <id>
110
112
 
111
- ${chalk.dim('# badgr task is a thin label wrapper over badgr launch . -- <command>:')}
112
- badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium
113
+ ${chalk.dim('# badgr job bounded coding-agent job with a pass/fail check, tracked at /jobs:')}
114
+ badgr job cline "Run the Chromium tests and tell me what failed" --check "npm run test:chromium" --max-cost 1
113
115
 
114
- ${chalk.dim('# Diagnose why a GPU workload is likely failing (local, read-only, no login):')}
116
+ ${chalk.dim('# Diagnose any GPU issue for free (no login needed):')}
117
+ badgr diagnose "https://github.com/org/repo/issues/123"
118
+ badgr diagnose ./error.log
119
+ badgr diagnose --docker ajayrajtp/vllm_gemma412b:latest
120
+ badgr diagnose --repo https://github.com/org/repo
121
+ badgr diagnose --comfyui workflow.json
122
+ badgr diagnose "https://github.com/org/repo/issues/123" --approve ${chalk.dim('# approve a capped smoke test')}
123
+
124
+ ${chalk.dim('# Local model-fit and workload diagnosis (no network):')}
115
125
  badgr doctor
116
126
  badgr doctor --model Qwen/Qwen2.5-7B-Instruct --serve
117
127
  badgr doctor --logs ./vllm.log
@@ -195,6 +205,10 @@ ${chalk.bold('badgr run OPTIONS')}
195
205
  --no-gpu Run on a CPU-only VM — no GPU is provisioned (conflicts with --gpu/--gpu-memory)
196
206
  --tier 1 Managed provider routing (default)
197
207
  --tier 2 Marketplace provider routing, lower-cost options
208
+ --smoke Cheapest compatible provider for a local/dev test run — defaults: $0.25
209
+ max cost, 10min max runtime (override with --max-cost/--max-runtime).
210
+ Always: one attempt, mandatory teardown, no --workspace.
211
+ Same as setting BADGR_DEV_CHEAPEST=1. An explicit --tier overrides this.
198
212
  --image <image> Docker image (default: python:3.11-slim)
199
213
  --env KEY=VALUE Set an environment variable (repeatable)
200
214
  --count <n> Number of GPUs (default: 1)
@@ -270,7 +284,7 @@ async function main() {
270
284
  case 'detect': return detectCommand(config, rest, chalk);
271
285
  case 'run': return runCommand(config, rest, chalk);
272
286
  case 'launch': return launchCommand(config, rest, chalk);
273
- case 'task': return taskCommand(config, rest, chalk);
287
+ case 'job': return jobCommand(config, rest, chalk);
274
288
  case 'artifacts': return artifactsCommand(config, rest, chalk);
275
289
  case 'pull': return pullCommand(config, rest, chalk);
276
290
  case 'serve': return serveCommand(config, rest, chalk);
@@ -280,7 +294,9 @@ async function main() {
280
294
  case 'restart': return restartCommand(config, rest, chalk);
281
295
  case 'rerun': return rerunCommand(config, rest, chalk);
282
296
  case 'heartbeat': return heartbeatCommand(config, rest, chalk);
283
- case 'doctor': return doctorCommand(config, rest, chalk);
297
+ case 'diagnose': return diagnoseCommand(config, rest, chalk);
298
+ case 'run-issue': return diagnoseCommand(config, rest, chalk); // alias — matches the /run-issue web flow name
299
+ case 'doctor': return doctorCommand(config, rest, chalk);
284
300
  case 'receipts': return receiptsCommand(config, rest, chalk);
285
301
  case 'models': return modelsCommand(config, chalk);
286
302
  case 'capacity': return capacityCommand(config, rest, chalk);
@@ -7,7 +7,7 @@ badgr billing — manage your AI Badgr balance
7
7
 
8
8
  COMMANDS
9
9
  badgr billing status Show current balance
10
- badgr billing add <amount> Open checkout to add balance (minimum $10)
10
+ badgr billing add <amount> Open checkout to add balance (minimum $5)
11
11
 
12
12
  EXAMPLES
13
13
  badgr billing status
@@ -49,8 +49,8 @@ async function billingStatus(config, chalk) {
49
49
  async function billingAdd(config, amount, chalk) {
50
50
  requireApiKey(config);
51
51
  const amountInt = parseInt(amount, 10);
52
- if (!amountInt || amountInt < 10) {
53
- console.error(chalk.red(' Minimum top-up is $10. Example: badgr billing add 10'));
52
+ if (!amountInt || amountInt < 5) {
53
+ console.error(chalk.red(' Minimum top-up is $5. Example: badgr billing add 5'));
54
54
  process.exit(1);
55
55
  }
56
56
 
@@ -79,5 +79,8 @@ export async function connectCommand(args, chalk, deps = {}) {
79
79
  setCredential(provider, key);
80
80
  console.log(chalk.green(`\n ✓ ${alreadyConnected ? 'Updated' : 'Connected'} ${provider}`));
81
81
  console.log(chalk.dim(` Stored in ${CREDENTIALS_FILE} (owner-only file permissions)`));
82
- console.log(chalk.dim(` Used automatically by: badgr launch ${provider === 'anthropic' ? 'claude' : provider === 'openai' ? 'codex' : provider} "<task>"\n`));
82
+ const usageHint = provider === 'anthropic' ? 'badgr launch claude "<task>"'
83
+ : provider === 'openai' ? 'badgr launch codex "<task>"'
84
+ : `badgr launch cline/claude/codex --provider ${provider} --model <model-id> "<task>"`;
85
+ console.log(chalk.dim(` Used automatically by: ${usageHint}\n`));
83
86
  }
@@ -0,0 +1,493 @@
1
+ import fs from 'fs';
2
+ import { callApi } from '../api.js';
3
+ import { ensureLoggedIn } from '../onboarding.js';
4
+
5
+ const DIAGNOSE_HELP = `
6
+ Paste anything. Badgr detects the input, diagnoses it for free, then
7
+ shows either missing information, a verified template, or a capped
8
+ smoke-test plan. No GPU launches without explicit --approve.
9
+
10
+ Usage:
11
+ badgr diagnose "<input>"
12
+ badgr diagnose "<input>" --approve
13
+ badgr diagnose "<input>" --json
14
+ badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
15
+ one shared via a case link)
16
+
17
+ Input (auto-detected):
18
+ GitHub issue URL https://github.com/org/repo/issues/N
19
+ GitHub repo URL https://github.com/org/repo
20
+ Docker image owner/image:tag or image:tag
21
+ Local log / file ./error.log or vllm.log
22
+ ComfyUI workflow workflow.json (JSON file)
23
+ Raw conversation paste Discord / Slack / support thread text
24
+ Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
25
+
26
+ Flags:
27
+ --approve Approve the capped smoke test after diagnosis (opens
28
+ browser sign-in automatically if not logged in)
29
+ --docker <image> Force Docker-image intake (override auto-detect)
30
+ --repo <url> Force repository intake (override auto-detect)
31
+ --comfyui <path> Force ComfyUI workflow intake (override auto-detect)
32
+ --github <url> Include a GitHub issue URL found inside pasted text
33
+ as additional context. Opt-in only, never fetched
34
+ automatically (see "Additional context found" below)
35
+ --json Machine-readable JSON output
36
+ --help, -h Show this help
37
+
38
+ Safety: nothing runs from AI-extracted data without --approve.
39
+ No GPU launches without explicit approval and a credit check.
40
+
41
+ Full interactive flow: https://aibadgr.com/run-issue`;
42
+
43
+ function detectInput(raw, flags) {
44
+ if (flags.docker) {
45
+ return {
46
+ kind: 'docker_image',
47
+ label: `Docker image: ${flags.docker}`,
48
+ body: { docker_image: flags.docker },
49
+ };
50
+ }
51
+ if (flags.repo) {
52
+ return {
53
+ kind: 'repo_url',
54
+ label: `GitHub repository: ${flags.repo}`,
55
+ body: { repo_url: flags.repo },
56
+ };
57
+ }
58
+ if (flags.comfyui) {
59
+ if (!fs.existsSync(flags.comfyui)) throw new Error(`File not found: ${flags.comfyui}`);
60
+ return {
61
+ kind: 'comfyui_workflow',
62
+ label: `ComfyUI workflow: ${flags.comfyui}`,
63
+ body: { workflow_json: fs.readFileSync(flags.comfyui, 'utf8') },
64
+ };
65
+ }
66
+
67
+ if (!raw) return null;
68
+
69
+ // A case_id (bare, or embedded in a /repro/<id> or /run-issue?case_id=<id>
70
+ // link — the two shapes an admin-prepared or self-created case gets
71
+ // shared as) resumes that existing case instead of diagnosing new input.
72
+ const bareCaseMatch = /^repro_[a-zA-Z0-9]+$/.exec(raw.trim());
73
+ const urlCaseMatch = /\/repro\/(repro_[a-zA-Z0-9]+)/.exec(raw) || /[?&]case_id=(repro_[a-zA-Z0-9]+)/.exec(raw);
74
+ const caseId = bareCaseMatch?.[0] || urlCaseMatch?.[1];
75
+ if (caseId) {
76
+ return { kind: 'existing_case', label: `Existing case: ${caseId}`, caseId };
77
+ }
78
+
79
+ if (/^https?:\/\/(www\.)?github\.com\/[^/]+\/[^/]+\/issues\/\d/.test(raw)) {
80
+ return {
81
+ kind: 'github_issue',
82
+ label: `GitHub issue: ${raw}`,
83
+ body: { github_url: raw },
84
+ };
85
+ }
86
+
87
+ if (/^https?:\/\/(www\.)?github\.com\//.test(raw)) {
88
+ return {
89
+ kind: 'repo_url',
90
+ label: `GitHub repository: ${raw}`,
91
+ body: { repo_url: raw },
92
+ };
93
+ }
94
+
95
+ if (fs.existsSync(raw)) {
96
+ const content = fs.readFileSync(raw, 'utf8');
97
+ if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
98
+ let parsed;
99
+ try { parsed = JSON.parse(content); } catch { /* not valid JSON */ }
100
+ if (parsed && typeof parsed === 'object' && (parsed.nodes || parsed['1'] || parsed['0'])) {
101
+ return {
102
+ kind: 'comfyui_workflow',
103
+ label: `ComfyUI workflow: ${raw}`,
104
+ body: { workflow_json: content },
105
+ };
106
+ }
107
+ }
108
+ return {
109
+ kind: 'log_file',
110
+ label: `Log / error file: ${raw}`,
111
+ body: { text: content.slice(0, 50_000) },
112
+ };
113
+ }
114
+
115
+ if (
116
+ !raw.startsWith('http') &&
117
+ /^[a-z0-9][a-z0-9._\-]*(?:\/[a-z0-9._\-]+)*(?::[a-zA-Z0-9._\-]+)?$/.test(raw) &&
118
+ raw.length < 200
119
+ ) {
120
+ return {
121
+ kind: 'docker_image',
122
+ label: `Docker image: ${raw}`,
123
+ body: { docker_image: raw },
124
+ };
125
+ }
126
+
127
+ return {
128
+ kind: 'text',
129
+ label: 'Text / conversation',
130
+ body: { text: raw.slice(0, 50_000) },
131
+ };
132
+ }
133
+
134
+ function _printResult(result, chalk) {
135
+ const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
136
+
137
+ if (github_issue) {
138
+ console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
139
+ }
140
+
141
+ // The conversation stays primary — a GitHub URL found inside pasted text
142
+ // is only ever offered here, never fetched automatically.
143
+ if (detected_github_url) {
144
+ console.log();
145
+ console.log(` ${chalk.bold('Additional context found:')} ${detected_github_url}`);
146
+ console.log(` ${chalk.dim(`Include it: badgr diagnose "<input>" --github ${detected_github_url}`)}`);
147
+ }
148
+
149
+ const wt = e.workload_type && e.workload_type !== 'unknown' ? e.workload_type : null;
150
+ const conf = e.confidence ? ` ${chalk.dim(`(${Math.round(e.confidence * 100)}% confidence)`)}` : '';
151
+ if (wt) console.log(` ${chalk.bold('Workload:')} ${wt}${conf}`);
152
+ if (e.summary) console.log(` ${chalk.bold('Summary:')} ${e.summary}`);
153
+ console.log();
154
+
155
+ if (e.docker_images?.length) {
156
+ console.log(` ${chalk.bold('Image:')} ${e.docker_images[0]}${e.docker_images.length > 1 ? chalk.dim(` +${e.docker_images.length - 1} more`) : ''}`);
157
+ }
158
+ if (e.models?.length) {
159
+ console.log(` ${chalk.bold('Model:')} ${e.models[0]}${e.models.length > 1 ? chalk.dim(` +${e.models.length - 1} more`) : ''}`);
160
+ }
161
+ if (e.commands?.length) {
162
+ console.log(` ${chalk.bold('Command:')} ${e.commands[0]}`);
163
+ }
164
+ if (e.errors?.length) {
165
+ console.log(` ${chalk.bold('Errors:')}`);
166
+ for (const err of e.errors.slice(0, 3)) console.log(` ${chalk.red('✗')} ${err}`);
167
+ }
168
+
169
+ if (static_incompatibilities?.length) {
170
+ console.log();
171
+ console.log(` ${chalk.bold('Diagnosis:')} ${chalk.yellow('Static incompatibility — no GPU needed')}`);
172
+ for (const inc of static_incompatibilities) console.log(` ${chalk.yellow('!')} ${inc}`);
173
+ } else if (e.missing_information?.length) {
174
+ console.log();
175
+ console.log(` ${chalk.bold('Diagnosis:')} Missing information`);
176
+ for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
177
+ } else if (wt) {
178
+ console.log();
179
+ console.log(` ${chalk.bold('Diagnosis:')} Workload detected — ready for capped test`);
180
+ }
181
+
182
+ if (multi_gpu_detected) {
183
+ console.log(` ${chalk.yellow('!')} Multi-GPU detected: ${multi_gpu_detected}`);
184
+ console.log(` ${chalk.dim('Capped test will use a single GPU only.')}`);
185
+ }
186
+ if (large_download) {
187
+ console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
188
+ console.log(` ${chalk.dim('Funding approval required before test launches.')}`);
189
+ }
190
+ if (result.secret_types_detected?.length) {
191
+ console.log(` ${chalk.dim(`Secrets redacted before analysis: ${result.secret_types_detected.join(', ')}`)}`);
192
+ }
193
+ if (!result.ai_available) {
194
+ console.log(` ${chalk.dim('(AI extraction unavailable — pattern matching only)')}`);
195
+ }
196
+
197
+ console.log();
198
+ if (static_incompatibilities?.length) {
199
+ console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
200
+ } else if (e.missing_information?.length) {
201
+ console.log(` ${chalk.bold('Recommended action:')} Provide the missing details above, then re-run`);
202
+ } else {
203
+ console.log(` ${chalk.bold('Recommended action:')} Run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch a capped test`);
204
+ }
205
+ }
206
+
207
+ async function _doApprove(config, result, chalk) {
208
+ // Diagnosis and case creation stay anonymous and free — login is only
209
+ // required once we know a real GPU test needs approving (below).
210
+ const e = result.extraction;
211
+ const confirmBody = {
212
+ workload_type: e.workload_type || 'generic',
213
+ docker_image: e.docker_images?.[0] || null,
214
+ model_id: e.models?.[0] || null,
215
+ launch_command: e.commands?.[0] || null,
216
+ error_log: null,
217
+ expected_result: e.expected_result || null,
218
+ environment_variable_names: e.environment_variable_names || [],
219
+ gpu_requirements: Array.isArray(e.gpu_requirements)
220
+ ? e.gpu_requirements.join(', ')
221
+ : e.gpu_requirements || null,
222
+ source_summary: e.summary || null,
223
+ };
224
+
225
+ let caseData;
226
+ try {
227
+ console.log(chalk.dim(' Creating case...'));
228
+ caseData = await callApi('/run-issue/confirm', {
229
+ method: 'POST',
230
+ apiKey: config.apiKey || '',
231
+ baseUrl: config.baseUrl,
232
+ body: confirmBody,
233
+ timeoutMs: 20_000,
234
+ });
235
+ } catch (err) {
236
+ console.error(chalk.red(`\n ✗ Could not create case: ${err.message}\n`));
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+
241
+ const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
242
+
243
+ if (status === 'incompatible') {
244
+ console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
245
+ console.log();
246
+ return;
247
+ }
248
+ if (status === 'missing_information') {
249
+ console.log(chalk.yellow('\n Cannot approve — missing information:'));
250
+ for (const m of (missing || [])) console.log(` ? ${m}`);
251
+ console.log();
252
+ return;
253
+ }
254
+ if (status === 'template_matched') {
255
+ console.log(chalk.green('\n Matched a verified template — no new GPU test needed.'));
256
+ if (plan?.final_command) {
257
+ console.log();
258
+ console.log(` ${chalk.bold('Verified command:')}`);
259
+ console.log(` ${chalk.cyan(plan.final_command)}`);
260
+ }
261
+ console.log();
262
+ return;
263
+ }
264
+
265
+ if (plan) {
266
+ console.log();
267
+ if (plan.gpu_type) console.log(` ${chalk.bold('GPU:')} ${plan.gpu_type}`);
268
+ if (plan.estimated_cost) console.log(` ${chalk.bold('Maximum cost:')} $${Number(plan.estimated_cost).toFixed(2)}`);
269
+ if (plan.max_runtime_min) console.log(` ${chalk.bold('Max runtime:')} ${plan.max_runtime_min} min`);
270
+ if (plan.requires_reduced_test) console.log(` ${chalk.yellow('!')} Multi-GPU detected — test uses a single GPU`);
271
+ if (plan.requires_funding_approval) console.log(` ${chalk.yellow('!')} Large download — funding required`);
272
+ }
273
+
274
+ await _approveAndRun(config, caseId, plan, chalk);
275
+ }
276
+
277
+ // Claim → approve → run for a case that already exists (just created via
278
+ // /confirm above, or resumed via an existing_case input). Auth is required
279
+ // from here on — diagnosis and case creation stay anonymous and free, but a
280
+ // real GPU test needs a signed-in account. The CLI never grants a free-run
281
+ // entitlement (web admin only, see grant_free_run in run_issue_routes.py);
282
+ // it only redeems one a case already carries, same as normal billing.
283
+ async function _approveAndRun(config, caseId, plan, chalk) {
284
+ const printResumeHint = () =>
285
+ console.error(chalk.dim(` Resume this case: https://aibadgr.com/run-issue?case_id=${caseId}\n`));
286
+
287
+ let authConfig = config;
288
+ if (!authConfig.apiKey) {
289
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
290
+ console.error(chalk.red('\n ✗ Sign in required to approve a GPU test. Run: badgr login\n'));
291
+ printResumeHint();
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ console.log(chalk.yellow('\n Sign in to continue. Opening browser...'));
296
+ try {
297
+ authConfig = await ensureLoggedIn(authConfig, chalk);
298
+ } catch (err) {
299
+ console.error(chalk.red(`\n ✗ Sign-in failed: ${err.message}\n`));
300
+ printResumeHint();
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ }
305
+
306
+ try {
307
+ await callApi(`/run-issue/cases/${caseId}/claim`, {
308
+ method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 10_000,
309
+ });
310
+ } catch { /* already claimed — continue */ }
311
+
312
+ let approved;
313
+ try {
314
+ approved = await callApi(`/run-issue/cases/${caseId}/approve`, {
315
+ method: 'POST',
316
+ apiKey: authConfig.apiKey,
317
+ baseUrl: authConfig.baseUrl,
318
+ body: {
319
+ confirmed: true,
320
+ funding_approved: Boolean(plan?.requires_funding_approval),
321
+ reduced_test_confirmed: Boolean(plan?.requires_reduced_test),
322
+ },
323
+ timeoutMs: 15_000,
324
+ });
325
+ } catch (err) {
326
+ if (err.httpStatus === 402 || err.message?.includes('402')) {
327
+ console.error(chalk.yellow('\n ✗ Insufficient credits. Run: badgr billing\n'));
328
+ } else {
329
+ console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
330
+ }
331
+ process.exitCode = 1;
332
+ return;
333
+ }
334
+
335
+ console.log(chalk.dim('\n Launching capped smoke test...'));
336
+ let ran;
337
+ try {
338
+ ran = await callApi(`/run-issue/cases/${caseId}/run`, {
339
+ method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
340
+ });
341
+ } catch (err) {
342
+ console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
343
+ process.exitCode = 1;
344
+ return;
345
+ }
346
+
347
+ console.log();
348
+ console.log(chalk.green(' Smoke test launched.'));
349
+ console.log(` ${chalk.bold('Case:')} ${caseId}`);
350
+ if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
351
+ if (ran?.free_run_used) console.log(` ${chalk.bold('Billing:')} Free run — no charge`);
352
+ console.log(` ${chalk.bold('Evidence:')} https://aibadgr.com/repro/${caseId}`);
353
+ console.log();
354
+ }
355
+
356
+ // Resume an existing case (bare case_id or a shared /repro or /run-issue
357
+ // link) instead of diagnosing new input. No /confirm — the case already
358
+ // exists; this only reads its current state and, with --approve, redeems
359
+ // whatever entitlement/billing state it already carries.
360
+ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
361
+ let caseData;
362
+ try {
363
+ caseData = await callApi(`/run-issue/cases/${caseId}`, {
364
+ apiKey: config.apiKey || '', baseUrl: config.baseUrl, timeoutMs: 15_000,
365
+ });
366
+ } catch (err) {
367
+ console.error(chalk.red(`\n ✗ Could not load case ${caseId}: ${err.message}\n`));
368
+ process.exitCode = 1;
369
+ return;
370
+ }
371
+
372
+ if (flags.json) {
373
+ console.log(JSON.stringify(caseData, null, 2));
374
+ if (flags.approve) await _approveAndRun(config, caseId, caseData.test_plan, chalk);
375
+ return;
376
+ }
377
+
378
+ console.log();
379
+ console.log(` ${chalk.bold('Case:')} ${caseId}`);
380
+ console.log(` ${chalk.bold('Status:')} ${caseData.status}`);
381
+ if (caseData.workload_type) console.log(` ${chalk.bold('Workload:')} ${caseData.workload_type}`);
382
+ if (caseData.known_command) console.log(` ${chalk.bold('Command:')} ${caseData.known_command}`);
383
+ if ((caseData.missing_information || []).length) {
384
+ console.log();
385
+ console.log(` ${chalk.bold('Missing information:')}`);
386
+ for (const m of caseData.missing_information) console.log(` ? ${m}`);
387
+ }
388
+
389
+ console.log();
390
+ if (caseData.free_verification_consumed) {
391
+ console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
392
+ } else if (caseData.free_verification_available) {
393
+ console.log(` ${chalk.green(`1 free verification job available — up to $${(caseData.free_verification_max_cost_usd ?? 5).toFixed(0)}`)}`);
394
+ } else if (caseData.test_plan) {
395
+ console.log(` ${chalk.dim(`Verification available — max $${Number(caseData.test_plan.max_cost_usd ?? 0).toFixed(2)}`)}`);
396
+ }
397
+ console.log();
398
+
399
+ if (flags.approve) {
400
+ await _approveAndRun(config, caseId, caseData.test_plan, chalk);
401
+ } else {
402
+ console.log(` ${chalk.bold('Resume:')} badgr diagnose ${caseId} --approve`);
403
+ console.log();
404
+ }
405
+ }
406
+
407
+ export async function diagnoseCommand(config, args, chalk) {
408
+ if (args.includes('--help') || args.includes('-h')) {
409
+ console.log(DIAGNOSE_HELP);
410
+ return;
411
+ }
412
+
413
+ const flags = {};
414
+ let positional = null;
415
+ let i = 0;
416
+ while (i < args.length) {
417
+ const a = args[i];
418
+ if (a === '--docker') { flags.docker = args[++i]; i++; continue; }
419
+ if (a === '--repo') { flags.repo = args[++i]; i++; continue; }
420
+ if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
421
+ if (a === '--github') { flags.github = args[++i]; i++; continue; }
422
+ if (a === '--approve') { flags.approve = true; i++; continue; }
423
+ if (a === '--json') { flags.json = true; i++; continue; }
424
+ if (!a.startsWith('-') && positional === null) positional = a;
425
+ i++;
426
+ }
427
+
428
+ if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
429
+ console.log(DIAGNOSE_HELP);
430
+ return;
431
+ }
432
+
433
+ let detected;
434
+ try {
435
+ detected = detectInput(positional, flags);
436
+ } catch (err) {
437
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
438
+ process.exitCode = 1;
439
+ return;
440
+ }
441
+
442
+ if (!detected) {
443
+ console.log(DIAGNOSE_HELP);
444
+ return;
445
+ }
446
+
447
+ if (detected.kind === 'existing_case') {
448
+ await _diagnoseExistingCase(config, detected.caseId, flags, chalk);
449
+ return;
450
+ }
451
+
452
+ // --github opts into fetching a GitHub issue URL found inside pasted text
453
+ // (see the "Additional context found" notice) — never fetched by default.
454
+ if (flags.github) detected.body.github_url = flags.github;
455
+
456
+ if (!flags.json) {
457
+ console.log();
458
+ console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
459
+ console.log(chalk.dim(' Analysing...'));
460
+ }
461
+
462
+ let result;
463
+ try {
464
+ result = await callApi('/run-issue/extract', {
465
+ method: 'POST',
466
+ apiKey: config.apiKey || '',
467
+ baseUrl: config.baseUrl,
468
+ body: detected.body,
469
+ timeoutMs: 120_000,
470
+ });
471
+ } catch (err) {
472
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
473
+ process.exitCode = 1;
474
+ return;
475
+ }
476
+
477
+ if (flags.json) {
478
+ console.log(JSON.stringify({ detected: detected.kind, ...result }, null, 2));
479
+ return;
480
+ }
481
+
482
+ console.log();
483
+ _printResult(result, chalk);
484
+
485
+ console.log();
486
+ console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
487
+ console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
488
+ console.log();
489
+
490
+ if (flags.approve) {
491
+ await _doApprove(config, result, chalk);
492
+ }
493
+ }
@@ -0,0 +1,246 @@
1
+ import { callApi } from '../api.js';
2
+ import { requireApiKey } from '../config.js';
3
+
4
+ /**
5
+ * badgr job <agent> "<instruction>" --check "npm test"
6
+ *
7
+ * Submits a bounded coding-agent job via POST /v1/jobs (type: "agent"),
8
+ * which creates a Job record and runs it on a GPU/CPU VM.
9
+ * All three interfaces (website, CLI, POST /v1/jobs) share the same
10
+ * execution path on the backend (backend/jobs_routes.py).
11
+ *
12
+ * Usage:
13
+ * badgr job cline "Fix the checkout bug" --check "npm test"
14
+ * badgr job claude "Add pagination" --check "npm run test:e2e" --max-cost 3
15
+ * badgr job codex "Refactor auth" --check "pytest tests/" --repo https://github.com/org/repo
16
+ */
17
+
18
+ const _BOOL_FLAGS = { '--dry-run': 'dryRun', '--detach': 'detach' };
19
+ const _VALUE_FLAGS = {
20
+ '--check': 'check', '--eval': 'check', '--eval-command': 'check',
21
+ '--repo': 'repository', '--repository': 'repository',
22
+ '--ref': 'ref',
23
+ '--agent': 'agent',
24
+ '--provider': 'provider',
25
+ '--model': 'model',
26
+ '--max-cost': 'maxCost',
27
+ '--max-runtime': 'maxRuntime',
28
+ };
29
+
30
+ // Scan the arg list, pull out known flags wherever they appear, and collect
31
+ // the remaining tokens as positionals. This lets flags appear anywhere:
32
+ // badgr job cline "Fix it" --check "npm test"
33
+ // badgr job "Fix it" --check "npm test" --agent cline
34
+ function parseJobArgs(rawArgs) {
35
+ const flags = {};
36
+ const positional = [];
37
+ let i = 0;
38
+ while (i < rawArgs.length) {
39
+ const tok = rawArgs[i];
40
+ if (tok in _BOOL_FLAGS) { flags[_BOOL_FLAGS[tok]] = true; i += 1; continue; }
41
+ if (tok in _VALUE_FLAGS) { flags[_VALUE_FLAGS[tok]] = rawArgs[i + 1]; i += 2; continue; }
42
+ positional.push(tok);
43
+ i += 1;
44
+ }
45
+ return { flags, positional };
46
+ }
47
+
48
+ // Providers the agent Job type accepts (backend jobs_routes._VALID_AGENT_PROVIDERS).
49
+ // The BYOK/OpenAI-compatible providers `badgr launch --provider` supports
50
+ // (openrouter, deepseek, glm, custom) are NOT among them: the Jobs API
51
+ // resolves credentials server-side from stored provider credentials and has
52
+ // nowhere to put a custom base URL, so those must be rejected in the CLI
53
+ // rather than sent on to a 400 from the server.
54
+ export const JOB_API_PROVIDERS = ['badgr', 'openai', 'anthropic'];
55
+
56
+ export const JOB_AGENTS = ['cline', 'claude', 'claude-code', 'codex', 'playwright'];
57
+
58
+ export async function jobCommand(config, args, chalk) {
59
+ // Support both:
60
+ // badgr job <agent> "<instruction>" --check "..."
61
+ // badgr job "<instruction>" --check "..." --agent cline (fallback)
62
+ const { flags, positional } = parseJobArgs(args);
63
+
64
+ let agentName = null;
65
+ let instructionText = null;
66
+
67
+ const knownAgents = new Set(JOB_AGENTS);
68
+
69
+ if (positional.length >= 2 && knownAgents.has(positional[0])) {
70
+ agentName = positional[0];
71
+ instructionText = positional.slice(1).join(' ').trim();
72
+ } else if (positional.length >= 1) {
73
+ instructionText = positional.join(' ').trim();
74
+ agentName = flags.agent ?? 'cline';
75
+ }
76
+
77
+ if (!instructionText || !agentName) {
78
+ console.error(chalk.red('\nUsage: badgr job <agent> "<instruction>" --check "<command>"\n'));
79
+ console.error(chalk.dim(' Agents: cline (default), claude-code, codex, playwright'));
80
+ console.error(chalk.dim(' Example: badgr job cline "Fix the checkout bug" --check "npm test"'));
81
+ console.error(chalk.dim(' Example: badgr job claude-code "Add pagination" --check "pytest tests/" --max-cost 3\n'));
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ if (!flags.check) {
87
+ console.error(chalk.red('\n ✗ --check <command> is required — it verifies the job succeeded.\n'));
88
+ console.error(chalk.dim(' Example: badgr job cline "Fix the bug" --check "npm test"\n'));
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ return runJob(config, {
94
+ agent: agentName,
95
+ instruction: instructionText,
96
+ check: flags.check,
97
+ provider: flags.provider,
98
+ model: flags.model,
99
+ repository: flags.repository,
100
+ ref: flags.ref,
101
+ maxCost: flags.maxCost,
102
+ maxRuntime: flags.maxRuntime,
103
+ dryRun: flags.dryRun,
104
+ detach: flags.detach,
105
+ }, chalk);
106
+ }
107
+
108
+ /**
109
+ * Submit one agent job to POST /v1/jobs and (unless detached) poll it to a
110
+ * terminal state. Takes an already-resolved options object rather than argv
111
+ * so callers that have parsed their own flags — `badgr launch <agent>
112
+ * --eval-command ...` — can reuse this path directly instead of
113
+ * re-serializing their flags back into an argv for jobCommand to reparse.
114
+ */
115
+ export async function runJob(config, opts, chalk) {
116
+ const { agent: agentName, instruction: instructionText, check } = opts;
117
+
118
+ // Require API key — jobs are tracked under the user account.
119
+ const apiKey = requireApiKey(config, chalk);
120
+ if (!apiKey) return;
121
+
122
+ const provider = opts.provider ?? null;
123
+ const model = opts.model ?? null;
124
+ const repository = opts.repository ?? '.';
125
+ const ref = opts.ref ?? null;
126
+ const maxCostUsd = opts.maxCost != null ? Number(opts.maxCost) : 2.0;
127
+ const maxRuntimeSeconds = opts.maxRuntime != null ? Number(opts.maxRuntime) : 1800;
128
+
129
+ if (provider && !JOB_API_PROVIDERS.includes(provider)) {
130
+ console.error(chalk.red(`\n ✗ The Jobs API does not accept --provider ${provider}.`));
131
+ console.error(chalk.dim(` Supported: ${JOB_API_PROVIDERS.join(', ')}`));
132
+ console.error(chalk.dim(' BYOK providers (openrouter, deepseek, glm, custom) work with'));
133
+ console.error(chalk.dim(' badgr launch without --eval-command.\n'));
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+
138
+ // Print plan.
139
+ console.log('');
140
+ console.log(chalk.bold(` Job: ${instructionText}`));
141
+ console.log(chalk.dim(` Agent: ${agentName}${provider ? ` via ${provider}` : ''}${model ? ` / ${model}` : ''}`));
142
+ console.log(chalk.dim(` Check: ${check}`));
143
+ console.log(chalk.dim(` Max: $${maxCostUsd.toFixed(2)} / ${Math.round(maxRuntimeSeconds / 60)} min`));
144
+ console.log('');
145
+
146
+ if (opts.dryRun) {
147
+ console.log(chalk.yellow(' (dry run — no job submitted)\n'));
148
+ return;
149
+ }
150
+
151
+ // Submit job.
152
+ let job;
153
+ try {
154
+ job = await callApi('/v1/jobs', {
155
+ method: 'POST',
156
+ apiKey,
157
+ baseUrl: config.baseUrl,
158
+ body: {
159
+ type: 'agent',
160
+ input: {
161
+ repository,
162
+ ref,
163
+ agent: agentName,
164
+ provider,
165
+ model,
166
+ instruction: instructionText,
167
+ check,
168
+ },
169
+ policy: {
170
+ max_cost: maxCostUsd,
171
+ max_runtime_minutes: Math.round(maxRuntimeSeconds / 60),
172
+ },
173
+ },
174
+ });
175
+ } catch (err) {
176
+ console.error(chalk.red(`\n ✗ Could not submit job: ${err.message}\n`));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+
181
+ if (job?.detail || job?.error) {
182
+ console.error(chalk.red(`\n ✗ ${job.detail ?? job.error}\n`));
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+
187
+ const jobId = job?.job_id;
188
+ if (!jobId) {
189
+ console.error(chalk.red('\n ✗ Unexpected response from server.\n'));
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+
194
+ console.log(chalk.green(` ✓ Job submitted`));
195
+ console.log(chalk.dim(` ID: ${jobId}`));
196
+
197
+ if (opts.detach) {
198
+ console.log(chalk.dim(` Status: badgr status (or GET /v1/jobs/${jobId})\n`));
199
+ return;
200
+ }
201
+
202
+ // Poll until terminal.
203
+ console.log(chalk.dim(' Waiting for job to complete…\n'));
204
+ const pollMs = 10_000;
205
+ const maxWaitMs = (maxRuntimeSeconds + 60) * 1000;
206
+ const startMs = Date.now();
207
+
208
+ while (true) {
209
+ await new Promise(r => setTimeout(r, pollMs));
210
+ if (Date.now() - startMs > maxWaitMs) {
211
+ console.error(chalk.yellow(`\n ⚠ Timed out waiting. Job ${jobId} is still running.\n`));
212
+ console.log(chalk.dim(` Check: badgr status (or GET /v1/jobs/${jobId})\n`));
213
+ break;
214
+ }
215
+ let latest;
216
+ try {
217
+ latest = await callApi(`/v1/jobs/${jobId}`, { apiKey, baseUrl: config.baseUrl });
218
+ } catch {
219
+ continue;
220
+ }
221
+ if (!latest?.job_id) continue;
222
+ if (latest.status === 'running' && latest.stage) {
223
+ process.stdout.write(chalk.dim(`\r ${latest.stage}…`));
224
+ }
225
+ if (latest.status === 'completed' || latest.status === 'failed' || latest.status === 'canceled') {
226
+ console.log('');
227
+ if (latest.status === 'completed') {
228
+ console.log(chalk.green(`\n ✓ Job succeeded`));
229
+ if (latest.output?.exit_code != null) {
230
+ console.log(chalk.green(` Check: ${latest.output.exit_code === 0 ? 'passed' : 'failed'}`));
231
+ }
232
+ } else if (latest.status === 'failed') {
233
+ console.error(chalk.red(`\n ✗ Job failed`));
234
+ if (latest.error?.message) console.error(chalk.dim(` ${latest.error.message}`));
235
+ process.exitCode = 1;
236
+ } else {
237
+ console.log(chalk.yellow(`\n Job ${latest.status}`));
238
+ }
239
+ if (latest.charged_usd != null) {
240
+ console.log(chalk.dim(` Cost: $${latest.charged_usd.toFixed(4)}`));
241
+ }
242
+ console.log('');
243
+ break;
244
+ }
245
+ }
246
+ }
@@ -1,5 +1,6 @@
1
1
  import { runCommand, parseRunArgs } from './run.js';
2
- import { getCredential, setCredential, PROVIDER_ENV_KEYS } from '../credentials.js';
2
+ import { runJob } from './job.js';
3
+ import { getCredential, setCredential, PROVIDER_ENV_KEYS, MODEL_PROVIDERS, KNOWN_MODEL_PROVIDERS, ENV_KEYS_FOR_API_KIND } from '../credentials.js';
3
4
  import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
4
5
 
5
6
  // Phase 1 supports these coding/testing workloads via shorthand. Do not add
@@ -43,8 +44,8 @@ import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
43
44
  // images/badgr-job-runner/entrypoint.py, which subprocess.run executes
44
45
  // directly with shell=False — no shlex.split() involved for this path).
45
46
  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' },
47
+ cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task], provider: null },
48
+ claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['badgr-claude-run', task], provider: 'anthropic' },
48
49
  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
  };
50
51
 
@@ -100,9 +101,25 @@ const _VALUE_FLAGS = {
100
101
  '--max-runtime': 'maxRuntime', '--max-cost': 'maxCost', '--save': 'save',
101
102
  '--workspace': 'workspace', '--output': 'output', '--checkpoint': 'checkpoint',
102
103
  '--resume-cmd': 'resumeCmd', '--size': 'size',
104
+ // BYOK / OpenAI-compatible model selection — `badgr launch cline` only,
105
+ // see resolveModelProviderEnv below.
106
+ '--provider': 'provider', '--model': 'model', '--base-url': 'baseUrl',
107
+ // When present, routes to POST /v1/jobs (type: agent) instead of the
108
+ // plain badgr-launch GPU/CPU run path.
109
+ '--eval-command': 'evalCommand', '--eval': 'evalCommand',
103
110
  };
104
111
  const _REPEATABLE_FLAGS = { '--env': 'env', '--artifacts': 'artifacts' };
105
112
 
113
+ // `badgr launch --eval-command` hands the run to the agent Job type
114
+ // (POST /v1/jobs, type: agent), which provisions and configures the VM
115
+ // server-side. These VM-shaping flags have no equivalent there, so they are
116
+ // called out instead of being silently dropped.
117
+ const JOB_API_IGNORED_FLAGS = [
118
+ ['--base-url', 'baseUrl'], ['--env', 'env'], ['--artifacts', 'artifacts'],
119
+ ['--workspace', 'workspace'], ['--output', 'output'], ['--size', 'size'],
120
+ ['--image', 'image'], ['--region', 'region'], ['--tier', 'tier'],
121
+ ];
122
+
106
123
  const _PASSTHROUGH_FLAGS = [
107
124
  ['--image', 'image'], ['--count', 'count'], ['--region', 'region'],
108
125
  ['--tier', 'tier'], ['--max-price', 'maxPrice'], ['--name', 'name'],
@@ -229,28 +246,32 @@ function resolveVmSize(flags, workloadName, chalk) {
229
246
  return true;
230
247
  }
231
248
 
232
- async function resolveMissingCredential(provider, chalk) {
233
- const providerLabel = provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
249
+ async function resolveMissingCredential(provider, chalk, label) {
250
+ const displayLabel = label ?? (provider === 'anthropic' ? 'Anthropic' : 'OpenAI');
234
251
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
235
- console.error(chalk.red(`\n ✗ No ${provider} credential found.\n`));
252
+ console.error(chalk.red(`\n ✗ No ${displayLabel} credential found.\n`));
236
253
  console.error(chalk.dim(` Run: badgr connect ${provider}\n`));
237
254
  return null;
238
255
  }
239
256
 
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'));
257
+ console.log(chalk.yellow(`\n ${displayLabel} is not connected.`));
258
+ if (label) {
259
+ console.log(chalk.dim(` Your own ${displayLabel} API key pays for model usage — Badgr credits still pay for the disposable VM.\n`));
260
+ } else {
261
+ console.log(chalk.dim(` ${displayLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${displayLabel} account for model usage.`));
262
+ console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
263
+ }
243
264
 
244
265
  try {
245
266
  const { password } = await import('@inquirer/prompts');
246
267
  const key = await password({
247
- message: `Enter your ${providerLabel} API key:`,
268
+ message: `Enter your ${displayLabel} API key:`,
248
269
  validate: v => v.trim() ? true : 'API key is required',
249
270
  });
250
271
  const trimmed = key.trim();
251
272
  if (!trimmed) return null;
252
273
  setCredential(provider, trimmed);
253
- console.log(chalk.green(` ✓ ${providerLabel} connected\n`));
274
+ console.log(chalk.green(` ✓ ${displayLabel} connected\n`));
254
275
  return trimmed;
255
276
  } catch {
256
277
  // Ctrl+C or a non-interactive stdin that lied about isTTY.
@@ -258,6 +279,53 @@ async function resolveMissingCredential(provider, chalk) {
258
279
  }
259
280
  }
260
281
 
282
+ /**
283
+ * BYOK / OpenAI-compatible or Anthropic-compatible model selection.
284
+ *
285
+ * `badgr launch <cline|codex> --provider <name> --model <id> [--base-url <url>]`
286
+ * uses apiKind='openai' (injects OPENAI_API_KEY/OPENAI_BASE_URL/MODEL).
287
+ * `badgr launch claude --provider <name> --model <id> [--base-url <url>]`
288
+ * uses apiKind='anthropic' (injects ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL/MODEL).
289
+ *
290
+ * Validates before provisioning — unknown provider, missing --model, or
291
+ * missing --base-url for `custom` all hard-fail here, no VM created.
292
+ * Returns the extra env pairs to inject, or null on validation failure.
293
+ */
294
+ async function resolveModelProviderEnv(flags, chalk, apiKind = 'openai') {
295
+ const provider = flags.provider;
296
+ if (!(provider in MODEL_PROVIDERS)) {
297
+ console.error(chalk.red(`\n ✗ Unknown model provider: ${provider}`));
298
+ console.error(chalk.dim(` Supported: ${KNOWN_MODEL_PROVIDERS.join(', ')}\n`));
299
+ return null;
300
+ }
301
+ if (!flags.model) {
302
+ console.error(chalk.red(`\n ✗ --provider ${provider} requires --model <model-id> — no default model is assumed.\n`));
303
+ return null;
304
+ }
305
+ const baseUrl = flags.baseUrl || MODEL_PROVIDERS[provider].defaultBaseUrl;
306
+ if (!baseUrl) {
307
+ console.error(chalk.red(`\n ✗ --provider custom requires --base-url <url>.\n`));
308
+ return null;
309
+ }
310
+
311
+ const envKeys = ENV_KEYS_FOR_API_KIND[apiKind];
312
+ const userEnvKeys = new Set((flags.env || []).map(kv => kv.split('=')[0]));
313
+ const env = [];
314
+
315
+ if (!userEnvKeys.has(envKeys.apiKey)) {
316
+ let credential = getCredential(provider);
317
+ if (!credential) {
318
+ credential = await resolveMissingCredential(provider, chalk, MODEL_PROVIDERS[provider].label);
319
+ if (!credential) return null;
320
+ }
321
+ env.push(`${envKeys.apiKey}=${credential}`);
322
+ }
323
+ if (!userEnvKeys.has(envKeys.model)) env.push(`${envKeys.model}=${flags.model}`);
324
+ if (!userEnvKeys.has(envKeys.baseUrl)) env.push(`${envKeys.baseUrl}=${baseUrl}`);
325
+
326
+ return env;
327
+ }
328
+
261
329
  async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
262
330
  const { flags, task, gpuRejected } = parseWorkloadShorthandArgs(rawArgs);
263
331
 
@@ -275,6 +343,32 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
275
343
 
276
344
  warnIfTaskMayHaveSwallowedAFlag(task, chalk);
277
345
 
346
+ // When --eval-command is given, route to the agent Job type
347
+ // (POST /v1/jobs, type: agent) which tracks check results and stores a
348
+ // receipt — same UX path, different backend record type. Runs after the
349
+ // --gpu / empty-task guards above so those still apply, and calls runJob
350
+ // directly with the already-parsed flags rather than re-serializing them
351
+ // into an argv (which silently dropped everything the re-serializer
352
+ // forgot to list).
353
+ if (flags.evalCommand) {
354
+ const ignored = JOB_API_IGNORED_FLAGS.filter(([, key]) => flags[key] !== undefined);
355
+ if (ignored.length) {
356
+ console.error(chalk.yellow(`\n ⚠ ${ignored.map(([f]) => f).join(', ')} ${ignored.length === 1 ? 'is' : 'are'} not supported with --eval-command and will be ignored.`));
357
+ console.error(chalk.dim(' The Jobs API provisions and configures the VM itself.\n'));
358
+ }
359
+ return runJob(config, {
360
+ agent: agentName,
361
+ instruction: task,
362
+ check: flags.evalCommand,
363
+ provider: flags.provider,
364
+ model: flags.model,
365
+ maxCost: flags.maxCost,
366
+ maxRuntime: flags.maxRuntime,
367
+ dryRun: flags.dryRun,
368
+ detach: flags.detach,
369
+ }, chalk);
370
+ }
371
+
278
372
  if (!resolveVmSize(flags, agentName, chalk)) {
279
373
  process.exitCode = 1;
280
374
  return;
@@ -282,7 +376,23 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
282
376
 
283
377
  const spec = AGENT_WORKLOADS[agentName];
284
378
 
285
- if (spec.provider) {
379
+ if (spec.provider && flags.provider) {
380
+ // BYOK override for a fixed-provider workload (claude → anthropic,
381
+ // codex → openai). Skip the managed credential flow and inject the
382
+ // BYOK model provider env vars instead. The apiKind determines which
383
+ // env var names the agent image reads (ANTHROPIC_* vs OPENAI_*).
384
+ const apiKind = spec.provider === 'anthropic' ? 'anthropic' : 'openai';
385
+ const modelEnv = await resolveModelProviderEnv(flags, chalk, apiKind);
386
+ if (!modelEnv) {
387
+ process.exitCode = 1;
388
+ return;
389
+ }
390
+ if (!flags.env) flags.env = [];
391
+ flags.env.push(...modelEnv);
392
+ flags.authRequired = { provider: flags.provider, status: 'connected' };
393
+ } else if (spec.provider) {
394
+ // Standard managed-credential flow: anthropic → ANTHROPIC_API_KEY,
395
+ // openai → OPENAI_API_KEY. Prompts inline on first use in a TTY.
286
396
  const envKey = PROVIDER_ENV_KEYS[spec.provider];
287
397
  const userSuppliedKey = flags.env?.some(kv => kv.startsWith(`${envKey}=`));
288
398
  let credential = userSuppliedKey ? null : getCredential(spec.provider);
@@ -309,12 +419,24 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
309
419
  if (!flags.env) flags.env = [];
310
420
  flags.env.push(`${envKey}=${credential}`);
311
421
  }
422
+ } else if (flags.provider) {
423
+ // BYOK for cline (spec.provider is null) — injects OPENAI_API_KEY,
424
+ // OPENAI_BASE_URL, MODEL. The backend skips its managed model token
425
+ // when it sees an existing OPENAI_API_KEY (see jobs_routes.py).
426
+ const modelEnv = await resolveModelProviderEnv(flags, chalk, 'openai');
427
+ if (!modelEnv) {
428
+ process.exitCode = 1;
429
+ return;
430
+ }
431
+ if (!flags.env) flags.env = [];
432
+ flags.env.push(...modelEnv);
433
+ flags.authRequired = { provider: flags.provider, status: 'connected' };
312
434
  } else {
313
435
  flags.authRequired = { provider: null, status: 'none' };
314
436
  }
315
- // cline has no `provider` Badgr mints and injects its own short-lived
316
- // job-scoped model token server-side (see backend jobs_routes.py); the
317
- // CLI never handles or displays that token.
437
+ // cline with no --provider: Badgr mints a short-lived job-scoped model
438
+ // token server-side (see backend jobs_routes.py); the CLI never handles
439
+ // or displays that token. --provider switches cline to BYOK instead.
318
440
 
319
441
  const cmdArgv = spec.buildCmd(task);
320
442
  flags.cmd = displayCmd(cmdArgv);
@@ -336,6 +458,13 @@ async function launchDirectWorkload(config, workloadName, rawArgs, chalk) {
336
458
  // there's no natural-language interface to hand it to, unlike the agent
337
459
  // workloads above. Read but intentionally not passed to buildCmd().
338
460
 
461
+ if (flags.provider) {
462
+ console.error(chalk.red(`\n ✗ --provider is not supported for \`badgr launch ${workloadName}\` — it is a test runner with no LLM component.\n`));
463
+ console.error(chalk.dim(` Use \`badgr launch claude/cline/codex\` for AI agent workloads.\n`));
464
+ process.exitCode = 1;
465
+ return;
466
+ }
467
+
339
468
  if (gpuRejected) {
340
469
  console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
341
470
  process.exitCode = 1;
@@ -5,7 +5,7 @@ import { createWriteStream } from 'fs';
5
5
  import { requireApiKey } from '../config.js';
6
6
  import { callApi, terminateDeployment, uploadBlob, quoteRun } from '../api.js';
7
7
  import { addReceipt, updateReceipt, generateReceiptId, selectedComputeFromDeployment } from '../store.js';
8
- import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
8
+ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD, SMOKE_MAX_COST_USD, SMOKE_MAX_RUNTIME_MINUTES } from '../fallback.js';
9
9
  import { formatCliError } from '../errors.js';
10
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
11
11
  import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
@@ -57,6 +57,7 @@ export function parseRunArgs(args) {
57
57
  if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
58
58
  if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
59
59
  if (flagArgs[i] === '--tier') { flags.tier = flagArgs[++i]; i++; continue; }
60
+ if (flagArgs[i] === '--smoke') { flags.smoke = true; i++; continue; }
60
61
  if (flagArgs[i] === '--max-price') { flags.maxPrice = parseFloat(flagArgs[++i]); i++; continue; }
61
62
  if (flagArgs[i] === '--name') { flags.name = flagArgs[++i]; i++; continue; }
62
63
  if (flagArgs[i] === '--detach') { flags.detach = true; i++; continue; }
@@ -409,7 +410,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
409
410
 
410
411
  // Known badgr run flags — used to detect broken shell line continuation.
411
412
  const _KNOWN_RUN_FLAGS = new Set([
412
- '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
413
+ '--gpu', '--image', '--count', '--region', '--tier', '--smoke', '--max-price', '--name',
413
414
  '--detach', '--no-detach', '--fallback', '--no-fallback', '--strict-capacity',
414
415
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--gpu-memory',
415
416
  '--cpu', '--memory', '--no-gpu', '--env',
@@ -692,6 +693,28 @@ export async function runCommand(config, args, chalk, opts = {}) {
692
693
  return;
693
694
  }
694
695
 
696
+ // ── Smoke mode: cheapest compatible provider for local/dev test runs ──────
697
+ // --smoke, or BADGR_DEV_CHEAPEST=1 in the environment for a local default so
698
+ // it doesn't have to be typed every time. An explicit --tier always wins —
699
+ // the user asked for a specific tier, so smoke's routing/caps don't apply.
700
+ const devCheapestEnv = process.env.BADGR_DEV_CHEAPEST === '1' || process.env.BADGR_DEV_CHEAPEST === 'true';
701
+ const smokeMode = !flags.tier && (flags.smoke || devCheapestEnv);
702
+
703
+ if (smokeMode) {
704
+ if (flags.detach) {
705
+ console.error(chalk.red(' ✗ --smoke requires teardown to run in the foreground — remove --detach.'));
706
+ process.exitCode = 1;
707
+ return;
708
+ }
709
+ if (flags.workspace) {
710
+ console.error(chalk.red(' ✗ --smoke does not support --workspace (no persistent storage for smoke runs).'));
711
+ process.exitCode = 1;
712
+ return;
713
+ }
714
+ if (flags.maxCost === undefined) flags.maxCost = SMOKE_MAX_COST_USD;
715
+ if (flags.maxRuntime === undefined) flags.maxRuntime = SMOKE_MAX_RUNTIME_MINUTES;
716
+ }
717
+
695
718
  if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
696
719
  try {
697
720
  const { input } = await import('@inquirer/prompts');
@@ -801,6 +824,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
801
824
  if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
802
825
  if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
803
826
  if (!isLaunch) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
827
+ if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
804
828
 
805
829
  // Upload-size estimate is GPU-job-specific (spec: "badgr run ... upload
806
830
  // size") and does a real local zip pass — skip it for CPU launches
@@ -848,6 +872,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
848
872
  console.log(` ${chalk.bold('Max cost:')} ${maxCostLabel}`);
849
873
  console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
850
874
  console.log(` ${chalk.bold('Auto-stop:')} ${maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
875
+ if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
851
876
  if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
852
877
  if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
853
878
  if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${redactEnvForDisplay(flags.env)}`);
@@ -912,6 +937,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
912
937
  max_price_per_hour: flags.maxPrice,
913
938
  name: flags.name,
914
939
  tier: tierOverride || effectiveTier,
940
+ ...(smokeMode ? { routing: 'cheapest' } : {}),
915
941
  ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
916
942
  max_runtime_seconds: effectiveMaxRuntime * 60,
917
943
  ...(maxCost ? { max_cost_usd: maxCost } : {}),
@@ -936,7 +962,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
936
962
  effectiveTier,
937
963
  chalk,
938
964
  { thing: 'job', cmd: cmdName },
939
- { allowTier2Fallback: !flags.noFallback },
965
+ { allowTier2Fallback: !flags.noFallback, singleAttempt: smokeMode },
940
966
  );
941
967
  } catch (err) {
942
968
  if (err.isPaymentRequired) {
@@ -8,10 +8,43 @@ export const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
8
8
  export const PROVIDER_ENV_KEYS = {
9
9
  anthropic: 'ANTHROPIC_API_KEY',
10
10
  openai: 'OPENAI_API_KEY',
11
+ // OpenAI-compatible BYOK lanes for `badgr launch cline --provider <name>`
12
+ // (see MODEL_PROVIDERS below) — the cline agent image only ever reads
13
+ // OPENAI_API_KEY/OPENAI_BASE_URL/MODEL (images/badgr-agent-cline/
14
+ // badgr-cline-run), regardless of which upstream provider the key
15
+ // actually belongs to.
16
+ openrouter: 'OPENAI_API_KEY',
17
+ deepseek: 'OPENAI_API_KEY',
18
+ glm: 'OPENAI_API_KEY',
19
+ custom: 'OPENAI_API_KEY',
11
20
  };
12
21
 
13
22
  export const KNOWN_PROVIDERS = Object.keys(PROVIDER_ENV_KEYS);
14
23
 
24
+ // `badgr launch cline --provider <name> --model <id> [--base-url <url>]` —
25
+ // BYOK / OpenAI-compatible model selection. `custom` has no default base
26
+ // URL: it must always be supplied explicitly, since there is nothing sane
27
+ // to default it to. Every other entry's defaultBaseUrl is overridable with
28
+ // an explicit --base-url too (e.g. a company-hosted DeepSeek-compatible
29
+ // gateway).
30
+ export const MODEL_PROVIDERS = {
31
+ openrouter: { label: 'OpenRouter', defaultBaseUrl: 'https://openrouter.ai/api/v1' },
32
+ deepseek: { label: 'DeepSeek', defaultBaseUrl: 'https://api.deepseek.com/v1' },
33
+ glm: { label: 'GLM (Zhipu)', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
34
+ custom: { label: 'Custom (OpenAI-compatible)', defaultBaseUrl: null },
35
+ };
36
+
37
+ export const KNOWN_MODEL_PROVIDERS = Object.keys(MODEL_PROVIDERS);
38
+
39
+ // Which env vars each API kind expects inside the agent container.
40
+ // `claude` uses the Anthropic SDK (ANTHROPIC_*); `cline` and `codex` use the
41
+ // OpenAI SDK (OPENAI_*). `model` is always 'MODEL' — every agent wrapper
42
+ // reads that env var and maps it to its own --model flag.
43
+ export const ENV_KEYS_FOR_API_KIND = {
44
+ openai: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL', model: 'MODEL' },
45
+ anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseUrl: 'ANTHROPIC_BASE_URL', model: 'MODEL' },
46
+ };
47
+
15
48
  /**
16
49
  * Credential storage for `badgr connect <provider>`. This is a local file
17
50
  * under ~/.badgr with owner-only permissions (chmod 600) — not the
package/src/fallback.js CHANGED
@@ -6,6 +6,13 @@ import { CATALOG, formatCliError } from './errors.js';
6
6
  /** Rates above this threshold trigger a visible warning when no --max-cost is set. */
7
7
  export const HIGH_RATE_THRESHOLD = 3.00;
8
8
 
9
+ // ── badgr run --smoke defaults ────────────────────────────────────────────────
10
+ // Hardcoded default smoke caps, not a config system — --max-cost/--max-runtime
11
+ // still override them when passed explicitly (see run.js). The goal is to stop
12
+ // expensive local smoke tests by default, not build another configuration system.
13
+ export const SMOKE_MAX_COST_USD = 0.25;
14
+ export const SMOKE_MAX_RUNTIME_MINUTES = 10;
15
+
9
16
  /** Normalise --tier flag variants to '1' or '2'. */
10
17
  export function normalizeTier(tier) {
11
18
  return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
@@ -37,12 +44,16 @@ export class CapacityError extends Error {
37
44
  * @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
38
45
  * @param {object} [opts]
39
46
  * @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
47
+ * @param {boolean} [opts.singleAttempt=false] - set true to skip both the same-tier
48
+ * provider retry and tier-2 expansion, failing immediately on the first error
49
+ * (badgr run --smoke's "one attempt" guarantee)
40
50
  */
41
51
  export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
42
52
  const { callApi } = await import('./api.js');
43
53
  const thing = labels?.thing ?? 'job';
44
54
  const cmd = labels?.cmd ?? 'badgr run';
45
- const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
55
+ const singleAttempt = opts.singleAttempt === true;
56
+ const allowTier2Fallback = !singleAttempt && opts.allowTier2Fallback !== false; // default true
46
57
 
47
58
  // 220s: comfortably above backend's BADGR_PROVISION_TIMEOUT_SECONDS (default
48
59
  // 200s, itself set above deployment_service.py's 180s routing-search
@@ -113,7 +124,7 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
113
124
  // Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
114
125
  // Retry once with prefer_different_provider so the backend routes to a different provider
115
126
  // (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
116
- if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
127
+ if (!singleAttempt && (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR')) {
117
128
  console.log(chalk.dim('\n Provider unavailable — trying alternative provider...\n'));
118
129
  try {
119
130
  const retryBody = { ...buildBody(), prefer_different_provider: true };
@@ -1,25 +0,0 @@
1
- import { launchCommand } from './launch.js';
2
-
3
- /**
4
- * badgr task "<description>" -- <command>
5
- *
6
- * The MVP version of "assign a task" — a thin label wrapper over
7
- * `badgr launch . -- <command>`. It does not queue, schedule, or track
8
- * anything beyond what `badgr launch` already does; the description is
9
- * printed for the human and otherwise discarded (the receipt/status page
10
- * still key off the deployment ID, same as any other launch).
11
- */
12
- export async function taskCommand(config, args, chalk) {
13
- const description = args[0];
14
- if (!description || description.startsWith('-')) {
15
- console.error(chalk.red('\nUsage: badgr task "<description>" [badgr launch flags...] -- <command>\n'));
16
- console.error(chalk.dim(' The description must come first, before any flags.'));
17
- console.error(chalk.dim(' Example: badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium'));
18
- console.error(chalk.dim(' Example: badgr task "Fix the failing checkout test" --max-cost 1 -- claude -p "Fix the failing checkout test"\n'));
19
- process.exitCode = 1;
20
- return;
21
- }
22
-
23
- console.log(chalk.dim(` Task: ${description}`));
24
- return launchCommand(config, ['.', ...args.slice(1)], chalk);
25
- }