badgr-cli 1.1.2 → 1.1.4

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.
@@ -0,0 +1,798 @@
1
+ import fs from 'fs';
2
+ import { classifyPastedInput } from 'badgr-shared';
3
+ import { callApi } from '../api.js';
4
+ import { ensureLoggedIn } from '../onboarding.js';
5
+
6
+ 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.
10
+
11
+ Usage:
12
+ badgr diagnose "<input>"
13
+ badgr diagnose "<input>" --smoke
14
+ badgr diagnose "<input>" --approve
15
+ badgr diagnose "<input>" --json
16
+ badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
17
+ one shared via a case link)
18
+
19
+ Input (auto-detected):
20
+ GitHub issue URL https://github.com/org/repo/issues/N
21
+ GitHub repo URL https://github.com/org/repo
22
+ Docker image owner/image:tag or image:tag
23
+ Local log / file ./error.log or vllm.log
24
+ ComfyUI workflow workflow.json (JSON file)
25
+ Raw conversation paste Discord / Slack / support thread text
26
+ Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
27
+
28
+ 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
44
+ 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.
47
+ --docker <image> Force Docker-image intake (override auto-detect)
48
+ --repo <url> Force repository intake (override auto-detect)
49
+ --comfyui <path> Force ComfyUI workflow intake (override auto-detect)
50
+ --github <url> Include a GitHub issue URL found inside pasted text
51
+ as additional context. Opt-in only, never fetched
52
+ automatically (see "Additional context found" below)
53
+ --json Machine-readable JSON output
54
+ --help, -h Show this help
55
+
56
+ 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)
62
+
63
+ Run page: READY and SMOKE CHECKED results also create a free, anonymous
64
+ "Run" link (the same prepared case /run-issue's own confirm form
65
+ creates) -- a saved, runnable configuration you or anyone with the
66
+ link can open and click Run on. NEEDS INFO and INVALID never get
67
+ one (nothing runnable to save, or a check just proved it broken)
68
+ -- both print the correction instead. The page shows the exact
69
+ canonical "badgr ..." command --approve would run, not merely the
70
+ source command extraction found. Still no GPU starts until that
71
+ click. --approve reuses this same case rather than creating a
72
+ second one.
73
+
74
+ Safety: nothing runs from AI-extracted data without --approve.
75
+ No GPU launches without explicit approval and a credit check.
76
+ --smoke never provisions a GPU and never requires login.
77
+
78
+ Full interactive flow: https://aibadgr.com/run-issue`;
79
+
80
+ function detectInput(raw, flags) {
81
+ if (flags.docker) {
82
+ return {
83
+ kind: 'docker_image',
84
+ label: `Docker image: ${flags.docker}`,
85
+ body: { docker_image: flags.docker },
86
+ };
87
+ }
88
+ if (flags.repo) {
89
+ return {
90
+ kind: 'repo_url',
91
+ label: `GitHub repository: ${flags.repo}`,
92
+ body: { repo_url: flags.repo },
93
+ };
94
+ }
95
+ if (flags.comfyui) {
96
+ if (!fs.existsSync(flags.comfyui)) throw new Error(`File not found: ${flags.comfyui}`);
97
+ return {
98
+ kind: 'comfyui_workflow',
99
+ label: `ComfyUI workflow: ${flags.comfyui}`,
100
+ body: { workflow_json: fs.readFileSync(flags.comfyui, 'utf8') },
101
+ };
102
+ }
103
+
104
+ if (!raw) return null;
105
+
106
+ // Case-id / GitHub-URL-shape classification is shared with the web page
107
+ // (`classifyPastedInput`, packages/shared/src/index.ts) so the CLI and
108
+ // /run-issue never disagree about what a given paste is. A case_id (bare,
109
+ // or embedded in a /repro/<id> or /run-issue?case_id=<id> link — the two
110
+ // shapes an admin-prepared or self-created case gets shared as) resumes
111
+ // that existing case instead of diagnosing new input.
112
+ const classified = classifyPastedInput(raw);
113
+ if (classified.kind === 'existing_case') {
114
+ return { kind: 'existing_case', label: `Existing case: ${classified.body.case_id}`, caseId: classified.body.case_id };
115
+ }
116
+ if (classified.kind === 'github_issue') {
117
+ return { kind: 'github_issue', label: `GitHub issue: ${raw}`, body: classified.body };
118
+ }
119
+ if (classified.kind === 'repo_url') {
120
+ return { kind: 'repo_url', label: `GitHub repository: ${raw}`, body: classified.body };
121
+ }
122
+
123
+ // Local filesystem paths -- only the CLI has a filesystem to check
124
+ // against, so this stays CLI-only, sitting between the shared URL checks
125
+ // above and the shared Docker-image/text fallback below (matches
126
+ // classifyPastedInput's own precedence for everything except this).
127
+ if (fs.existsSync(raw)) {
128
+ const content = fs.readFileSync(raw, 'utf8');
129
+ if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
130
+ let parsed;
131
+ try { parsed = JSON.parse(content); } catch { /* not valid JSON */ }
132
+ if (parsed && typeof parsed === 'object' && (parsed.nodes || parsed['1'] || parsed['0'])) {
133
+ return {
134
+ kind: 'comfyui_workflow',
135
+ label: `ComfyUI workflow: ${raw}`,
136
+ body: { workflow_json: content },
137
+ };
138
+ }
139
+ }
140
+ return {
141
+ kind: 'log_file',
142
+ label: `Log / error file: ${raw}`,
143
+ body: { text: content.slice(0, 50_000) },
144
+ };
145
+ }
146
+
147
+ if (classified.kind === 'docker_image') {
148
+ return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
149
+ }
150
+
151
+ return { kind: 'text', label: 'Text / conversation', body: classified.body };
152
+ }
153
+
154
+ function _printResult(result, chalk) {
155
+ const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
156
+
157
+ if (github_issue) {
158
+ console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
159
+ }
160
+
161
+ // The conversation stays primary — a GitHub URL found inside pasted text
162
+ // is only ever offered here, never fetched automatically.
163
+ if (detected_github_url) {
164
+ console.log();
165
+ console.log(` ${chalk.bold('Additional context found:')} ${detected_github_url}`);
166
+ console.log(` ${chalk.dim(`Include it: badgr diagnose "<input>" --github ${detected_github_url}`)}`);
167
+ }
168
+
169
+ const wt = e.workload_type && e.workload_type !== 'unknown' ? e.workload_type : null;
170
+ const conf = e.confidence ? ` ${chalk.dim(`(${Math.round(e.confidence * 100)}% confidence)`)}` : '';
171
+ if (wt) console.log(` ${chalk.bold('Workload:')} ${wt}${conf}`);
172
+ if (e.summary) console.log(` ${chalk.bold('Summary:')} ${e.summary}`);
173
+ console.log();
174
+
175
+ if (e.docker_images?.length) {
176
+ console.log(` ${chalk.bold('Image:')} ${e.docker_images[0]}${e.docker_images.length > 1 ? chalk.dim(` +${e.docker_images.length - 1} more`) : ''}`);
177
+ }
178
+ if (e.models?.length) {
179
+ console.log(` ${chalk.bold('Model:')} ${e.models[0]}${e.models.length > 1 ? chalk.dim(` +${e.models.length - 1} more`) : ''}`);
180
+ }
181
+ if (e.commands?.length) {
182
+ console.log(` ${chalk.bold('Command:')} ${e.commands[0]}`);
183
+ }
184
+ if (e.errors?.length) {
185
+ console.log(` ${chalk.bold('Errors:')}`);
186
+ for (const err of e.errors.slice(0, 3)) console.log(` ${chalk.red('✗')} ${err}`);
187
+ }
188
+
189
+ if (static_incompatibilities?.length) {
190
+ console.log();
191
+ console.log(` ${chalk.bold('Diagnosis:')} ${chalk.yellow('Static incompatibility — no GPU needed')}`);
192
+ for (const inc of static_incompatibilities) console.log(` ${chalk.yellow('!')} ${inc}`);
193
+ } else if (e.missing_information?.length) {
194
+ console.log();
195
+ console.log(` ${chalk.bold('Diagnosis:')} Missing information`);
196
+ for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
197
+ } else if (wt) {
198
+ console.log();
199
+ console.log(` ${chalk.bold('Diagnosis:')} Workload detected — ready for capped test`);
200
+ }
201
+
202
+ 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.')}`);
205
+ }
206
+ if (large_download) {
207
+ console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
208
+ console.log(` ${chalk.dim('Funding approval required before test launches.')}`);
209
+ }
210
+ if (result.secret_types_detected?.length) {
211
+ console.log(` ${chalk.dim(`Secrets redacted before analysis: ${result.secret_types_detected.join(', ')}`)}`);
212
+ }
213
+ if (!result.ai_available) {
214
+ console.log(` ${chalk.dim('(AI extraction unavailable — pattern matching only)')}`);
215
+ }
216
+
217
+ console.log();
218
+ if (static_incompatibilities?.length) {
219
+ console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
220
+ } else if (e.missing_information?.length) {
221
+ console.log(` ${chalk.bold('Recommended action:')} Provide the missing details above, then re-run`);
222
+ } else {
223
+ console.log(` ${chalk.bold('Recommended action:')} Run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch a capped test`);
224
+ }
225
+ }
226
+
227
+ // Local-machine file paths (e.g. a pasted .gguf path) can only be checked
228
+ // client-side -- the backend never sees this machine's filesystem, so it
229
+ // must not (and does not) claim to validate them. Only paths that look like
230
+ // a real filesystem reference (absolute, `./`, `../`, or `~/`) are checked;
231
+ // bare words, URLs, and Docker image references are left alone.
232
+ //
233
+ // Path checks must respect context (locked Free Command Check spec, problem
234
+ // #3): a repo path is checked against the repo tree, a container path
235
+ // against Dockerfile/image evidence -- both server-side, inside
236
+ // `mechanical_checks` -- and only a genuinely local path belongs to this
237
+ // machine. Once a repository or Docker image is in evidence for this
238
+ // workload, an absolute path *inside the resolved command* (e.g. "python3
239
+ // /src/main.py") is a repo/container path, not a claim about this laptop,
240
+ // so it must never be re-checked against the local filesystem here -- doing
241
+ // so is exactly the "/src/main.py NOT FOUND on this machine" false failure
242
+ // the spec calls out. `model_artifact` is different: it is always this
243
+ // machine's own claim about a locally-downloaded checkpoint, never repo or
244
+ // container evidence, so it stays checked regardless.
245
+ function _localPathCandidates(e) {
246
+ const candidates = new Set();
247
+ const hasRepoOrContainerContext = Boolean(
248
+ e.repositories?.length || e.docker_images?.length || e.dockerfile
249
+ );
250
+ const maybeAdd = (value) => {
251
+ if (typeof value !== 'string') return;
252
+ const s = value.trim().replace(/^['"]|['"]$/g, '');
253
+ if (!s || /^https?:\/\//i.test(s)) return;
254
+ if (s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('~/')) {
255
+ candidates.add(s);
256
+ }
257
+ };
258
+ maybeAdd(e.model_artifact);
259
+ if (!hasRepoOrContainerContext) {
260
+ for (const tok of (e.commands?.[0] || '').split(/\s+/)) maybeAdd(tok);
261
+ }
262
+ return [...candidates];
263
+ }
264
+
265
+ // Locked status ladder (product-level labels, not the backend's internal
266
+ // proof_level enum): NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
267
+ // 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).
270
+ //
271
+ // The evidence-only judgment (needs_info/invalid/smoke_checked) is computed
272
+ // once, server-side, in `_compute_smoke_status` (backend/run_issue_routes.py)
273
+ // and read here via `e.smoke_status` -- this and /run-issue's `smokeStatus()`
274
+ // (frontend/app/run-issue/page.tsx) both just render that one backend
275
+ // verdict rather than each re-deriving pass/fail from mechanical_checks, so
276
+ // the CLI and the web page can never disagree on what a given result means.
277
+ // A `smoke_status` fallback derivation is kept only for a backend response
278
+ // that predates this field (defensive, not the normal path).
279
+ //
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) {
288
+ const e = result.extraction || {};
289
+ 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');
293
+ const localFail = _localPathCandidates(e).some((p) => !fs.existsSync(p));
294
+ if (backendStatus === 'invalid' || localFail) return 'INVALID';
295
+ return backendStatus === 'ready' ? 'READY' : 'SMOKE CHECKED';
296
+ }
297
+
298
+ 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',
304
+ };
305
+
306
+ // Compact, copy-pasteable result -- the literal STATUS word plus just enough
307
+ // to forward to someone else. Additive to the detailed report above it;
308
+ // does not replace it. Never prints "Verified" -- SMOKE CHECKED is the
309
+ // ceiling for this free path.
310
+ function _printCopySummary(result, flags, chalk, caseData) {
311
+ const e = result.extraction || {};
312
+ const status = _statusWord(result, flags.smoke);
313
+
314
+ console.log(` ${chalk.bold(`STATUS: ${status}`)}`);
315
+ console.log();
316
+
317
+ if (status === 'NEEDS INFO') {
318
+ // Static incompatibilities and missing-information both mean "no
319
+ // command yet" -- whichever fired is the reason to surface here.
320
+ const reasons = result.static_incompatibilities?.length
321
+ ? result.static_incompatibilities
322
+ : (e.missing_information || []);
323
+ console.log(` ${chalk.bold('Missing:')}`);
324
+ for (const r of reasons) console.log(` - ${r}`);
325
+ console.log();
326
+ return;
327
+ }
328
+
329
+ // The prepared case's canonical `badgr ...` command (the exact line
330
+ // --approve would run) is preferred over the raw source command --
331
+ // /confirm can only compute it once a case exists (READY/SMOKE CHECKED),
332
+ // and only when there's enough evidence to convert (see
333
+ // _canonical_badgr_command's docstring, backend/run_issue_routes.py, for
334
+ // what it can't yet convert, e.g. training/ComfyUI). Falls back to the
335
+ // raw extracted command otherwise -- still true and still runnable input,
336
+ // just not guaranteed to already be `badgr`-shaped.
337
+ const badgrJob = caseData?.findings?.canonical_command || e.commands?.[0] || '(no command produced)';
338
+ console.log(` ${chalk.bold('Badgr job:')}`);
339
+ console.log(` ${badgrJob}`);
340
+ console.log();
341
+
342
+ if (caseData?.case_id) {
343
+ console.log(` ${chalk.bold('Run:')}`);
344
+ console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id))}`);
345
+ console.log();
346
+ }
347
+
348
+ if (status === 'SMOKE CHECKED' || status === 'INVALID') {
349
+ console.log(` ${chalk.bold('Checks:')}`);
350
+ const iconFor = (s) => (s === 'pass' ? '✓' : s === 'fail' ? '✗' : '-');
351
+ for (const check of (e.mechanical_checks || [])) {
352
+ console.log(` ${iconFor(check.status)} ${_CHECK_LABELS[check.name] || check.name}`);
353
+ }
354
+ const localPaths = _localPathCandidates(e);
355
+ if (!localPaths.length) {
356
+ console.log(' - local file check skipped');
357
+ } else {
358
+ const allExist = localPaths.every((p) => fs.existsSync(p));
359
+ console.log(` ${allExist ? '✓' : '✗'} local file check${allExist ? '' : ' (not found)'}`);
360
+ }
361
+ console.log();
362
+ if (status === 'INVALID') {
363
+ console.log(' A check above actually failed -- this command is not runnable as-is.');
364
+ } else {
365
+ console.log(' Not GPU-verified.');
366
+ }
367
+ console.log();
368
+ }
369
+ }
370
+
371
+ // --smoke's mechanical checks: prints the backend's evidence-backed
372
+ // pass/fail/skip results (`extraction.mechanical_checks`, computed for
373
+ // free from data already gathered by /run-issue/extract -- no extra
374
+ // network call), plus this machine's own local-file existence check.
375
+ // Deliberately never prints "Verified" -- see AGENTS.md §12; a genuinely
376
+ // passing smoke check still only earns "Smoke Checked".
377
+ function _printSmokeChecks(result, chalk) {
378
+ const e = result.extraction || {};
379
+ const mech = e.mechanical_checks || [];
380
+ const localPaths = _localPathCandidates(e);
381
+
382
+ console.log();
383
+ console.log(` ${chalk.bold('Smoke Checked (free, no GPU, no login):')}`);
384
+
385
+ const icon = (status) => (
386
+ status === 'pass' ? chalk.green('✓') : status === 'fail' ? chalk.red('✗') : chalk.dim('-')
387
+ );
388
+ for (const check of mech) {
389
+ console.log(` ${icon(check.status)} ${chalk.dim(`[${check.name}]`)} ${check.detail}`);
390
+ }
391
+ for (const p of localPaths) {
392
+ const exists = fs.existsSync(p);
393
+ console.log(` ${exists ? chalk.green('✓') : chalk.red('✗')} ${chalk.dim('[local_file]')} ${p} ${exists ? 'exists on this machine' : 'NOT FOUND on this machine'}`);
394
+ }
395
+ if (!mech.length && !localPaths.length) {
396
+ console.log(` ${chalk.dim('No mechanical checks applicable to this input.')}`);
397
+ }
398
+
399
+ const proofLevel = e.smoke_check?.proof_level;
400
+ if (proofLevel === 'smoke_checked') {
401
+ console.log(` ${chalk.green('✓')} ${chalk.dim('[command_scope]')} Command is the repository/image/service's own documented default.`);
402
+ }
403
+ console.log();
404
+ }
405
+
406
+ function _confirmBodyFromExtraction(e) {
407
+ return {
408
+ workload_type: e.workload_type || 'generic',
409
+ docker_image: e.docker_images?.[0] || null,
410
+ model_id: e.models?.[0] || null,
411
+ launch_command: e.commands?.[0] || null,
412
+ error_log: null,
413
+ expected_result: e.expected_result || null,
414
+ environment_variable_names: e.environment_variable_names || [],
415
+ gpu_requirements: Array.isArray(e.gpu_requirements)
416
+ ? e.gpu_requirements.join(', ')
417
+ : e.gpu_requirements || null,
418
+ source_summary: e.summary || null,
419
+ };
420
+ }
421
+
422
+ function _runPageUrl(caseId) {
423
+ return `https://aibadgr.com/run-issue?case_id=${caseId}`;
424
+ }
425
+
426
+ // Creates the free, anonymous "prepared run" case that backs a shareable
427
+ // Run Link -- the exact same /run-issue/confirm call and case object
428
+ // /run-issue's own confirm step creates, so the CLI and the web page never
429
+ // diverge on what a given diagnosis resolves to. No GPU, no login, no
430
+ // payment -- confirm only ever inspects and plans (see its own docstring).
431
+ // Called once per diagnosis (main flow), then reused by --approve below
432
+ // instead of confirming a second time.
433
+ //
434
+ // Returns `{ caseData }` on success, `{ error }` on a network/API failure --
435
+ // never throws, since a plain diagnosis must still succeed even if the
436
+ // free run-page creation itself fails.
437
+ async function _createRunPage(config, e, chalk) {
438
+ try {
439
+ const caseData = await callApi('/run-issue/confirm', {
440
+ method: 'POST',
441
+ apiKey: config.apiKey || '',
442
+ baseUrl: config.baseUrl,
443
+ body: _confirmBodyFromExtraction(e),
444
+ timeoutMs: 20_000,
445
+ });
446
+ return caseData?.case_id ? { caseData } : { caseData: null };
447
+ } catch (err) {
448
+ return { error: err };
449
+ }
450
+ }
451
+
452
+ async function _doApprove(config, caseData, chalk) {
453
+ const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
454
+
455
+ if (status === 'incompatible') {
456
+ console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
457
+ console.log();
458
+ return;
459
+ }
460
+ if (status === 'missing_information') {
461
+ console.log(chalk.yellow('\n Cannot approve — missing information:'));
462
+ for (const m of (missing || [])) console.log(` ? ${m}`);
463
+ console.log();
464
+ return;
465
+ }
466
+ if (status === 'template_matched') {
467
+ console.log(chalk.green('\n Matched a verified template — no new GPU test needed.'));
468
+ if (plan?.final_command) {
469
+ console.log();
470
+ console.log(` ${chalk.bold('Verified command:')}`);
471
+ console.log(` ${chalk.cyan(plan.final_command)}`);
472
+ }
473
+ console.log();
474
+ return;
475
+ }
476
+
477
+ if (plan) {
478
+ 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`);
484
+ }
485
+
486
+ await _approveAndRun(config, caseId, plan, chalk);
487
+ }
488
+
489
+ // Claim → approve → run for a case that already exists (just created via
490
+ // /confirm above, or resumed via an existing_case input). Auth is required
491
+ // from here on — diagnosis and case creation stay anonymous and free, but a
492
+ // real GPU test needs a signed-in account. The CLI never grants a free-run
493
+ // entitlement (web admin only, see grant_free_run in run_issue_routes.py);
494
+ // it only redeems one a case already carries, same as normal billing.
495
+ async function _approveAndRun(config, caseId, plan, chalk) {
496
+ const printResumeHint = () =>
497
+ console.error(chalk.dim(` Resume this case: https://aibadgr.com/run-issue?case_id=${caseId}\n`));
498
+
499
+ let authConfig = config;
500
+ if (!authConfig.apiKey) {
501
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
502
+ console.error(chalk.red('\n ✗ Sign in required to approve a GPU test. Run: badgr login\n'));
503
+ printResumeHint();
504
+ process.exitCode = 1;
505
+ return;
506
+ }
507
+ console.log(chalk.yellow('\n Sign in to continue. Opening browser...'));
508
+ try {
509
+ authConfig = await ensureLoggedIn(authConfig, chalk);
510
+ } catch (err) {
511
+ console.error(chalk.red(`\n ✗ Sign-in failed: ${err.message}\n`));
512
+ printResumeHint();
513
+ process.exitCode = 1;
514
+ return;
515
+ }
516
+ }
517
+
518
+ try {
519
+ await callApi(`/run-issue/cases/${caseId}/claim`, {
520
+ method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 10_000,
521
+ });
522
+ } catch { /* already claimed — continue */ }
523
+
524
+ let approved;
525
+ try {
526
+ approved = await callApi(`/run-issue/cases/${caseId}/approve`, {
527
+ method: 'POST',
528
+ apiKey: authConfig.apiKey,
529
+ baseUrl: authConfig.baseUrl,
530
+ body: {
531
+ confirmed: true,
532
+ funding_approved: Boolean(plan?.requires_funding_approval),
533
+ reduced_test_confirmed: Boolean(plan?.requires_reduced_test),
534
+ },
535
+ timeoutMs: 15_000,
536
+ });
537
+ } catch (err) {
538
+ if (err.httpStatus === 402 || err.message?.includes('402')) {
539
+ console.error(chalk.yellow('\n ✗ Insufficient credits. Run: badgr billing\n'));
540
+ } else {
541
+ console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
542
+ }
543
+ process.exitCode = 1;
544
+ return;
545
+ }
546
+
547
+ console.log(chalk.dim('\n Launching capped smoke test...'));
548
+ let ran;
549
+ try {
550
+ ran = await callApi(`/run-issue/cases/${caseId}/run`, {
551
+ method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
552
+ });
553
+ } catch (err) {
554
+ console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
555
+ process.exitCode = 1;
556
+ return;
557
+ }
558
+
559
+ console.log();
560
+ console.log(chalk.green(' Smoke test launched.'));
561
+ console.log(` ${chalk.bold('Case:')} ${caseId}`);
562
+ if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
563
+ 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}`);
565
+ console.log();
566
+ }
567
+
568
+ // Resume an existing case (bare case_id or a shared /repro or /run-issue
569
+ // link) instead of diagnosing new input. No /confirm — the case already
570
+ // exists; this only reads its current state and, with --approve, redeems
571
+ // whatever entitlement/billing state it already carries.
572
+ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
573
+ let caseData;
574
+ try {
575
+ caseData = await callApi(`/run-issue/cases/${caseId}`, {
576
+ apiKey: config.apiKey || '', baseUrl: config.baseUrl, timeoutMs: 15_000,
577
+ });
578
+ } catch (err) {
579
+ console.error(chalk.red(`\n ✗ Could not load case ${caseId}: ${err.message}\n`));
580
+ process.exitCode = 1;
581
+ return;
582
+ }
583
+
584
+ if (flags.json) {
585
+ console.log(JSON.stringify(caseData, null, 2));
586
+ if (flags.approve) await _approveAndRun(config, caseId, caseData.test_plan, chalk);
587
+ return;
588
+ }
589
+
590
+ console.log();
591
+ console.log(` ${chalk.bold('Case:')} ${caseId}`);
592
+ console.log(` ${chalk.bold('Status:')} ${caseData.status}`);
593
+ if (caseData.workload_type) console.log(` ${chalk.bold('Workload:')} ${caseData.workload_type}`);
594
+ if (caseData.known_command) console.log(` ${chalk.bold('Command:')} ${caseData.known_command}`);
595
+ if ((caseData.missing_information || []).length) {
596
+ console.log();
597
+ console.log(` ${chalk.bold('Missing information:')}`);
598
+ for (const m of caseData.missing_information) console.log(` ? ${m}`);
599
+ }
600
+
601
+ // "Verified" is only ever earned here -- a case whose real GPU run
602
+ // actually completed (`lead_summary.status`, computed server-side by
603
+ // `_build_lead_summary` from `ReproCase.status`/`final_command`). Never
604
+ // reachable from --smoke, which never provisions anything.
605
+ console.log();
606
+ const existingStatus = caseData.lead_summary?.status === 'verified'
607
+ || caseData.status === 'verified' || caseData.status === 'completed'
608
+ ? 'VERIFIED'
609
+ : (caseData.missing_information || []).length ? 'NEEDS INFO' : 'READY';
610
+ console.log(` ${chalk.bold(`STATUS: ${existingStatus}`)}`);
611
+ if (existingStatus === 'VERIFIED') {
612
+ console.log();
613
+ console.log(` ${chalk.bold('Command:')}`);
614
+ console.log(` ${caseData.final_command || caseData.known_command}`);
615
+ console.log();
616
+ console.log(` ${chalk.bold('Result:')}`);
617
+ const resultLines = caseData.lead_summary?.test_checklist?.length
618
+ ? caseData.lead_summary.test_checklist
619
+ : ['Verified'];
620
+ 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.`);
623
+ }
624
+ }
625
+
626
+ console.log();
627
+ if (caseData.free_verification_consumed) {
628
+ console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
629
+ } else if (caseData.free_verification_available) {
630
+ 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)}`)}`);
633
+ }
634
+ console.log();
635
+
636
+ if (flags.approve) {
637
+ await _approveAndRun(config, caseId, caseData.test_plan, chalk);
638
+ } else {
639
+ console.log(` ${chalk.bold('Resume:')} badgr diagnose ${caseId} --approve`);
640
+ console.log();
641
+ }
642
+ }
643
+
644
+ export async function diagnoseCommand(config, args, chalk) {
645
+ if (args.includes('--help') || args.includes('-h')) {
646
+ console.log(DIAGNOSE_HELP);
647
+ return;
648
+ }
649
+
650
+ const flags = {};
651
+ let positional = null;
652
+ let i = 0;
653
+ while (i < args.length) {
654
+ const a = args[i];
655
+ if (a === '--docker') { flags.docker = args[++i]; i++; continue; }
656
+ if (a === '--repo') { flags.repo = args[++i]; i++; continue; }
657
+ if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
658
+ if (a === '--github') { flags.github = args[++i]; i++; continue; }
659
+ if (a === '--approve') { flags.approve = true; i++; continue; }
660
+ if (a === '--smoke') { flags.smoke = true; i++; continue; }
661
+ if (a === '--json') { flags.json = true; i++; continue; }
662
+ if (!a.startsWith('-') && positional === null) positional = a;
663
+ i++;
664
+ }
665
+
666
+ if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
667
+ console.log(DIAGNOSE_HELP);
668
+ return;
669
+ }
670
+
671
+ let detected;
672
+ try {
673
+ detected = detectInput(positional, flags);
674
+ } catch (err) {
675
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
676
+ process.exitCode = 1;
677
+ return;
678
+ }
679
+
680
+ if (!detected) {
681
+ console.log(DIAGNOSE_HELP);
682
+ return;
683
+ }
684
+
685
+ if (detected.kind === 'existing_case') {
686
+ await _diagnoseExistingCase(config, detected.caseId, flags, chalk);
687
+ return;
688
+ }
689
+
690
+ // --github opts into fetching a GitHub issue URL found inside pasted text
691
+ // (see the "Additional context found" notice) — never fetched by default.
692
+ if (flags.github) detected.body.github_url = flags.github;
693
+
694
+ if (!flags.json) {
695
+ console.log();
696
+ console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
697
+ console.log(chalk.dim(' Analysing...'));
698
+ }
699
+
700
+ let result;
701
+ try {
702
+ result = await callApi('/run-issue/extract', {
703
+ method: 'POST',
704
+ apiKey: config.apiKey || '',
705
+ baseUrl: config.baseUrl,
706
+ body: detected.body,
707
+ timeoutMs: 120_000,
708
+ });
709
+ } catch (err) {
710
+ console.error(chalk.red(`\n ✗ ${err.message}\n`));
711
+ process.exitCode = 1;
712
+ return;
713
+ }
714
+
715
+ const status = _statusWord(result, flags.smoke);
716
+
717
+ // A command that's actually runnable also gets a free prepared Run
718
+ // page -- the same case /run-issue's own confirm step would create --
719
+ // so diagnosis ends at a page someone can click Run on, not just a
720
+ // printed command. Only READY and SMOKE CHECKED qualify: NEEDS INFO has
721
+ // nothing runnable to save yet, and INVALID is a command a real check
722
+ // just proved broken -- turning that into a clickable "Run" page would
723
+ // contradict the label. Both print their own guidance instead (Missing:
724
+ // / the failed check) and stop there.
725
+ let caseData = null;
726
+ let caseError = null;
727
+ if (status === 'READY' || status === 'SMOKE CHECKED') {
728
+ const created = await _createRunPage(config, result.extraction || {}, chalk);
729
+ if (created.error) caseError = created.error;
730
+ else caseData = created.caseData;
731
+ }
732
+
733
+ if (flags.json) {
734
+ const output = { detected: detected.kind, ...result, status };
735
+ if (flags.smoke) {
736
+ const e = result.extraction || {};
737
+ output.smoke = {
738
+ mechanical_checks: e.mechanical_checks || [],
739
+ local_file_checks: _localPathCandidates(e).map(p => ({ path: p, exists: fs.existsSync(p) })),
740
+ };
741
+ }
742
+ if (caseData?.case_id) {
743
+ output.run = {
744
+ case_id: caseData.case_id,
745
+ url: _runPageUrl(caseData.case_id),
746
+ canonical_command: caseData.findings?.canonical_command || null,
747
+ };
748
+ }
749
+ console.log(JSON.stringify(output, null, 2));
750
+
751
+ // --json must not silently skip --approve -- the JSON branch used to
752
+ // return here unconditionally, so a scripted `--approve --json` call
753
+ // never claimed/approved/launched anything (bug: approval was silently
754
+ // dropped in the one output mode automation actually uses). Approval
755
+ // output itself stays human-readable (chalk console lines), matching
756
+ // _diagnoseExistingCase's existing --json + --approve behaviour below.
757
+ if (flags.approve) {
758
+ if (caseError) {
759
+ console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
760
+ process.exitCode = 1;
761
+ return;
762
+ }
763
+ if (!caseData) {
764
+ console.error(chalk.yellow('\n Cannot approve — missing information above.\n'));
765
+ return;
766
+ }
767
+ await _doApprove(config, caseData, chalk);
768
+ }
769
+ return;
770
+ }
771
+
772
+ console.log();
773
+ _printResult(result, chalk);
774
+
775
+ if (flags.smoke) {
776
+ _printSmokeChecks(result, chalk);
777
+ }
778
+
779
+ console.log();
780
+ _printCopySummary(result, flags, chalk, caseData);
781
+
782
+ console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
783
+ console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
784
+ console.log();
785
+
786
+ if (flags.approve) {
787
+ if (caseError) {
788
+ console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
789
+ process.exitCode = 1;
790
+ return;
791
+ }
792
+ if (!caseData) {
793
+ console.log(chalk.yellow('\n Cannot approve — missing information above.\n'));
794
+ return;
795
+ }
796
+ await _doApprove(config, caseData, chalk);
797
+ }
798
+ }