badgr-cli 1.1.4 → 1.1.6

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.
@@ -2,15 +2,16 @@ import fs from 'fs';
2
2
  import { classifyPastedInput } from 'badgr-shared';
3
3
  import { callApi } from '../api.js';
4
4
  import { ensureLoggedIn } from '../onboarding.js';
5
+ import { webBaseUrl } from '../config.js';
5
6
 
6
7
  const DIAGNOSE_HELP = `
7
- Paste anything. Badgr detects the input, diagnoses it for free, then
8
- shows either missing information, a verified template, or a capped
9
- smoke-test plan. No GPU launches without explicit --approve.
8
+ Paste anything. This IS the Badgr Smoke Test: free, no GPU, no login.
9
+ Badgr detects the input, resolves a command, and automatically runs every
10
+ applicable free structural check on it. No GPU launches without explicit
11
+ --approve, which starts a real Badgr Run.
10
12
 
11
13
  Usage:
12
14
  badgr diagnose "<input>"
13
- badgr diagnose "<input>" --smoke
14
15
  badgr diagnose "<input>" --approve
15
16
  badgr diagnose "<input>" --json
16
17
  badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
@@ -26,24 +27,14 @@ Input (auto-detected):
26
27
  Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
27
28
 
28
29
  Flags:
29
- --smoke Free. Runs real, cheap mechanical validation of the
30
- produced command on top of the normal diagnosis --
31
- command syntax, CLI-entrypoint corroboration,
32
- referenced-file presence, Docker ENTRYPOINT/CMD
33
- consistency, required env vars -- plus a client-side
34
- check of any local file path you pasted (e.g. a
35
- .gguf path), which only this machine can see. Never
36
- starts a GPU, never requires login, never prints
37
- "Verified" -- only "Smoke Checked" (AGENTS.md proof
38
- levels). Can be combined with --approve: the smoke
39
- checks print first, then the normal approve flow
40
- runs (they are independent, non-blocking steps).
41
- Unrelated to "badgr run --smoke", which launches a
42
- real, billable cheapest-GPU job.
43
- --approve Approve the capped smoke test after diagnosis (opens
30
+ --approve Launch a real Badgr Run after diagnosis (opens
44
31
  browser sign-in automatically if not logged in).
45
- This is a real, billable GPU-provisioned run --
46
- distinct from the free --smoke flag above.
32
+ This is a real, billable GPU-provisioned execution
33
+ -- distinct from the free diagnosis above.
34
+ --smoke Deprecated, no-op. The free mechanical checks this
35
+ used to gate are now always run automatically as
36
+ part of plain "badgr diagnose" -- kept only so
37
+ existing scripts that pass it don't break.
47
38
  --docker <image> Force Docker-image intake (override auto-detect)
48
39
  --repo <url> Force repository intake (override auto-detect)
49
40
  --comfyui <path> Force ComfyUI workflow intake (override auto-detect)
@@ -54,11 +45,18 @@ Flags:
54
45
  --help, -h Show this help
55
46
 
56
47
  Status: every run prints one of NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
57
- NEEDS INFO -- cannot yet produce a complete command
58
- READY -- complete evidence-backed command, no smoke run
59
- SMOKE CHECKED -- --smoke ran; all applicable checks PASS or SKIPPED
60
- INVALID -- --smoke ran and an applicable check actually FAILED
61
- VERIFIED -- only after a real successful run (never from --smoke)
48
+ NEEDS INFO -- cannot yet produce a complete command
49
+ READY -- complete evidence-backed command, no applicable checks to run
50
+ SMOKE CHECKED -- applicable checks ran; all PASS or SKIPPED
51
+ INVALID -- an applicable check actually FAILED
52
+ VERIFIED -- only after a real Badgr Run succeeds (never from diagnosis alone)
53
+
54
+ Checks run automatically whenever applicable: command syntax,
55
+ CLI-entrypoint corroboration, referenced-file presence, Docker
56
+ ENTRYPOINT/CMD consistency, required env vars -- plus a client-side check
57
+ of any local file path you pasted (e.g. a .gguf path), which only this
58
+ machine can see. Never starts a GPU, never requires login, never prints
59
+ "Verified" -- only "Smoke Checked" (AGENTS.md proof levels).
62
60
 
63
61
  Run page: READY and SMOKE CHECKED results also create a free, anonymous
64
62
  "Run" link (the same prepared case /run-issue's own confirm form
@@ -73,10 +71,36 @@ Run page: READY and SMOKE CHECKED results also create a free, anonymous
73
71
 
74
72
  Safety: nothing runs from AI-extracted data without --approve.
75
73
  No GPU launches without explicit approval and a credit check.
76
- --smoke never provisions a GPU and never requires login.
74
+ Diagnosis (with or without its automatic checks) never provisions
75
+ a GPU and never requires login.
77
76
 
78
77
  Full interactive flow: https://aibadgr.com/run-issue`;
