badgr-cli 1.1.3 → 1.1.5

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.
@@ -1,11 +1,14 @@
1
1
  import fs from 'fs';
2
+ import { classifyPastedInput } from 'badgr-shared';
2
3
  import { callApi } from '../api.js';
3
4
  import { ensureLoggedIn } from '../onboarding.js';
5
+ import { webBaseUrl } from '../config.js';
4
6
 
5
7
  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.
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.
9
12
 
10
13
  Usage:
11
14
  badgr diagnose "<input>"
@@ -24,8 +27,14 @@ Input (auto-detected):
24
27
  Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
25
28
 
26
29
  Flags:
27
- --approve Approve the capped smoke test after diagnosis (opens
28
- browser sign-in automatically if not logged in)
30
+ --approve Launch a real Badgr Run after diagnosis (opens
31
+ browser sign-in automatically if not logged in).
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.
29
38
  --docker <image> Force Docker-image intake (override auto-detect)
30
39
  --repo <url> Force repository intake (override auto-detect)
31
40
  --comfyui <path> Force ComfyUI workflow intake (override auto-detect)
@@ -35,11 +44,63 @@ Flags:
35
44
  --json Machine-readable JSON output
36
45
  --help, -h Show this help
37
46
 
47
+ Status: every run prints one of NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
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).
60
+
61
+ Run page: READY and SMOKE CHECKED results also create a free, anonymous
62
+ "Run" link (the same prepared case /run-issue's own confirm form
63
+ creates) -- a saved, runnable configuration you or anyone with the
64
+ link can open and click Run on. NEEDS INFO and INVALID never get
65
+ one (nothing runnable to save, or a check just proved it broken)
66
+ -- both print the correction instead. The page shows the exact
67
+ canonical "badgr ..." command --approve would run, not merely the
68
+ source command extraction found. Still no GPU starts until that
69
+ click. --approve reuses this same case rather than creating a
70
+ second one.
71
+
38
72
  Safety: nothing runs from AI-extracted data without --approve.
39
73
  No GPU launches without explicit approval and a credit check.
74
+ Diagnosis (with or without its automatic checks) never provisions
75
+ a GPU and never requires login.
40
76
 
41
77
  Full interactive flow: https://aibadgr.com/run-issue`;
42
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
+
43
104
  function detectInput(raw, flags) {
44
105
  if (flags.docker) {
45
106
  return {
@@ -66,32 +127,27 @@ function detectInput(raw, flags) {
66
127
 
67
128
  if (!raw) return null;
68
129
 
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 };
130
+ // Case-id / GitHub-URL-shape classification is shared with the web page
131
+ // (`classifyPastedInput`, packages/shared/src/index.ts) so the CLI and
132
+ // /run-issue never disagree about what a given paste is. A case_id (bare,
133
+ // or embedded in a /repro/<id> or /run-issue?case_id=<id> link — the two
134
+ // shapes an admin-prepared or self-created case gets shared as) resumes
135
+ // that existing case instead of diagnosing new input.
136
+ const classified = classifyPastedInput(raw);
137
+ if (classified.kind === 'existing_case') {
138
+ return { kind: 'existing_case', label: `Existing case: ${classified.body.case_id}`, caseId: classified.body.case_id };
77
139
  }
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
- };
140
+ if (classified.kind === 'github_issue') {
141
+ return { kind: 'github_issue', label: `GitHub issue: ${raw}`, body: classified.body };
85
142
  }
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
- };
143
+ if (classified.kind === 'repo_url') {
144
+ return { kind: 'repo_url', label: `GitHub repository: ${raw}`, body: classified.body };
93
145
  }
94
146
 
147
+ // Local filesystem paths -- only the CLI has a filesystem to check
148
+ // against, so this stays CLI-only, sitting between the shared URL checks
149
+ // above and the shared Docker-image/text fallback below (matches
150
+ // classifyPastedInput's own precedence for everything except this).
95
151
  if (fs.existsSync(raw)) {
96
152
  const content = fs.readFileSync(raw, 'utf8');
97
153
  if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
@@ -112,27 +168,34 @@ function detectInput(raw, flags) {
112
168
  };
113
169
  }
114
170
 
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
- };
171
+ if (classified.kind === 'docker_image') {
172
+ return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
125
173
  }
126
174
 
