badgr-cli 1.1.3 → 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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Badgr supports many GPU workloads through two commands: `serve` for persistent e
13
13
  npm install -g badgr-cli
14
14
  ```
15
15
 
16
- **Jump to:** [Quick start](#quick-start) · [Coding agents (`badgr launch`)](#coding--testing-agents-badgr-launch) · [Image generation](#also-try-image-generation) · [`badgr doctor`](#something-not-working-badgr-doctor) · [Commands](#commands) · [`serve` options](#badgr-serve-options) · [`run` options](#badgr-run-options) · [Receipts](#receipts) · [OpenAI compatibility](#openai-compatibility) · [GPU options](#gpu-options) · [Advanced](#advanced) · [Requirements](#requirements)
16
+ **Jump to:** [Quick start](#quick-start) · [Coding agents (`badgr launch`)](#coding--testing-agents-badgr-launch) · [Image generation](#also-try-image-generation) · [`badgr doctor`](#something-not-working-badgr-doctor) · [`badgr diagnose`](#diagnose-a-gpu-issue-badgr-diagnose) · [Commands](#commands) · [`serve` options](#badgr-serve-options) · [`run` options](#badgr-run-options) · [Receipts](#receipts) · [OpenAI compatibility](#openai-compatibility) · [GPU options](#gpu-options) · [Advanced](#advanced) · [Requirements](#requirements)
17
17
 
18
18
  ---
19
19
 
@@ -159,21 +159,80 @@ Run `badgr doctor --help` for the full flag list. Details in
159
159
 
160
160
  ---
161
161
 
162
+ ## Badgr Smoke Test: `badgr diagnose`
163
+
164
+ Paste a GitHub issue, a Docker image, a repo URL, a log file, a ComfyUI
165
+ workflow, or a raw conversation — Badgr auto-detects the input, redacts
166
+ secrets, and runs free static checks as part of a Badgr Smoke Test.
167
+ Nothing runs on a GPU without explicit `--approve`.
168
+
169
+ ```bash
170
+ # Free Badgr Smoke Test — no login required
171
+ badgr diagnose "https://github.com/org/repo/issues/123"
172
+ badgr diagnose ajayrajtp/vllm_gemma412b:latest
173
+ badgr diagnose ./vllm-error.log
174
+ badgr diagnose "https://github.com/org/repo"
175
+ badgr diagnose workflow.json
176
+
177
+ # Free mechanical validation of the produced command — still no GPU, no login
178
+ badgr diagnose "https://github.com/org/repo/issues/123" --smoke
179
+
180
+ # Approve a capped smoke test after diagnosis (login required)
181
+ badgr diagnose "https://github.com/org/repo/issues/123" --approve
182
+
183
+ # Resume an existing case, e.g. one shared via a case link
184
+ badgr diagnose repro_xxxxxxxx --approve
185
+ badgr diagnose "https://aibadgr.com/repro/repro_xxxxxxxx" --approve
186
+
187
+ # Machine-readable output
188
+ badgr diagnose "https://github.com/org/repo/issues/123" --json
189
+ ```
190
+
191
+ | Flag | Description |
192
+ |------|-------------|
193
+ | `--smoke` | Free. Runs real, cheap mechanical validation of the produced command (syntax, CLI-entrypoint corroboration, referenced-file presence, Docker `ENTRYPOINT`/`CMD` consistency, required env vars, plus a client-side check of any local file path you pasted). Never starts a GPU, never requires login. Combine with `--approve` — the smoke checks print first, then the normal approve flow runs. Not the same as `badgr run --smoke`, which launches a real, billable GPU job |
194
+ | `--approve` | Approve the capped smoke test after diagnosis (opens browser sign-in automatically if not logged in) |
195
+ | `--docker <image>` | Force Docker-image intake (override auto-detect) |
196
+ | `--repo <url>` | Force repository intake (override auto-detect) |
197
+ | `--comfyui <path>` | Force ComfyUI workflow intake (override auto-detect) |
198
+ | `--github <url>` | Include a GitHub issue URL found inside pasted text as additional context (opt-in, never fetched automatically) |
199
+ | `--json` | Machine-readable JSON output |
200
+
201
+ Every run prints one status: `NEEDS INFO` (fields still missing) →
202
+ `READY` (complete evidence-backed command, no smoke run) → `SMOKE CHECKED`
203
+ (`--smoke` ran, every applicable check passed or was skipped) / `INVALID`
204
+ (`--smoke` ran and a check actually failed) → `VERIFIED` (reserved for an
205
+ actual successful GPU-provisioned run — never assigned from static
206
+ resolution or `--smoke`). `READY`/`SMOKE CHECKED` results also print the
207
+ canonical `badgr run`/`badgr serve` command `--approve` would run, plus a
208
+ shareable case link — pass that case ID or URL back into `badgr diagnose
209
+ <case_id_or_url> --approve` to resume the same case without re-diagnosing.
210
+
211
+ `badgr run-issue` is an alias for `badgr diagnose`, matching the
212
+ [aibadgr.com/run-issue](https://aibadgr.com/run-issue) Badgr Smoke Test
213
+ web flow.
214
+
215
+ ---
216
+
162
217
  ## Commands
163
218
 
164
219
  ```text