79
78
 
79
+ // Best-effort, purely local (no network, no AI) runtime/model preview for
80
+ // an already-classified "command" input -- shown immediately, before the
81
+ // `/run-issue/extract` request (which still runs unchanged right after, so
82
+ // the full server-side checks -- static incompatibility, missing info,
83
+ // etc. -- are untouched). An obvious `vllm serve facebook/opt-125m` used to
84
+ // sit behind "Analysing... Xs" with zero feedback about what Badgr even
85
+ // detected until the AI-backed extract call finished; the deterministic
86
+ // parser already knows the runtime and model instantly, so there's no
87
+ // reason to make the user wait through that just to see it. Returns null
88
+ // (nothing printed) for any command shape this doesn't recognize --
89
+ // never a guess, and never a substitute for the real extract result below.
90
+ const _PREVIEW_PATTERNS = [
91
+ { runtime: 'vLLM', re: /\bvllm\s+serve\s+(\S+)/i },
92
+ { runtime: 'Ollama', re: /\bollama\s+run\s+(\S+)/i },
93
+ ];
94
+
95
+ function _previewLaunchCommand(raw) {
96
+ if (!raw || raw.includes('\n')) return null;
97
+ for (const { runtime, re } of _PREVIEW_PATTERNS) {
98
+ const m = re.exec(raw);
99
+ if (m) return { runtime, model: m[1] };
100
+ }
101
+ return null;
102
+ }
103
+
80
104
  function detectInput(raw, flags) {
81
105
  if (flags.docker) {
82
106
  return {
@@ -148,11 +172,30 @@ function detectInput(raw, flags) {
148
172
  return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
149
173
  }
150
174
 
175
+ if (classified.kind === 'command') {
176
+ return { kind: 'command', label: 'Explicit command', body: classified.body };
177
+ }
178
+
151
179
  return { kind: 'text', label: 'Text / conversation', body: classified.body };
152
180
  }
153
181
 
182
+ // backend/run_issue_routes.py's /extract now returns the canonical
183
+ // "service" (persistent, badgr serve) vs "finite_job" (bounded, badgr
184
+ // run/train/comfyui) classification directly on the extraction as
185
+ // `workload_shape` (_workload_shape, the same predicate
186
+ // _canonical_badgr_command itself uses to pick a subcommand) -- read that
187
+ // instead of re-deriving an approximation here, so the CLI's wording can
188
+ // never drift from what the backend actually decided. The local fallback
189
+ // only matters for a response predating this field (e.g. a cached fixture).
190
+ function _isServiceShape(e) {
191
+ if (e.workload_shape) return e.workload_shape === 'service';
192
+ const wt = e.workload_type;
193
+ return Boolean(e.models?.length) && wt !== 'training' && wt !== 'comfyui';
194
+ }
195
+
154
196
  function _printResult(result, chalk) {
155
197
  const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
198
+ const isService = _isServiceShape(e);
156
199
 
157
200
  if (github_issue) {
158
201
  console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
@@ -196,12 +239,19 @@ function _printResult(result, chalk) {
196
239
  for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
197
240
  } else if (wt) {
198
241
  console.log();
199
- console.log(` ${chalk.bold('Diagnosis:')} Workload detected — ready for capped test`);
242
+ console.log(` ${chalk.bold('Diagnosis:')} ${isService
243
+ ? 'Persistent service detected — ready to serve'
244
+ : 'Workload detected — ready for capped test'}`);
200
245
  }
201
246
 
202
247
  if (multi_gpu_detected) {
203
- console.log(` ${chalk.yellow('!')} Multi-GPU detected: ${multi_gpu_detected}`);
204
- console.log(` ${chalk.dim('Capped test will use a single GPU only.')}`);
248
+ console.log(` ${chalk.yellow('!')} Multi-GPU requested: ${multi_gpu_detected.gpu_count} GPUs ${chalk.dim(`(${multi_gpu_detected.evidence})`)}`);
249
+ // Never claim an automatic single-GPU downgrade here -- this topology
250
+ // is preserved if Badgr's runner supports it, or the paid CTA is
251
+ // blocked with an explicit unsupported-topology message once a plan
252
+ // exists (see /confirm's requires_reduced_test); a reduced test is
253
+ // only ever a distinct, explicit secondary choice, never silent.
254
+ console.log(` ${chalk.dim('Badgr will preserve this topology if supported, or report it as unsupported before any GPU runs.')}`);
205
255
  }
206
256
  if (large_download) {
207
257
  console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
@@ -219,8 +269,23 @@ function _printResult(result, chalk) {
219
269
  console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
220
270
  } else if (e.missing_information?.length) {
221
271
  console.log(` ${chalk.bold('Recommended action:')} Provide the missing details above, then re-run`);
272
+ } else if (isService) {
273
+ // A persistent service has no lifetime cost cap by default -- the free
274
+ // verification step below is still capped (it's just a readiness
275
+ // check), but the resulting `badgr serve` endpoint keeps running and
276
+ // billing until stopped, or an explicit --max-cost/--max-runtime is
277
+ // supplied. See backend/run_issue_routes.py's _canonical_badgr_command.
278
+ console.log(` ${chalk.bold('Recommended action:')} Open the Run link below to verify readiness and start the service`);
279
+ console.log(` ${chalk.dim('The service keeps running after verification -- stop it with `badgr down`, or launch it with --max-cost/--persistent.')}`);
222
280
  } else {
223
- console.log(` ${chalk.bold('Recommended action:')} Run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch a capped test`);
281
+ // The prepared case URL (printed below by _printCopySummary as "Run:")
282
+ // is the primary handoff -- it's the same Page 2 the web flow uses, with
283
+ // the real free-check evidence, the dynamic recommended cap, and login/
284
+ // approval built in. `--approve` remains available for backward
285
+ // compatibility (a same-terminal, no-browser capped test) but is no
286
+ // longer the recommended path.
287
+ console.log(` ${chalk.bold('Recommended action:')} Open the Run link below to review and approve a capped test`);
288
+ console.log(` ${chalk.dim(`(or run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch one directly from this terminal)`)}`);
224
289
  }
225
290
  }
226
291
 
@@ -265,8 +330,11 @@ function _localPathCandidates(e) {
265
330
  // Locked status ladder (product-level labels, not the backend's internal
266
331
  // proof_level enum): NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
267
332
  // Pure presentation over data /run-issue/extract already returns -- no new
268
- // endpoint, no new GPU behaviour. --smoke can reach SMOKE CHECKED or INVALID
269
- // (never VERIFIED, see AGENTS.md §12 and _printCopySummary below).
333
+ // endpoint, no new GPU behaviour. Diagnosis alone can reach SMOKE CHECKED
334
+ // or INVALID (never VERIFIED, see AGENTS.md §12 and _printCopySummary
335
+ // below) -- `badgr diagnose` IS the Badgr Smoke Test, so applicable free
336
+ // checks always run and always count toward the printed status; there is
337
+ // no separate opt-in step for them.
270
338
  //
271
339
  // The evidence-only judgment (needs_info/invalid/smoke_checked) is computed
272
340
  // once, server-side, in `_compute_smoke_status` (backend/run_issue_routes.py)
@@ -277,39 +345,50 @@ function _localPathCandidates(e) {
277
345
  // A `smoke_status` fallback derivation is kept only for a backend response
278
346
  // that predates this field (defensive, not the normal path).
279
347
  //
280
- // READY means "no smoke run" -- a command that's merely complete is never
281
- // itself proof of validity, so mechanical/local-file results only ever
282
- // change the printed word when --smoke actually ran them (locked spec
283
- // problem #6: a real FAIL must demote SMOKE CHECKED to INVALID; SKIPPED
284
- // checks never do). This part stays CLI-side: it depends on the --smoke
285
- // flag and this machine's own filesystem, neither of which the backend can
286
- // see.
287
- function _statusWord(result, smokeRequested) {
348
+ // READY means "no applicable checks to run" -- a command that's merely
349
+ // complete is never itself proof of validity, so mechanical/local-file
350
+ // results only ever change the printed word when a check actually ran
351
+ // (locked spec problem #6: a real FAIL must demote SMOKE CHECKED to
352
+ // INVALID; SKIPPED checks never do). The local-file half of this stays
353
+ // CLI-side since it depends on this machine's own filesystem, which the
354
+ // backend can never see.
355
+ function _statusWord(result) {
288
356
  const e = result.extraction || {};
289
357
  if (result.static_incompatibilities?.length || e.missing_information?.length) return 'NEEDS INFO';
290
- if (!smokeRequested) return 'READY';
291
- const backendStatus = e.smoke_status
292
- || ((e.mechanical_checks || []).some((check) => check.status === 'fail') ? 'invalid' : 'smoke_checked');
358
+ // Mirrors backend/run_issue_routes.py's _compute_smoke_status exactly: a
359
+ // mechanical FAIL is invalid; otherwise only real command-scope evidence
360
+ // (smoke_check.proof_level === "smoke_checked" -- the repository/image/
361
+ // service's own documented default, not just a syntax-only pass) earns
362
+ // SMOKE CHECKED. Everything else is READY, even if mechanical checks ran
363
+ // and all passed -- a syntax-only pass proves the command is well formed,
364
+ // not that its runtime is available.
365
+ const backendStatus = e.smoke_status || (
366
+ (e.mechanical_checks || []).some((check) => check.status === 'fail')
367
+ ? 'invalid'
368
+ : (e.smoke_check || {}).proof_level === 'smoke_checked' ? 'smoke_checked' : 'ready'
369
+ );
293
370
  const localFail = _localPathCandidates(e).some((p) => !fs.existsSync(p));
294
371
  if (backendStatus === 'invalid' || localFail) return 'INVALID';
295
372
  return backendStatus === 'ready' ? 'READY' : 'SMOKE CHECKED';
296
373
  }
297
374
 
298
375
  const _CHECK_LABELS = {
299
- command_syntax: 'command syntax',
300
- cli_entrypoint: 'CLI entrypoint',
301
- referenced_files: 'referenced repo files',
302
- docker_entrypoint: 'Docker metadata',
303
- required_env_vars: 'required env vars',
376
+ command_syntax: 'command syntax',
377
+ supported_flags: 'known CLI flags',
378
+ cli_entrypoint: 'CLI entrypoint',
379
+ referenced_files: 'referenced repo files',
380
+ image_availability: 'image exists / accessible',
381
+ docker_entrypoint: 'Docker metadata',
382
+ required_env_vars: 'required env vars',
304
383
  };
305
384
 
306
385
  // Compact, copy-pasteable result -- the literal STATUS word plus just enough
307
386
  // to forward to someone else. Additive to the detailed report above it;
308
387
  // does not replace it. Never prints "Verified" -- SMOKE CHECKED is the
309
388
  // ceiling for this free path.
310
- function _printCopySummary(result, flags, chalk, caseData) {
389
+ function _printCopySummary(result, chalk, caseData, config) {
311
390
  const e = result.extraction || {};
312
- const status = _statusWord(result, flags.smoke);
391
+ const status = _statusWord(result);
313
392
 
314
393
  console.log(` ${chalk.bold(`STATUS: ${status}`)}`);
315
394
  console.log();
@@ -341,7 +420,7 @@ function _printCopySummary(result, flags, chalk, caseData) {
341
420
 
342
421
  if (caseData?.case_id) {
343
422
  console.log(` ${chalk.bold('Run:')}`);
344
- console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id))}`);
423
+ console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id, config))}`);
345
424
  console.log();
346
425
  }
347
426
 
@@ -380,7 +459,13 @@ function _printSmokeChecks(result, chalk) {
380
459
  const localPaths = _localPathCandidates(e);
381
460
 
382
461
  console.log();
383
- console.log(` ${chalk.bold('Smoke Checked (free, no GPU, no login):')}`);
462
+ // Neutral header -- this section always runs and prints regardless of the
463
+ // final verdict (_statusWord can land on READY, NEEDS INFO, or INVALID
464
+ // just as easily as SMOKE CHECKED; see its own comment for why a
465
+ // syntax-only pass isn't proof of scope). A fixed "Smoke Checked" header
466
+ // here previously disagreed with a `STATUS: READY` line printed right
467
+ // after it -- this section is the free checks themselves, not the verdict.
468
+ console.log(` ${chalk.bold('Free checks (no GPU, no login):')}`);
384
469
 
385
470
  const icon = (status) => (
386
471
  status === 'pass' ? chalk.green('✓') : status === 'fail' ? chalk.red('✗') : chalk.dim('-')
@@ -416,11 +501,46 @@ function _confirmBodyFromExtraction(e) {
416
501
  ? e.gpu_requirements.join(', ')
417
502
  : e.gpu_requirements || null,
418
503
  source_summary: e.summary || null,
504
+ // Same free-check evidence /extract already computed for this session --
505
+ // /confirm only persists it (see ConfirmBody's docstring), it never
506
+ // re-derives it. Without these, a case created via `badgr diagnose`
507
+ // (as opposed to the web page's own /confirm call, which already sends
508
+ // them) persisted no evidence at all, so its Run Link's Page 2 showed
509
+ // no "Free Smoke Test" section on a cold load -- not because the
510
+ // section is missing, but because there was nothing in it to render.
511
+ mechanical_checks: e.mechanical_checks || null,
512
+ smoke_check: e.smoke_check || null,
513
+ resolution_evidence: e.resolved_workload || null,
419
514
  };
420
515
  }
421
516
 
422
- function _runPageUrl(caseId) {
423
- return `https://aibadgr.com/run-issue?case_id=${caseId}`;
517
+ function _runPageUrl(caseId, config) {
518
+ return `${webBaseUrl(config)}/run-issue?case_id=${caseId}`;
519
+ }
520
+
521
+ // /run-issue/extract can legitimately take up to 120s (AI extraction) and
522
+ // /run-issue/confirm up to 20s -- both used to print one static line
523
+ // ("Analysing..." / nothing at all) and then go silent until the request
524
+ // resolved, which is indistinguishable from a hung terminal on a slow
525
+ // backend. This redraws a single in-place elapsed-time line every second
526
+ // so a slow response still reads as "working", not "stuck". Only runs when
527
+ // stdout is a TTY -- a piped/non-interactive run (e.g. --json in a script)
528
+ // must not get raw carriage-return control codes mixed into its output.
529
+ async function _withElapsed(chalk, label, promise) {
530
+ if (!process.stdout.isTTY) return promise;
531
+ const startMs = Date.now();
532
+ const tick = () => {
533
+ const elapsedSec = Math.floor((Date.now() - startMs) / 1000);
534
+ process.stdout.write(`\r\x1b[2K${chalk.dim(` ${label} ${elapsedSec}s`)}`);
535
+ };
536
+ tick();
537
+ const timer = setInterval(tick, 1000);
538
+ try {
539
+ return await promise;
540
+ } finally {
541
+ clearInterval(timer);
542
+ process.stdout.write('\r\x1b[2K');
543
+ }
424
544
  }
425
545
 
426
546
  // Creates the free, anonymous "prepared run" case that backs a shareable
@@ -450,7 +570,7 @@ async function _createRunPage(config, e, chalk) {
450
570
  }
451
571
 
452
572
  async function _doApprove(config, caseData, chalk) {
453
- const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
573
+ const { case_id: caseId, status, test_plan: plan, workload_plan: workloadPlan, missing_information: missing } = caseData;
454
574
 
455
575
  if (status === 'incompatible') {
456
576
  console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
@@ -474,13 +594,19 @@ async function _doApprove(config, caseData, chalk) {
474
594
  return;
475
595
  }
476
596
 
477
- if (plan) {
597
+ // Display the canonical WorkloadPlan (backend/reliability_engine.py) --
598
+ // the same object creation -> routing -> Jobs -> watchdog all consume --
599
+ // rather than a second, independently-derived estimate. Falls back to the
600
+ // raw test_plan only for fields the canonical plan doesn't carry
601
+ // (requires_reduced_test / requires_funding_approval are smoke-check-only
602
+ // concerns, never part of WorkloadPlan).
603
+ if (workloadPlan || plan) {
478
604
  console.log();
479
- if (plan.gpu_type) console.log(` ${chalk.bold('GPU:')} ${plan.gpu_type}`);
480
- if (plan.estimated_cost) console.log(` ${chalk.bold('Maximum cost:')} $${Number(plan.estimated_cost).toFixed(2)}`);
481
- if (plan.max_runtime_min) console.log(` ${chalk.bold('Max runtime:')} ${plan.max_runtime_min} min`);
482
- if (plan.requires_reduced_test) console.log(` ${chalk.yellow('!')} Multi-GPU detected test uses a single GPU`);
483
- if (plan.requires_funding_approval) console.log(` ${chalk.yellow('!')} Large download — funding required`);
605
+ if (workloadPlan?.gpu_type) console.log(` ${chalk.bold('GPU:')} ${workloadPlan.gpu_type}`);
606
+ if (workloadPlan?.estimated_cost_usd != null) console.log(` ${chalk.bold('Maximum cost:')} $${Number(workloadPlan.estimated_cost_usd).toFixed(2)}`);
607
+ if (workloadPlan?.deadline_secs) console.log(` ${chalk.bold('Max runtime:')} ${Math.round(workloadPlan.deadline_secs / 60)} min`);
608
+ if (plan?.requires_reduced_test) console.log(` ${chalk.red('!')} Requested topology not supported -- run blocked, see below`);
609
+ if (plan?.requires_funding_approval) console.log(` ${chalk.yellow('!')} Large download — funding required`);
484
610
  }
485
611
 
486
612
  await _approveAndRun(config, caseId, plan, chalk);
@@ -494,8 +620,33 @@ async function _doApprove(config, caseData, chalk) {
494
620
  // it only redeems one a case already carries, same as normal billing.
495
621
  async function _approveAndRun(config, caseId, plan, chalk) {
496
622
  const printResumeHint = () =>
497
- console.error(chalk.dim(` Resume this case: https://aibadgr.com/run-issue?case_id=${caseId}\n`));
623
+ console.error(chalk.dim(` Resume this case: ${webBaseUrl(config)}/run-issue?case_id=${caseId}\n`));
624
+
625
+ // A requested topology Badgr's current runner can't satisfy (e.g.
626
+ // --tensor-parallel-size 2 on a single-GPU-only runner) must never be
627
+ // silently downgraded to a reduced single-GPU test -- that used to
628
+ // happen here automatically (reduced_test_confirmed defaulted to
629
+ // Boolean(plan.requires_reduced_test), so plain --approve auto-consented
630
+ // on the caller's behalf). A reduced test is only ever a distinct,
631
+ // explicit secondary choice -- the web /run-issue page already offers
632
+ // that as its own "Run reduced 1-GPU acceptance test instead" action;
633
+ // the CLI has no equivalent explicit opt-in yet, so it blocks here
634
+ // rather than inventing implicit consent.
635
+ if (plan?.requires_reduced_test) {
636
+ const detected = plan.multi_gpu_detected;
637
+ console.error(chalk.red('\n ✗ Cannot run this workload as requested\n'));
638
+ if (detected?.gpu_count) {
639
+ console.error(` This command requires ${detected.gpu_count} GPUs. Badgr's current verification runner supports only 1 GPU.\n`);
640
+ }
641
+ console.error(chalk.dim(` A reduced 1-GPU test is available as an explicit separate choice on the case page, not via --approve:`));
642
+ console.error(chalk.dim(` ${webBaseUrl(config)}/run-issue?case_id=${caseId}\n`));
643
+ process.exitCode = 1;
644
+ return;
645
+ }
498
646
 
647
+ // Same just-in-time login flow badgr run/serve/comfyui/job trigger — no
648
+ // separate `badgr login` step, and once the browser login completes this
649
+ // function keeps running right here with the now-populated config.
499
650
  let authConfig = config;
500
651
  if (!authConfig.apiKey) {
501
652
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -530,13 +681,20 @@ async function _approveAndRun(config, caseId, plan, chalk) {
530
681
  body: {
531
682
  confirmed: true,
532
683
  funding_approved: Boolean(plan?.requires_funding_approval),
533
- reduced_test_confirmed: Boolean(plan?.requires_reduced_test),
684
+ // Always false: the guard above already returned before this point
685
+ // for any plan with requires_reduced_test set -- never re-derive
686
+ // implicit consent from the plan here.
687
+ reduced_test_confirmed: false,
534
688
  },
535
689
  timeoutMs: 15_000,
536
690
  });
537
691
  } catch (err) {
538
- if (err.httpStatus === 402 || err.message?.includes('402')) {
539
- console.error(chalk.yellow('\n ✗ Insufficient credits. Run: badgr billing\n'));
692
+ // Same isPaymentRequired convention every other paid command follows
693
+ // (run.js, serve.js, train.js, ...) -- api.js already builds the full
694
+ // balance/required/top-up message onto err.message for a 402.
695
+ if (err.isPaymentRequired) {
696
+ console.error(chalk.yellow(err.message));
697
+ printResumeHint();
540
698
  } else {
541
699
  console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
542
700
  }
@@ -544,24 +702,29 @@ async function _approveAndRun(config, caseId, plan, chalk) {
544
702
  return;
545
703
  }
546
704
 
547
- console.log(chalk.dim('\n Launching capped smoke test...'));
705
+ console.log(chalk.dim('\n Launching Badgr Run...'));
548
706
  let ran;
549
707
  try {
550
708
  ran = await callApi(`/run-issue/cases/${caseId}/run`, {
551
709
  method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
552
710
  });
553
711
  } catch (err) {
554
- console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
712
+ if (err.isPaymentRequired) {
713
+ console.error(chalk.yellow(err.message));
714
+ printResumeHint();
715
+ } else {
716
+ console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
717
+ }
555
718
  process.exitCode = 1;
556
719
  return;
557
720
  }
558
721
 
559
722
  console.log();
560
- console.log(chalk.green(' Smoke test launched.'));
723
+ console.log(chalk.green(' Run started.'));
561
724
  console.log(` ${chalk.bold('Case:')} ${caseId}`);
562
725
  if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
563
726
  if (ran?.free_run_used) console.log(` ${chalk.bold('Billing:')} Free run — no charge`);
564
- console.log(` ${chalk.bold('Evidence:')} https://aibadgr.com/repro/${caseId}`);
727
+ console.log(` ${chalk.bold('Evidence:')} ${webBaseUrl(config)}/repro/${caseId}`);
565
728
  console.log();
566
729
  }
567
730
 
@@ -618,8 +781,8 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
618
781
  ? caseData.lead_summary.test_checklist
619
782
  : ['Verified'];
620
783
  for (const line of resultLines) console.log(` - ${line}`);
621
- if (caseData.actual_cost_aud != null) {
622
- console.log(` Verified on Badgr for AUD $${Number(caseData.actual_cost_aud).toFixed(2)}. GPU torn down after the test.`);
784
+ if (caseData.actual_cost_usd != null) {
785
+ console.log(` Verified on Badgr for $${Number(caseData.actual_cost_usd).toFixed(2)} USD. GPU torn down after the test.`);
623
786
  }
624
787
  }
625
788
 
@@ -628,8 +791,9 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
628
791
  console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
629
792
  } else if (caseData.free_verification_available) {
630
793
  console.log(` ${chalk.green(`1 free verification job available — up to $${(caseData.free_verification_max_cost_usd ?? 5).toFixed(0)}`)}`);
631
- } else if (caseData.test_plan) {
632
- console.log(` ${chalk.dim(`Verification available max $${Number(caseData.test_plan.max_cost_usd ?? 0).toFixed(2)}`)}`);
794
+ } else if (caseData.workload_plan || caseData.test_plan) {
795
+ const capUsd = caseData.workload_plan?.recommended_cap_usd ?? caseData.test_plan?.max_cost_usd ?? 0;
796
+ console.log(` ${chalk.dim(`Verification available — max $${Number(capUsd).toFixed(2)}`)}`);
633
797
  }
634
798
  console.log();
635
799
 
@@ -648,7 +812,15 @@ export async function diagnoseCommand(config, args, chalk) {
648
812
  }
649
813
 
650
814
  const flags = {};
651
- let positional = null;
815
+ // Every non-flag token is joined into one input string, not just the
816
+ // first -- `badgr diagnose vllm serve facebook/opt-125m` (unquoted) must
817
+ // diagnose the same thing as `badgr diagnose "vllm serve
818
+ // facebook/opt-125m"` (quoted). Previously only args[0] after flags
819
+ // ("vllm") became `positional` and every token after it was silently
820
+ // dropped, so an unquoted multi-word command was diagnosed as if the
821
+ // user had only pasted its first word -- bizarrely resolving to a
822
+ // Docker-image lookup for an image literally named "vllm".
823
+ const positionalParts = [];
652
824
  let i = 0;
653
825
  while (i < args.length) {
654
826
  const a = args[i];
@@ -659,9 +831,10 @@ export async function diagnoseCommand(config, args, chalk) {
659
831
  if (a === '--approve') { flags.approve = true; i++; continue; }
660
832
  if (a === '--smoke') { flags.smoke = true; i++; continue; }
661
833
  if (a === '--json') { flags.json = true; i++; continue; }
662
- if (!a.startsWith('-') && positional === null) positional = a;
834
+ if (!a.startsWith('-')) positionalParts.push(a);
663
835
  i++;
664
836
  }
837
+ const positional = positionalParts.length ? positionalParts.join(' ') : null;
665
838
 
666
839
  if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
667
840
  console.log(DIAGNOSE_HELP);
@@ -694,25 +867,34 @@ export async function diagnoseCommand(config, args, chalk) {
694
867
  if (!flags.json) {
695
868
  console.log();
696
869
  console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
697
- console.log(chalk.dim(' Analysing...'));
870
+ if (detected.kind === 'command') {
871
+ const preview = _previewLaunchCommand(positional);
872
+ if (preview) {
873
+ console.log(` ${chalk.bold('Runtime:')} ${preview.runtime}`);
874
+ console.log(` ${chalk.bold('Model:')} ${preview.model}`);
875
+ }
876
+ }
698
877
  }
699
878
 
700
879
  let result;
701
880
  try {
702
- result = await callApi('/run-issue/extract', {
881
+ const extractPromise = callApi('/run-issue/extract', {
703
882
  method: 'POST',
704
883
  apiKey: config.apiKey || '',
705
884
  baseUrl: config.baseUrl,
706
885
  body: detected.body,
707
886
  timeoutMs: 120_000,
708
887
  });
888
+ result = flags.json
889
+ ? await extractPromise
890
+ : await _withElapsed(chalk, 'Running free checks...', extractPromise);
709
891
  } catch (err) {
710
892
  console.error(chalk.red(`\n ✗ ${err.message}\n`));
711
893
  process.exitCode = 1;
712
894
  return;
713
895
  }
714
896
 
715
- const status = _statusWord(result, flags.smoke);
897
+ const status = _statusWord(result);
716
898
 
717
899
  // A command that's actually runnable also gets a free prepared Run
718
900
  // page -- the same case /run-issue's own confirm step would create --
@@ -725,14 +907,20 @@ export async function diagnoseCommand(config, args, chalk) {
725
907
  let caseData = null;
726
908
  let caseError = null;
727
909
  if (status === 'READY' || status === 'SMOKE CHECKED') {
728
- const created = await _createRunPage(config, result.extraction || {}, chalk);
910
+ const createPromise = _createRunPage(config, result.extraction || {}, chalk);
911
+ const created = flags.json
912
+ ? await createPromise
913
+ : await _withElapsed(chalk, 'Preparing Run link...', createPromise);
729
914
  if (created.error) caseError = created.error;
730
915
  else caseData = created.caseData;
731
916
  }
732
917
 
733
918
  if (flags.json) {
734
919
  const output = { detected: detected.kind, ...result, status };
735
- if (flags.smoke) {
920
+ // Applicable free checks always run as part of plain diagnosis now
921
+ // (see _statusWord's comment) -- always included here too, not gated
922
+ // behind the deprecated --smoke flag.
923
+ {
736
924
  const e = result.extraction || {};
737
925
  output.smoke = {
738
926
  mechanical_checks: e.mechanical_checks || [],
@@ -742,7 +930,7 @@ export async function diagnoseCommand(config, args, chalk) {
742
930
  if (caseData?.case_id) {
743
931
  output.run = {
744
932
  case_id: caseData.case_id,
745
- url: _runPageUrl(caseData.case_id),
933
+ url: _runPageUrl(caseData.case_id, config),
746
934
  canonical_command: caseData.findings?.canonical_command || null,
747
935
  };
748
936
  }
@@ -772,15 +960,16 @@ export async function diagnoseCommand(config, args, chalk) {
772
960
  console.log();
773
961
  _printResult(result, chalk);
774
962
 
775
- if (flags.smoke) {
776
- _printSmokeChecks(result, chalk);
777
- }
963
+ // Applicable free checks always run as part of plain diagnosis now
964
+ // (see _statusWord's comment) -- always printed, not gated behind the
965
+ // deprecated --smoke flag.
966
+ _printSmokeChecks(result, chalk);
778
967
 
779
968
  console.log();
780
- _printCopySummary(result, flags, chalk, caseData);
969
+ _printCopySummary(result, chalk, caseData, config);
781
970
 
782
971
  console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
783
- console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
972
+ console.log(chalk.dim(` Full interactive flow: ${webBaseUrl(config)}/run-issue`));
784
973
  console.log();
785
974
 
786
975
  if (flags.approve) {