127
- return {
128
- kind: 'text',
129
- label: 'Text / conversation',
130
- body: { text: raw.slice(0, 50_000) },
131
- };
175
+ if (classified.kind === 'command') {
176
+ return { kind: 'command', label: 'Explicit command', body: classified.body };
177
+ }
178
+
179
+ return { kind: 'text', label: 'Text / conversation', body: classified.body };
180
+ }
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';
132
194
  }
133
195
 
134
196
  function _printResult(result, chalk) {
135
197
  const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
198
+ const isService = _isServiceShape(e);
136
199
 
137
200
  if (github_issue) {
138
201
  console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
@@ -176,12 +239,19 @@ function _printResult(result, chalk) {
176
239
  for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
177
240
  } else if (wt) {
178
241
  console.log();
179
- 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'}`);
180
245
  }
181
246
 
182
247
  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.')}`);
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.')}`);
185
255
  }
186
256
  if (large_download) {
187
257
  console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
@@ -199,16 +269,227 @@ function _printResult(result, chalk) {
199
269
  console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
200
270
  } else if (e.missing_information?.length) {
201
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.')}`);
202
280
  } else {
203
- 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)`)}`);
289
+ }
290
+ }
291
+
292
+ // Local-machine file paths (e.g. a pasted .gguf path) can only be checked
293
+ // client-side -- the backend never sees this machine's filesystem, so it
294
+ // must not (and does not) claim to validate them. Only paths that look like
295
+ // a real filesystem reference (absolute, `./`, `../`, or `~/`) are checked;
296
+ // bare words, URLs, and Docker image references are left alone.
297
+ //
298
+ // Path checks must respect context (locked Free Command Check spec, problem
299
+ // #3): a repo path is checked against the repo tree, a container path
300
+ // against Dockerfile/image evidence -- both server-side, inside
301
+ // `mechanical_checks` -- and only a genuinely local path belongs to this
302
+ // machine. Once a repository or Docker image is in evidence for this
303
+ // workload, an absolute path *inside the resolved command* (e.g. "python3
304
+ // /src/main.py") is a repo/container path, not a claim about this laptop,
305
+ // so it must never be re-checked against the local filesystem here -- doing
306
+ // so is exactly the "/src/main.py NOT FOUND on this machine" false failure
307
+ // the spec calls out. `model_artifact` is different: it is always this
308
+ // machine's own claim about a locally-downloaded checkpoint, never repo or
309
+ // container evidence, so it stays checked regardless.
310
+ function _localPathCandidates(e) {
311
+ const candidates = new Set();
312
+ const hasRepoOrContainerContext = Boolean(
313
+ e.repositories?.length || e.docker_images?.length || e.dockerfile
314
+ );
315
+ const maybeAdd = (value) => {
316
+ if (typeof value !== 'string') return;
317
+ const s = value.trim().replace(/^['"]|['"]$/g, '');
318
+ if (!s || /^https?:\/\//i.test(s)) return;
319
+ if (s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('~/')) {
320
+ candidates.add(s);
321
+ }
322
+ };
323
+ maybeAdd(e.model_artifact);
324
+ if (!hasRepoOrContainerContext) {
325
+ for (const tok of (e.commands?.[0] || '').split(/\s+/)) maybeAdd(tok);
326
+ }
327
+ return [...candidates];
328
+ }
329
+
330
+ // Locked status ladder (product-level labels, not the backend's internal
331
+ // proof_level enum): NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
332
+ // Pure presentation over data /run-issue/extract already returns -- no new
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.
338
+ //
339
+ // The evidence-only judgment (needs_info/invalid/smoke_checked) is computed
340
+ // once, server-side, in `_compute_smoke_status` (backend/run_issue_routes.py)
341
+ // and read here via `e.smoke_status` -- this and /run-issue's `smokeStatus()`
342
+ // (frontend/app/run-issue/page.tsx) both just render that one backend
343
+ // verdict rather than each re-deriving pass/fail from mechanical_checks, so
344
+ // the CLI and the web page can never disagree on what a given result means.
345
+ // A `smoke_status` fallback derivation is kept only for a backend response
346
+ // that predates this field (defensive, not the normal path).
347
+ //
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) {
356
+ const e = result.extraction || {};
357
+ if (result.static_incompatibilities?.length || e.missing_information?.length) return 'NEEDS INFO';
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
+ );
370
+ const localFail = _localPathCandidates(e).some((p) => !fs.existsSync(p));
371
+ if (backendStatus === 'invalid' || localFail) return 'INVALID';
372
+ return backendStatus === 'ready' ? 'READY' : 'SMOKE CHECKED';
373
+ }
374
+
375
+ const _CHECK_LABELS = {
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',
383
+ };
384
+
385
+ // Compact, copy-pasteable result -- the literal STATUS word plus just enough
386
+ // to forward to someone else. Additive to the detailed report above it;
387
+ // does not replace it. Never prints "Verified" -- SMOKE CHECKED is the
388
+ // ceiling for this free path.
389
+ function _printCopySummary(result, chalk, caseData, config) {
390
+ const e = result.extraction || {};
391
+ const status = _statusWord(result);
392
+
393
+ console.log(` ${chalk.bold(`STATUS: ${status}`)}`);
394
+ console.log();
395
+
396
+ if (status === 'NEEDS INFO') {
397
+ // Static incompatibilities and missing-information both mean "no
398
+ // command yet" -- whichever fired is the reason to surface here.
399
+ const reasons = result.static_incompatibilities?.length
400
+ ? result.static_incompatibilities
401
+ : (e.missing_information || []);
402
+ console.log(` ${chalk.bold('Missing:')}`);
403
+ for (const r of reasons) console.log(` - ${r}`);
404
+ console.log();
405
+ return;
406
+ }
407
+
408
+ // The prepared case's canonical `badgr ...` command (the exact line
409
+ // --approve would run) is preferred over the raw source command --
410
+ // /confirm can only compute it once a case exists (READY/SMOKE CHECKED),
411
+ // and only when there's enough evidence to convert (see
412
+ // _canonical_badgr_command's docstring, backend/run_issue_routes.py, for
413
+ // what it can't yet convert, e.g. training/ComfyUI). Falls back to the
414
+ // raw extracted command otherwise -- still true and still runnable input,
415
+ // just not guaranteed to already be `badgr`-shaped.
416
+ const badgrJob = caseData?.findings?.canonical_command || e.commands?.[0] || '(no command produced)';
417
+ console.log(` ${chalk.bold('Badgr job:')}`);
418
+ console.log(` ${badgrJob}`);
419
+ console.log();
420
+
421
+ if (caseData?.case_id) {
422
+ console.log(` ${chalk.bold('Run:')}`);
423
+ console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id, config))}`);
424
+ console.log();
425
+ }
426
+
427
+ if (status === 'SMOKE CHECKED' || status === 'INVALID') {
428
+ console.log(` ${chalk.bold('Checks:')}`);
429
+ const iconFor = (s) => (s === 'pass' ? '✓' : s === 'fail' ? '✗' : '-');
430
+ for (const check of (e.mechanical_checks || [])) {
431
+ console.log(` ${iconFor(check.status)} ${_CHECK_LABELS[check.name] || check.name}`);
432
+ }
433
+ const localPaths = _localPathCandidates(e);
434
+ if (!localPaths.length) {
435
+ console.log(' - local file check skipped');
436
+ } else {
437
+ const allExist = localPaths.every((p) => fs.existsSync(p));
438
+ console.log(` ${allExist ? '✓' : '✗'} local file check${allExist ? '' : ' (not found)'}`);
439
+ }
440
+ console.log();
441
+ if (status === 'INVALID') {
442
+ console.log(' A check above actually failed -- this command is not runnable as-is.');
443
+ } else {
444
+ console.log(' Not GPU-verified.');
445
+ }
446
+ console.log();
447
+ }
448
+ }
449
+
450
+ // --smoke's mechanical checks: prints the backend's evidence-backed
451
+ // pass/fail/skip results (`extraction.mechanical_checks`, computed for
452
+ // free from data already gathered by /run-issue/extract -- no extra
453
+ // network call), plus this machine's own local-file existence check.
454
+ // Deliberately never prints "Verified" -- see AGENTS.md §12; a genuinely
455
+ // passing smoke check still only earns "Smoke Checked".
456
+ function _printSmokeChecks(result, chalk) {
457
+ const e = result.extraction || {};
458
+ const mech = e.mechanical_checks || [];
459
+ const localPaths = _localPathCandidates(e);
460
+
461
+ console.log();
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):')}`);
469
+
470
+ const icon = (status) => (
471
+ status === 'pass' ? chalk.green('✓') : status === 'fail' ? chalk.red('✗') : chalk.dim('-')
472
+ );
473
+ for (const check of mech) {
474
+ console.log(` ${icon(check.status)} ${chalk.dim(`[${check.name}]`)} ${check.detail}`);
475
+ }
476
+ for (const p of localPaths) {
477
+ const exists = fs.existsSync(p);
478
+ console.log(` ${exists ? chalk.green('✓') : chalk.red('✗')} ${chalk.dim('[local_file]')} ${p} ${exists ? 'exists on this machine' : 'NOT FOUND on this machine'}`);
479
+ }
480
+ if (!mech.length && !localPaths.length) {
481
+ console.log(` ${chalk.dim('No mechanical checks applicable to this input.')}`);
204
482
  }