165
220
  login
166
221
  connect
167
222
  doctor
223
+ diagnose
168
224
  run
169
225
  launch
170
- task
226
+ job
171
227
  serve
172
228
  status
173
229
  logs
174
230
  pull
175
231
  artifacts
176
232
  down
233
+ restart
234
+ rerun
235
+ heartbeat
177
236
  receipts
178
237
  test
179
238
  ```
@@ -183,6 +242,7 @@ test
183
242
  | `badgr login` | Save API key to `~/.badgr/config.json` |
184
243
  | `badgr connect <provider>` | Store a provider credential (`anthropic`, `openai`) for `badgr launch` |
185
244
  | `badgr doctor` | Diagnose a GPU workload failure — read-only, no login needed |
245
+ | `badgr diagnose "<input>"` | Run a Badgr Smoke Test on a GitHub issue, Docker image, repo, log, or workflow — free, no GPU until `--approve` |
186
246
  | `badgr run <command>` | Run a one-off GPU job (any container command) |
187
247
  | `badgr launch cline\|claude\|codex\|playwright "<task>"` | Run a coding/testing agent on a CPU VM — image + command auto-selected |
188
248
  | `badgr job <agent> "<instruction>" --check "<cmd>"` | Tracked coding-agent job via `POST /v1/jobs` (type: agent) |
@@ -192,10 +252,13 @@ test
192
252
  | `badgr pull <id>` | Pull a code-editing agent's patch as a local git diff/branch |
193
253
  | `badgr artifacts <id>` | Download non-patch outputs (test reports, screenshots, traces) |
194
254
  | `badgr down <id>` | Terminate a deployment — stops billing immediately |
255
+ | `badgr restart <id>` | Relaunch an endpoint with the same config, on a new deployment ID, keeping its API key |
256
+ | `badgr rerun <id>` | Replay a past job or endpoint with its exact original spec, on a new deployment ID |
257
+ | `badgr heartbeat <id>` | Reset an endpoint's idle-timeout clock (see `--idle-timeout` under [`badgr serve` options](#badgr-serve-options)) |
195
258
  | `badgr receipts [n]` | Cost, route, and retry receipts (default 10) |
196
259
  | `badgr test` | Run an end-to-end test (provision → run → teardown) |
197
260
 
198
- More commands below, under [Advanced](#advanced): `comfyui`, `train`, `transcribe`, `embed`, `workload`, `workspace`, `batch`, `sbatch`, `capacity`, `billing`.
261
+ More commands below, under [Advanced](#advanced): `comfyui`, `train`, `transcribe`, `embed`, `workload`, `workspace`, `batch`, `sbatch`, `capacity`, `billing`, `models`, `template`.
199
262
 
200
263
  `badgr serve` — for anything that needs a persistent endpoint: LLM serving, embeddings, image generation APIs, transcription APIs.
201
264
 
@@ -228,16 +291,38 @@ Each command section below lists only its own extra flags.
228
291
  badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
229
292
  ```
230
293
 
294
+ Endpoints bill continuously, so `serve` requires either `--max-cost` or `--persistent` (or `--dry-run` to just preview) — it refuses to start otherwise.
295
+
231
296
  | Flag | Default | Description |
232
297
  |------|---------|-------------|
233
298
  | `--image <img>` | — | Serve a custom container instead of a HuggingFace model |
234
299
  | `--task <task>` | — | vLLM task override, e.g. `embed` for embedding models |