483
+
484
+ const proofLevel = e.smoke_check?.proof_level;
485
+ if (proofLevel === 'smoke_checked') {
486
+ console.log(` ${chalk.green('✓')} ${chalk.dim('[command_scope]')} Command is the repository/image/service's own documented default.`);
487
+ }
488
+ console.log();
205
489
  }
206
490
 
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 = {
491
+ function _confirmBodyFromExtraction(e) {
492
+ return {
212
493
  workload_type: e.workload_type || 'generic',
213
494
  docker_image: e.docker_images?.[0] || null,
214
495
  model_id: e.models?.[0] || null,
@@ -220,25 +501,76 @@ async function _doApprove(config, result, chalk) {
220
501
  ? e.gpu_requirements.join(', ')
221
502
  : e.gpu_requirements || null,
222
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,
223
514
  };
515
+ }
224
516
 
225
- let caseData;
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);
226
538
  try {
227
- console.log(chalk.dim(' Creating case...'));
228
- caseData = await callApi('/run-issue/confirm', {
539
+ return await promise;
540
+ } finally {
541
+ clearInterval(timer);
542
+ process.stdout.write('\r\x1b[2K');
543
+ }
544
+ }
545
+
546
+ // Creates the free, anonymous "prepared run" case that backs a shareable
547
+ // Run Link -- the exact same /run-issue/confirm call and case object
548
+ // /run-issue's own confirm step creates, so the CLI and the web page never
549
+ // diverge on what a given diagnosis resolves to. No GPU, no login, no
550
+ // payment -- confirm only ever inspects and plans (see its own docstring).
551
+ // Called once per diagnosis (main flow), then reused by --approve below
552
+ // instead of confirming a second time.
553
+ //
554
+ // Returns `{ caseData }` on success, `{ error }` on a network/API failure --
555
+ // never throws, since a plain diagnosis must still succeed even if the
556
+ // free run-page creation itself fails.
557
+ async function _createRunPage(config, e, chalk) {
558
+ try {
559
+ const caseData = await callApi('/run-issue/confirm', {
229
560
  method: 'POST',
230
561
  apiKey: config.apiKey || '',
231
562
  baseUrl: config.baseUrl,
232
- body: confirmBody,
563
+ body: _confirmBodyFromExtraction(e),
233
564
  timeoutMs: 20_000,
234
565
  });
566
+ return caseData?.case_id ? { caseData } : { caseData: null };
235
567
  } catch (err) {
236
- console.error(chalk.red(`\n ✗ Could not create case: ${err.message}\n`));
237
- process.exitCode = 1;
238
- return;
568
+ return { error: err };
239
569
  }
570
+ }
240
571
 
241
- const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
572
+ async function _doApprove(config, caseData, chalk) {
573
+ const { case_id: caseId, status, test_plan: plan, workload_plan: workloadPlan, missing_information: missing } = caseData;
242
574
 
243
575
  if (status === 'incompatible') {
244
576
  console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
@@ -262,13 +594,19 @@ async function _doApprove(config, result, chalk) {
262
594
  return;
263
595
  }
264
596
 
265
- 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) {
266
604
  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`);
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`);
272
610
  }
273
611
 
274
612
  await _approveAndRun(config, caseId, plan, chalk);
@@ -282,8 +620,33 @@ async function _doApprove(config, result, chalk) {
282
620
  // it only redeems one a case already carries, same as normal billing.
283
621
  async function _approveAndRun(config, caseId, plan, chalk) {
284
622
  const printResumeHint = () =>
285
- 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
+ }
286
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.
287
650
  let authConfig = config;
288
651
  if (!authConfig.apiKey) {
289
652
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -318,13 +681,20 @@ async function _approveAndRun(config, caseId, plan, chalk) {
318
681
  body: {
319
682
  confirmed: true,
320
683
  funding_approved: Boolean(plan?.requires_funding_approval),
321
- 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,
322
688
  },
323
689
  timeoutMs: 15_000,
324
690
  });
325
691
  } catch (err) {
326
- if (err.httpStatus === 402 || err.message?.includes('402')) {
327
- 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();
328
698
  } else {
329
699
  console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
330
700
  }
@@ -332,24 +702,29 @@ async function _approveAndRun(config, caseId, plan, chalk) {
332
702
  return;
333
703
  }
334
704
 
335
- console.log(chalk.dim('\n Launching capped smoke test...'));
705
+ console.log(chalk.dim('\n Launching Badgr Run...'));
336
706
  let ran;
337
707
  try {
338
708
  ran = await callApi(`/run-issue/cases/${caseId}/run`, {
339
709
  method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
340
710
  });
341
711
  } catch (err) {
342
- 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
+ }
343
718
  process.exitCode = 1;
344
719
  return;
345
720
  }
346
721
 
347
722
  console.log();
348
- console.log(chalk.green(' Smoke test launched.'));
723
+ console.log(chalk.green(' Run started.'));
349
724
  console.log(` ${chalk.bold('Case:')} ${caseId}`);
350
725
  if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
351
726
  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}`);
727
+ console.log(` ${chalk.bold('Evidence:')} ${webBaseUrl(config)}/repro/${caseId}`);
353
728
  console.log();
354
729
  }
355
730
 
@@ -386,13 +761,39 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
386
761
  for (const m of caseData.missing_information) console.log(` ? ${m}`);
387
762
  }
388
763
 
764
+ // "Verified" is only ever earned here -- a case whose real GPU run
765
+ // actually completed (`lead_summary.status`, computed server-side by
766
+ // `_build_lead_summary` from `ReproCase.status`/`final_command`). Never
767
+ // reachable from --smoke, which never provisions anything.
768
+ console.log();
769
+ const existingStatus = caseData.lead_summary?.status === 'verified'
770
+ || caseData.status === 'verified' || caseData.status === 'completed'
771
+ ? 'VERIFIED'
772
+ : (caseData.missing_information || []).length ? 'NEEDS INFO' : 'READY';
773
+ console.log(` ${chalk.bold(`STATUS: ${existingStatus}`)}`);
774
+ if (existingStatus === 'VERIFIED') {
775
+ console.log();
776
+ console.log(` ${chalk.bold('Command:')}`);
777
+ console.log(` ${caseData.final_command || caseData.known_command}`);
778
+ console.log();
779
+ console.log(` ${chalk.bold('Result:')}`);
780
+ const resultLines = caseData.lead_summary?.test_checklist?.length
781
+ ? caseData.lead_summary.test_checklist
782
+ : ['Verified'];
783
+ for (const line of resultLines) console.log(` - ${line}`);
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.`);
786
+ }
787
+ }
788
+
389
789
  console.log();