235
- | `--health-path <path>` | auto | Readiness path to poll (auto-detected for ComfyUI `/system_stats`) |
300
+ | `--runtime llama.cpp\|ollama` | vLLM | Serve via a different runtime instead of vLLM |
301
+ | `--hf-repo <repo>` | — | HuggingFace repo for a GGUF file (with `--runtime llama.cpp`), e.g. `org/model-repo` |
302
+ | `--hf-file <file>` | — | GGUF filename within that repo, e.g. `model.gguf` (required with `--runtime llama.cpp`) |
303
+ | `--idle-timeout <min>` | — | Auto-stop after N minutes with no `badgr heartbeat` call — see [`badgr heartbeat`](#badgr-heartbeat) |
304
+ | `--persistent` | off | Run until manually stopped — satisfies the cost-control requirement in place of `--max-cost` |
305
+ | `--check-nodes <n1,n2>` | — | For ComfyUI-shaped images: verify custom nodes are installed after startup |
306
+ | `--health-path <path>` | auto | Readiness path to poll (auto-detected: ComfyUI → `/system_stats`, llama.cpp → `/health`) |
236
307
  | `--no-wait` | off | Skip endpoint health check and return immediately |
308
+ | `--yes` / `-y` | off | Skip duplicate-deployment warning |
309
+ | `--dry-run` | — | Preview the plan (GPU, price) without provisioning |
237
310
  | `--list-aliases` | — | List blessed vLLM model aliases (`qwen-7b`, `llama-8b`, `qwen-coder-7b`) and exit — no provisioning, no API key required |
238
311
 
239
312
  Blessed aliases expand to a full model ID + preset GPU, e.g. `badgr serve qwen-7b` → `Qwen/Qwen2.5-7B-Instruct` on an RTX 4090. Run `badgr serve --list-aliases` to see the current list.
240
313
 
314
+ ```bash
315
+ # Serve a Hugging Face GGUF file via llama.cpp instead of vLLM
316
+ badgr serve --runtime llama.cpp --hf-repo org/model-repo --hf-file model.gguf --max-cost 10
317
+
318
+ # Serve Open WebUI, a chat UI, pointed at a model endpoint
319
+ badgr serve openwebui --model qwen-7b --max-cost 10
320
+ badgr serve openwebui --connect <existing-endpoint-url> # connect to an endpoint you already have
321
+
322
+ # Auto-stop only when idle — requires periodic badgr heartbeat calls to stay up
323
+ badgr serve qwen-7b --idle-timeout 30 --max-cost 10
324
+ ```
325
+
241
326
  ### Model support levels
242
327
 
243
328
  `badgr serve qwen-7b` is the happy path — a tested route with no extra setup. `badgr serve` also accepts any other model ID or a custom container:
@@ -269,10 +354,21 @@ badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
269
354
 
270
355
  Badgr zips and uploads the folder (Flow 1) or clones the repo (Flow 2), picks a generic runner, installs deps, runs the command, stores outputs for 48 hours, and tears down the GPU. `--max-cost` is required.
271
356
 
357
+ ```bash
358
+ # No GPU needed — run on a plain CPU VM instead
359
+ badgr run . --cmd "npm test" --no-gpu --max-cost 1
360
+
361
+ # Describe basic compute needs instead of a GPU model — Badgr finds a compatible machine
362
+ badgr run . --cpu 16 --memory 64GB --gpu-memory 24GB --max-cost 5
363
+ ```
364
+
272
365
  | Flag | Default | Description |
273
366
  |------|---------|-------------|
274
367
  | `--cmd <command>` | — | Command to run inside the uploaded project or cloned repo (required for folder/GitHub flows) |
275
- | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
368
+ | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing (alias: `--gpu-memory`) |
369
+ | `--cpu <cores>` | — | Minimum CPU cores (for a CPU-only run) |
370
+ | `--memory <size>` | — | Minimum RAM, e.g. `64GB` |
371
+ | `--no-gpu` | off | Run on a CPU-only VM — no GPU is provisioned (conflicts with `--gpu`/`--min-vram`) |
276
372
  | `--image <img>` | — | Custom Docker image — bypasses the runner |
277
373
  | `--max-runtime <min>` | — | Auto-stop after N minutes |
278
374
  | `--save <name>` | — | Save this job as a named workload after it completes |
@@ -320,13 +416,14 @@ Productized batch image generation — runs a list of prompts through a **blesse
320
416
  ```bash
321
417
  badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10
322
418
  badgr comfyui batch --workflow sdxl-basic --prompt "a cat on a beach" --prompt "a dog in the park" --max-cost 5
419
+ badgr comfyui batch --workflow flux-basic --prompt "a neon city at night" --max-cost 5
323
420
  ```
324
421
 
325
- Blessed workflows: `sdxl-basic` (SDXL text-to-image, default sampler settings). Max 20 prompts per batch.
422
+ Blessed workflows: `sdxl-basic` (SDXL text-to-image, default sampler settings), `flux-basic` (FLUX.1-schnell text-to-image). Max 20 prompts per batch.
326
423
 
327
424
  | Flag | Default | Description |
328
425
  |------|---------|-------------|
329
- | `--workflow <name>` | — | Blessed workflow ID (required) — currently `sdxl-basic` |
426
+ | `--workflow <name>` | — | Blessed workflow ID (required) — `sdxl-basic` or `flux-basic` |
330
427
  | `--prompts <file>` | — | Text file, one prompt per line |
331
428
  | `--prompt <text>` | — | Inline prompt (repeatable) — combine with `--prompts` if needed |
332
429
  | `--max-runtime <min>` | 60 | Auto-stop after N minutes |
@@ -350,6 +447,37 @@ Each receipt includes runtime, estimated/settled cost, status, retries, teardown
350
447
 
351
448
  ---
352
449
 
450
+ ## Managing a deployment
451
+
452
+ ```bash
453
+ badgr status # what's running and billing
454
+ badgr logs dep-abc123 # fetch current log output
455
+ badgr logs dep-abc123 --follow # stream logs until the deployment reaches a terminal state
456
+ badgr pull dep-abc123 # pull a coding agent's patch as a local git diff
457
+ badgr pull dep-abc123 --branch # ...as a new local branch instead of a diff
458
+ badgr pull dep-abc123 --diff-only # print the diff, don't touch the working tree
459
+ badgr artifacts dep-abc123 # download non-patch outputs (test reports, screenshots, traces)
460
+ badgr down dep-abc123 # terminate one deployment, stop billing
461
+ badgr down --all # terminate everything running, with a confirmation prompt
462
+ badgr down --all --yes # ...skip the confirmation prompt
463
+ badgr restart dep-abc123 # relaunch an endpoint with the same config — new ID, same API key
464
+ badgr rerun dep-abc123 # replay a past job/endpoint with its exact original spec — new ID
465
+ badgr heartbeat dep-abc123 # reset an endpoint's idle-timeout clock (see --idle-timeout on badgr serve)
466
+ ```
467
+
468
+ | Command | Flag | Description |
469
+ |---------|------|-------------|
470
+ | `badgr logs <id>` | `--follow` / `-f` | Stream/poll logs until the deployment reaches a terminal state instead of a one-shot fetch |
471
+ | `badgr pull <id>` | `--branch` | Create a new local git branch from the patch instead of leaving it as an unstaged diff |
472
+ | `badgr pull <id>` | `--diff-only` | Print the raw diff, don't touch the local working tree at all |
473
+ | `badgr pull <id>` | `--yes` / `-y` | Skip the confirmation prompt before applying |
474
+ | `badgr down <id\|--all>` | `--all` | Terminate every running deployment instead of one by ID |
475
+ | `badgr down <id\|--all>` | `--yes` / `-y` | Skip the confirmation prompt |
476
+
477
+ `badgr restart` is endpoint-only (it tears down the current pod and relaunches with the same GPU/model/price/runtime caps and endpoint API key, so existing clients keep working against a new URL). `badgr rerun` works for both one-off jobs and endpoints, replaying the exact original image/command/env/GPU/caps, and never tears down the source deployment.
478
+
479
+ ---
480
+
353
481
  ## OpenAI compatibility
354
482
 
355
483
  `badgr serve` provisions a vLLM endpoint that is fully OpenAI-compatible:
@@ -417,22 +545,26 @@ Less common commands — training, transcription, embeddings, and the workload/w
417
545
  badgr train config.yaml --gpu A100 --max-runtime 240 --env HF_TOKEN=$HF_TOKEN
418
546
  ```
419
547
 
420
- Detects framework (axolotl, unsloth, trl) from the config file, but **only Axolotl configs run today** — `unsloth`/`trl`/unrecognized configs are blocked before provisioning rather than billing a GPU that's guaranteed to fail. Default max-runtime is 120 min.
548
+ Detects framework (axolotl, unsloth, trl) from the config file. **Axolotl and TRL configs run today** — `unsloth`/unrecognized configs are blocked before provisioning rather than billing a GPU that's guaranteed to fail. Default max-runtime is 120 min.
421
549
 
422
550
  ```bash
423
551
  badgr train lora --base-model mistralai/Mistral-7B-v0.1 --dataset ./train.jsonl --preset small --max-cost 20
552
+
553
+ # Resume from a prior job's checkpoint instead of starting over
554
+ badgr train lora --base-model mistralai/Mistral-7B-v0.1 --dataset ./train.jsonl --resume https://.../checkpoint --max-cost 20
424
555
  ```
425
556
 
426
557
  Productized LoRA training — pass a base model and dataset, no Axolotl config file needed. Badgr generates the config from a preset and returns a downloadable adapter.
427
558
 
428
559
  | Flag | Default | Description |
429
560
  |------|---------|-------------|
430
- | `--framework <name>` | auto-detect | Force framework: `axolotl`, `unsloth`, `trl` (only `axolotl` currently runs) |
561
+ | `--framework <name>` | auto-detect | Force framework: `axolotl`, `unsloth`, `trl` (`axolotl`/`trl` currently run; `unsloth` is blocked pre-provisioning) |
431
562
  | `--base-model <id>` | — | HuggingFace model ID (required for `train lora`) — validated to exist before provisioning |
432
563
  | `--dataset <path\|url>` | — | Local file, direct URL, or `s3://` URI |
433
564
  | `--file-id <id>` | — | Badgr upload ID instead of `--dataset` |
434
565
  | `--preset small\|medium` | `small` | `small` = RTX 4090, rank 16, 3 epochs. `medium` = A100, rank 32, 5 epochs |
435
566
  | `--gpu-type <type>` | preset default | GPU type override for `train lora` |
567
+ | `--resume <checkpoint-url>` | — | Continue training from a prior job's checkpoint instead of starting fresh |
436
568
  | `--dry-run` | — | Preview the job without provisioning |
437
569
 
438
570
  On completion, `train lora` prints an `adapter_url` — download with `GET /v1/jobs/{job_id}/adapter`, or via `badgr workload info` if saved.
@@ -502,8 +634,11 @@ badgr batch status dep-abc123
502
634
  badgr batch artifacts dep-abc123
503
635
  badgr batch receipt dep-abc123
504
636
  badgr batch compare dep-abc123 dep-def456
637
+ badgr batch compare dep-abc123 dep-def456 --key accuracy --higher-is-better
505
638
  ```
506
639
 
640
+ `batch compare` reads each run's `success_metric` by default; `--key <metric>` compares a different field from the receipt instead, and `--higher-is-better` (default) / `--higher-is-better false` controls which run is reported as the winner.
641
+
507
642
  For CV/video/scientific batch, simulation, and physical-AI eval workloads — runs a container from a `workload.yml` spec and captures output artifacts automatically.
508
643
 
509
644
  **Fan-out** — run the same program once per file in a directory, one deployment per input, in parallel:
@@ -539,14 +674,44 @@ Translates `--cpus-per-task`/`--mem`/`--gres`/`--time`/`--export` from a real `.
539
674
  | `--max-concurrency <n>` | 5 | Cap in-flight array tasks |
540
675
  | `--dry-run` | — | Preview the translated job without provisioning |
541
676
 
542
- ### Other commands
677
+ ### `badgr models` — GPU catalog and pricing
678
+
679
+ ```bash
680
+ badgr models
681
+ ```
682
+
683
+ Lists available GPU types cheapest-first, with VRAM and hourly rate — pulled live from your account when logged in, falling back to the local catalog otherwise. No flags.
543
684
 
544
- - `badgr capacity [--gpu <type>]` — check available GPU capacity right now
545
- - `badgr billing status` / `badgr billing add <amount>` — check balance / add funds
685
+ ### `badgr template` — pre-built workload templates
686
+
687
+ ```bash
688
+ badgr template list
689
+ badgr template info axolotl
690
+ badgr serve template vllm --model meta-llama/Llama-3.1-8B-Instruct --max-cost 10
691
+ badgr run template axolotl --config ./config.yaml --max-cost 10
692
+ ```
693
+
694
+ Provider-neutral templates for common frameworks (`vllm`, `invokeai`, `comfyui`, `axolotl`, `unsloth`, …). `template list`/`template info <name>` just browse the catalog; launching always goes through `badgr serve template <name>` or `badgr run template <name>`, which apply the template's default flags before handing off to the normal `serve`/`run` path.
695
+
696
+ ### `badgr capacity` — check live availability
697
+
698
+ ```bash
699
+ badgr capacity # cheapest runnable GPU across all types
700
+ badgr capacity --gpu A100 # a specific GPU type
701
+ badgr capacity --gpu A100 --region EU # region-filtered
702
+ badgr capacity --gpu A100 --max-price 2.50 # price-capped
703
+ ```
704
+
705
+ ### `badgr billing`
706
+
707
+ ```bash
708
+ badgr billing status # current balance
709
+ badgr billing add 20 # add funds — $5 minimum top-up
710
+ ```
546
711
 
547
712
  ---
548
713
 
549
714
  ## Requirements
550
715
 
551
- - Node.js 18+
716
+ - Node.js 20.10+
552
717
  - A Badgr account — sign up at [aibadgr.com](https://aibadgr.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "dependencies": {
15
15
  "@inquirer/prompts": "^8.5.2",
16
16
  "archiver": "^7.0.1",
17
+ "badgr-shared": "0.1.1",
17
18
  "chalk": "^5.3.0",
18
19
  "js-yaml": "^4.1.0",
19
20
  "tar": "^7.4.3"
package/src/badgr.js CHANGED
@@ -47,7 +47,7 @@ ${chalk.bold('COMMANDS')}
47
47
  ${chalk.cyan('badgr job <agent> "<instruction>" --check "<command>"')} Bounded coding-agent job, tracked at /jobs (POST /v1/jobs, type: agent)
48
48
  ${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
49
49
  ${chalk.cyan('badgr serve openwebui')} Serve Open WebUI — chat UI, connects to a model endpoint
50
- ${chalk.cyan('badgr diagnose "<input>"')} Diagnose any GPU issue for free — GitHub issue, Docker image, log, repo, or text
50
+ ${chalk.cyan('badgr diagnose "<input>"')} Run a free Badgr Smoke Test on any GPU issue — GitHub issue, Docker image, log, repo, or text
51
51
  ${chalk.cyan('badgr doctor')} Local GPU / model-fit diagnosis (read-only, no login needed)
52
52
  ${chalk.cyan('badgr status')} Show what's running and what's billing
53
53
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
@@ -113,7 +113,7 @@ ${chalk.bold('EXAMPLES')}
113
113
  ${chalk.dim('# badgr job — bounded coding-agent job with a pass/fail check, tracked at /jobs:')}
114
114
  badgr job cline "Run the Chromium tests and tell me what failed" --check "npm run test:chromium" --max-cost 1
115
115
 
116
- ${chalk.dim('# Diagnose any GPU issue for free (no login needed):')}
116
+ ${chalk.dim('# Run a free Badgr Smoke Test on any GPU issue (no login needed):')}
117
117
  badgr diagnose "https://github.com/org/repo/issues/123"
118
118
  badgr diagnose ./error.log
119
119
  badgr diagnose --docker ajayrajtp/vllm_gemma412b:latest
@@ -1,4 +1,5 @@
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';
4
5
 
@@ -9,6 +10,7 @@ smoke-test plan. No GPU launches without explicit --approve.
9
10
 
10
11
  Usage:
11
12
  badgr diagnose "<input>"
13
+ badgr diagnose "<input>" --smoke
12
14
  badgr diagnose "<input>" --approve
13
15
  badgr diagnose "<input>" --json
14
16
  badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
@@ -24,8 +26,24 @@ Input (auto-detected):
24
26
  Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
25
27
 
26
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.
27
43
  --approve Approve the capped smoke test after diagnosis (opens
28
- browser sign-in automatically if not logged in)
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.
29
47
  --docker <image> Force Docker-image intake (override auto-detect)
30
48
  --repo <url> Force repository intake (override auto-detect)
31
49
  --comfyui <path> Force ComfyUI workflow intake (override auto-detect)
@@ -35,8 +53,27 @@ Flags:
35
53
  --json Machine-readable JSON output
36
54
  --help, -h Show this help
37
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
+
38
74
  Safety: nothing runs from AI-extracted data without --approve.
39
75
  No GPU launches without explicit approval and a credit check.
76
+ --smoke never provisions a GPU and never requires login.
40
77
 
41
78
  Full interactive flow: https://aibadgr.com/run-issue`;
42
79
 
@@ -66,32 +103,27 @@ function detectInput(raw, flags) {
66
103
 
67
104
  if (!raw) return null;
68
105
 
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 };
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 };
77
115
  }
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
- };
116
+ if (classified.kind === 'github_issue') {
117
+ return { kind: 'github_issue', label: `GitHub issue: ${raw}`, body: classified.body };
85
118
  }
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
- };
119
+ if (classified.kind === 'repo_url') {
120
+ return { kind: 'repo_url', label: `GitHub repository: ${raw}`, body: classified.body };
93
121
  }
94
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).
95
127
  if (fs.existsSync(raw)) {
96
128
  const content = fs.readFileSync(raw, 'utf8');
97
129
  if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
@@ -112,23 +144,11 @@ function detectInput(raw, flags) {
112
144
  };
113
145
  }
114
146
 
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
- };
147
+ if (classified.kind === 'docker_image') {
148
+ return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
125
149
  }
126
150
 
127
- return {
128
- kind: 'text',
129
- label: 'Text / conversation',
130
- body: { text: raw.slice(0, 50_000) },
131
- };
151
+ return { kind: 'text', label: 'Text / conversation', body: classified.body };
132
152
  }
133
153
 
134
154
  function _printResult(result, chalk) {
@@ -204,11 +224,187 @@ function _printResult(result, chalk) {
204
224
  }
205
225
  }
206
226
 
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 = {
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 {
212
408
  workload_type: e.workload_type || 'generic',
213
409
  docker_image: e.docker_images?.[0] || null,
214
410
  model_id: e.models?.[0] || null,
@@ -221,23 +417,39 @@ async function _doApprove(config, result, chalk) {
221
417
  : e.gpu_requirements || null,
222
418
  source_summary: e.summary || null,
223
419
  };
420
+ }
224
421
 
225
- let caseData;
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) {
226
438
  try {
227
- console.log(chalk.dim(' Creating case...'));
228
- caseData = await callApi('/run-issue/confirm', {
439
+ const caseData = await callApi('/run-issue/confirm', {
229
440
  method: 'POST',
230
441
  apiKey: config.apiKey || '',
231
442
  baseUrl: config.baseUrl,
232
- body: confirmBody,
443
+ body: _confirmBodyFromExtraction(e),
233
444
  timeoutMs: 20_000,
234
445
  });
446
+ return caseData?.case_id ? { caseData } : { caseData: null };
235
447
  } catch (err) {
236
- console.error(chalk.red(`\n ✗ Could not create case: ${err.message}\n`));
237
- process.exitCode = 1;
238
- return;
448
+ return { error: err };
239
449
  }
450
+ }
240
451
 
452
+ async function _doApprove(config, caseData, chalk) {
241
453
  const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
242
454
 
243
455
  if (status === 'incompatible') {
@@ -386,6 +598,31 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
386
598
  for (const m of caseData.missing_information) console.log(` ? ${m}`);
387
599
  }
388
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
+
389
626
  console.log();
390
627
  if (caseData.free_verification_consumed) {
391
628
  console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
@@ -420,6 +657,7 @@ export async function diagnoseCommand(config, args, chalk) {
420
657
  if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
421
658
  if (a === '--github') { flags.github = args[++i]; i++; continue; }
422
659
  if (a === '--approve') { flags.approve = true; i++; continue; }
660
+ if (a === '--smoke') { flags.smoke = true; i++; continue; }
423
661
  if (a === '--json') { flags.json = true; i++; continue; }
424
662
  if (!a.startsWith('-') && positional === null) positional = a;
425
663
  i++;
@@ -474,20 +712,87 @@ export async function diagnoseCommand(config, args, chalk) {
474
712
  return;
475
713
  }
476
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
+
477
733
  if (flags.json) {
478
- console.log(JSON.stringify({ detected: detected.kind, ...result }, null, 2));
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
+ }
479
769
  return;
480
770
  }
481
771
 
482
772
  console.log();
483
773
  _printResult(result, chalk);
484
774
 
775
+ if (flags.smoke) {
776
+ _printSmokeChecks(result, chalk);
777
+ }
778
+
485
779
  console.log();
780
+ _printCopySummary(result, flags, chalk, caseData);
781
+
486
782
  console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
487
783
  console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
488
784
  console.log();
489
785
 
490
786
  if (flags.approve) {
491
- await _doApprove(config, result, chalk);
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);
492
797
  }
493
798
  }
@@ -357,9 +357,10 @@ export async function serveCommand(config, args, chalk) {
357
357
  const { model, flags } = parseServeArgs(args);
358
358
  const customImage = flags.image || null;
359
359
  const isLlamaCpp = flags.runtime === 'llama.cpp';
360
+ const isOllama = flags.runtime === 'ollama';
360
361
 
361
362
  // Expand blessed alias (qwen-7b, llama-8b, qwen-coder-7b) to full model ID + GPU.
362
- const vllmAlias = model && !customImage && !isLlamaCpp ? BLESSED_VLLM_MODELS[model] : null;
363
+ const vllmAlias = model && !customImage && !isLlamaCpp && !isOllama ? BLESSED_VLLM_MODELS[model] : null;
363
364
  const effectiveModel = vllmAlias ? vllmAlias.model_id : model;
364
365
 
365
366
  // Detect flags that ended up as positional args due to broken shell line continuation
@@ -564,6 +565,7 @@ export async function serveCommand(config, args, chalk) {
564
565
  return {
565
566
  ...(effectiveModel ? { model: effectiveModel } : {}),
566
567
  ...(isLlamaCpp ? { image: LLAMA_CPP_IMAGE } : customImage ? { image: customImage } : {}),
568
+ ...(isOllama ? { runtime: 'ollama' } : {}),
567
569
  ...(flags.task ? { task: flags.task } : {}),
568
570
  gpu: gpuOverride || gpu,
569
571
  gpu_count: flags.count || 1,
@@ -649,7 +651,9 @@ export async function serveCommand(config, args, chalk) {
649
651
  }
650
652
 
651
653
  // ── Determine health check path ───────────────────────────────────────────
652
- const resolvedHealthPath = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
654
+ const resolvedHealthPath = isOllama
655
+ ? (flags.healthPath || '/api/tags')
656
+ : _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
653
657
 
654
658
  // Gated-model guidance is only shown when it's actually needed — on failure —
655
659
  // not up front, so common launches stay short and uncluttered.
@@ -770,7 +774,13 @@ export async function serveCommand(config, args, chalk) {
770
774
  console.log();
771
775
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
772
776
 
773
- if (endpointReady && !customImage) {
777
+ if (endpointReady && isOllama) {
778
+ console.log(` ${chalk.bold('Test with curl:')}`);
779
+ console.log(chalk.dim(` curl ${endpointUrl}/api/generate \\`));
780
+ console.log(chalk.dim(` -H "Content-Type: application/json" \\`));
781
+ console.log(chalk.dim(` -d '{"model":"${dep.model || effectiveModel}","prompt":"Hello","stream":false}'`));
782
+ console.log();
783
+ } else if (endpointReady && !customImage) {
774
784
  // dep.endpoint_api_key is a per-endpoint key generated for this deployment
775
785
  // (vLLM model serves only) — shown exactly once, here. Falls back to the
776
786
  // account-wide key (truncated) for serves that don't get one yet
package/src/errors.js CHANGED
@@ -17,7 +17,12 @@ export const CATALOG = {
17
17
  // ── Capacity ──────────────────────────────────────────────────────────────
18
18
 
19
19
  NO_CAPACITY: {
20
+ // Prefer the backend's specific detail (e.g. "CPU launch is not
21
+ // configured on this backend (missing HETZNER_API_TOKEN)") over the
22
+ // generic template — a real config/provider reason is far more
23
+ // actionable than "not available right now" when that's not why.
20
24
  message: (ctx) =>
25
+ ctx.server_message ||
21
26
  `No ${ctx.gpu || 'GPU'} available${ctx.region ? ` in ${ctx.region}` : ''} right now.`,
22
27
  billing: 'never_started',
23
28
  retried: false,
package/src/fallback.js CHANGED
@@ -93,7 +93,10 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
93
93
  if (key) {
94
94
  const ctx = {
95
95
  gpu: d.filters?.gpu ?? d.gpu,
96
- region: d.filters?.region,
96
+ // Server uses the literal string "any" as a filters.region placeholder
97
+ // when no region was requested — don't surface that as if the user
98
+ // had actually asked for a region named "any" (e.g. "in any right now").
99
+ region: d.filters?.region && d.filters.region !== 'any' ? d.filters.region : undefined,
97
100
  failure_category: d.failure_category,
98
101
  low_cost_failed: d.low_cost_provider_failed,
99
102
  server_message: d.message,