390
790
  if (caseData.free_verification_consumed) {
391
791
  console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
392
792
  } else if (caseData.free_verification_available) {
393
793
  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)}`)}`);
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)}`)}`);
396
797
  }
397
798
  console.log();
398
799
 
@@ -411,7 +812,15 @@ export async function diagnoseCommand(config, args, chalk) {
411
812
  }
412
813
 
413
814
  const flags = {};
414
- 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 = [];
415
824
  let i = 0;
416
825
  while (i < args.length) {
417
826
  const a = args[i];
@@ -420,10 +829,12 @@ export async function diagnoseCommand(config, args, chalk) {
420
829
  if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
421
830
  if (a === '--github') { flags.github = args[++i]; i++; continue; }
422
831
  if (a === '--approve') { flags.approve = true; i++; continue; }
832
+ if (a === '--smoke') { flags.smoke = true; i++; continue; }
423
833
  if (a === '--json') { flags.json = true; i++; continue; }
424
- if (!a.startsWith('-') && positional === null) positional = a;
834
+ if (!a.startsWith('-')) positionalParts.push(a);
425
835
  i++;
426
836
  }
837
+ const positional = positionalParts.length ? positionalParts.join(' ') : null;
427
838
 
428
839
  if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
429
840
  console.log(DIAGNOSE_HELP);
@@ -456,38 +867,121 @@ export async function diagnoseCommand(config, args, chalk) {
456
867
  if (!flags.json) {
457
868
  console.log();
458
869
  console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
459
- 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
+ }
460
877
  }
461
878
 
462
879
  let result;
463
880
  try {
464
- result = await callApi('/run-issue/extract', {
881
+ const extractPromise = callApi('/run-issue/extract', {
465
882
  method: 'POST',
466
883
  apiKey: config.apiKey || '',
467
884
  baseUrl: config.baseUrl,
468
885
  body: detected.body,
469
886
  timeoutMs: 120_000,
470
887
  });
888
+ result = flags.json
889
+ ? await extractPromise
890
+ : await _withElapsed(chalk, 'Running free checks...', extractPromise);
471
891
  } catch (err) {
472
892
  console.error(chalk.red(`\n ✗ ${err.message}\n`));
473
893
  process.exitCode = 1;
474
894
  return;
475
895
  }
476
896
 
897
+ const status = _statusWord(result);
898
+
899
+ // A command that's actually runnable also gets a free prepared Run
900
+ // page -- the same case /run-issue's own confirm step would create --
901
+ // so diagnosis ends at a page someone can click Run on, not just a
902
+ // printed command. Only READY and SMOKE CHECKED qualify: NEEDS INFO has
903
+ // nothing runnable to save yet, and INVALID is a command a real check
904
+ // just proved broken -- turning that into a clickable "Run" page would
905
+ // contradict the label. Both print their own guidance instead (Missing:
906
+ // / the failed check) and stop there.
907
+ let caseData = null;
908
+ let caseError = null;
909
+ if (status === 'READY' || status === 'SMOKE CHECKED') {
910
+ const createPromise = _createRunPage(config, result.extraction || {}, chalk);
911
+ const created = flags.json
912
+ ? await createPromise
913
+ : await _withElapsed(chalk, 'Preparing Run link...', createPromise);
914
+ if (created.error) caseError = created.error;
915
+ else caseData = created.caseData;
916
+ }
917
+
477
918
  if (flags.json) {
478
- console.log(JSON.stringify({ detected: detected.kind, ...result }, null, 2));
919
+ const output = { detected: detected.kind, ...result, status };
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
+ {
924
+ const e = result.extraction || {};
925
+ output.smoke = {
926
+ mechanical_checks: e.mechanical_checks || [],
927
+ local_file_checks: _localPathCandidates(e).map(p => ({ path: p, exists: fs.existsSync(p) })),
928
+ };
929
+ }
930
+ if (caseData?.case_id) {
931
+ output.run = {
932
+ case_id: caseData.case_id,
933
+ url: _runPageUrl(caseData.case_id, config),
934
+ canonical_command: caseData.findings?.canonical_command || null,
935
+ };
936
+ }
937
+ console.log(JSON.stringify(output, null, 2));
938
+
939
+ // --json must not silently skip --approve -- the JSON branch used to
940
+ // return here unconditionally, so a scripted `--approve --json` call
941
+ // never claimed/approved/launched anything (bug: approval was silently
942
+ // dropped in the one output mode automation actually uses). Approval
943
+ // output itself stays human-readable (chalk console lines), matching
944
+ // _diagnoseExistingCase's existing --json + --approve behaviour below.
945
+ if (flags.approve) {
946
+ if (caseError) {
947
+ console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
948
+ process.exitCode = 1;
949
+ return;
950
+ }
951
+ if (!caseData) {
952
+ console.error(chalk.yellow('\n Cannot approve — missing information above.\n'));
953
+ return;
954
+ }
955
+ await _doApprove(config, caseData, chalk);
956
+ }
479
957
  return;
480
958
  }
481
959
 
482
960
  console.log();
483
961
  _printResult(result, chalk);
484
962
 
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);
967
+
485
968
  console.log();
969
+ _printCopySummary(result, chalk, caseData, config);
970
+
486
971
  console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
487
- console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
972
+ console.log(chalk.dim(` Full interactive flow: ${webBaseUrl(config)}/run-issue`));
488
973
  console.log();
489
974
 
490
975
  if (flags.approve) {
491
- await _doApprove(config, result, chalk);
976
+ if (caseError) {
977
+ console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
978
+ process.exitCode = 1;
979
+ return;
980
+ }
981
+ if (!caseData) {
982
+ console.log(chalk.yellow('\n Cannot approve — missing information above.\n'));
983
+ return;
984
+ }
985
+ await _doApprove(config, caseData, chalk);
492
986
  }
493
987
  }