badgr-cli 1.0.38 → 1.0.40

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
@@ -46,6 +46,14 @@ badgr down <deployment-id>
46
46
  | `badgr capacity` | Check available GPU capacity right now |
47
47
  | `badgr billing` | Show balance and add funds |
48
48
  | `badgr test` | Run an end-to-end test (provision → run → teardown) |
49
+ | `badgr workload list` | List saved workloads with run stats |
50
+ | `badgr workload run <name>` | Rerun a saved workload by name or ID |
51
+ | `badgr workload info <name>` | Show stats, route history, and recent jobs for a workload |
52
+ | `badgr workload delete <name>` | Delete a saved workload |
53
+ | `badgr workspace list` | List workspace trackers (job history + cost per context) |
54
+ | `badgr workspace create <name>` | Create a workspace tracker, optionally linked to a storage path |
55
+ | `badgr workspace info <name>` | Show jobs, cost, and files for a workspace |
56
+ | `badgr workspace delete <name>` | Archive a workspace tracker |
49
57
 
50
58
  `badgr serve` — for anything that needs a persistent endpoint: LLM serving, embeddings, image generation APIs, transcription APIs.
51
59
 
@@ -101,6 +109,8 @@ badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 60
101
109
  | `--max-runtime <min>` | — | Auto-stop after N minutes (recommended) |
102
110
  | `--max-cost <$>` | — | Auto-stop when total spend reaches this amount |
103
111
  | `--detach` | — | Launch and return immediately, don't stream logs |
112
+ | `--save <name>` | — | Save this job as a named workload after it completes |
113
+ | `--workspace <name\|id>` | — | Link this job to a workspace tracker (name or `ws_…` ID) |
104
114
 
105
115
  ---
106
116
 
@@ -199,6 +209,59 @@ Accepts a public URL, S3/GCS URI, or a local text file under 10 MB. Outputs JSON
199
209
 
200
210
  ---
201
211
 
212
+ ## Workloads
213
+
214
+ A workload is a saved job configuration. Once saved, you can rerun it by name instead of retyping all the flags. Badgr tracks success rate, average cost, average runtime, and the last known-good route so reruns are faster and cheaper over time.
215
+
216
+ **Save a workload** by adding `--save <name>` to any `badgr run` call:
217
+
218
+ ```bash
219
+ badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 120 --save my-training-job
220
+ ```
221
+
222
+ **Rerun a saved workload:**
223
+
224
+ ```bash
225
+ badgr workload run my-training-job
226
+ badgr workload run my-training-job --max-cost 5 # override spend cap
227
+ badgr workload run my-training-job --set HF_TOKEN=newval # override an env var
228
+ ```
229
+
230
+ **Inspect and manage workloads:**
231
+
232
+ ```bash
233
+ badgr workload list # list all saved workloads with stats
234
+ badgr workload info my-training-job # stats, route history, recent jobs
235
+ badgr workload delete my-training-job
236
+ ```
237
+
238
+ | `badgr workload run` flag | Description |
239
+ |---|---|
240
+ | `--max-cost <$>` | Override the saved spend cap for this run |
241
+ | `--max-runtime <min>` | Override the saved runtime limit for this run |
242
+ | `--set KEY=VALUE` | Override a saved config value for this run (repeatable) |
243
+
244
+ ---
245
+
246
+ ## Workspaces
247
+
248
+ A workspace tracker groups jobs by a named context — useful for tracking cost and job history across a project or storage path.
249
+
250
+ ```bash
251
+ badgr workspace create my-project --storage s3://my-bucket/runs --desc "nightly evals"
252
+ badgr run python eval.py --workspace my-project --max-cost 5
253
+ badgr workspace info my-project # jobs, total cost, files
254
+ badgr workspace list
255
+ badgr workspace delete my-project
256
+ ```
257
+
258
+ | `badgr workspace create` flag | Description |
259
+ |---|---|
260
+ | `--storage <path>` | S3/GCS path to associate with this workspace |
261
+ | `--desc <text>` | Optional description |
262
+
263
+ ---
264
+
202
265
  ## Routing
203
266
 
204
267
  Badgr Auto selects the best eligible route based on GPU type, VRAM, availability, region, workload requirements, and reliability. Advanced users can optionally choose an execution tier or hardware constraint.
@@ -275,7 +338,7 @@ Additional GPU types may be routable depending on current capacity — check wit
275
338
 
276
339
  Pricing is confirmed before provisioning. Use `--dry-run` to see pricing before committing.
277
340
 
278
- Full GPU support details: see [GPU_SUPPORT.md](../../GPU_SUPPORT.md) in the repo root.
341
+ Full GPU support details: see [NOTES.md](../../NOTES.md#gpu-support) in the repo root.
279
342
 
280
343
  ---
281
344
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.38",
4
- "description": "Badgr, run or serve GPU workloads from one command",
3
+ "version": "1.0.40",
4
+ "description": "Badgr run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "badgr": "src/badgr.js"
@@ -12,23 +12,27 @@
12
12
  "test:watch": "vitest"
13
13
  },
14
14
  "dependencies": {
15
- "@inquirer/prompts": "^8.5.2",
16
- "chalk": "^5.3.0"
15
+ "chalk": "^5.3.0",
16
+ "@inquirer/prompts": "^8.5.2"
17
17
  },
18
18
  "devDependencies": {
19
19
  "vitest": "^4.1.8"
20
20
  },
21
21
  "engines": {
22
- "node": ">=18.0.0"
22
+ "node": ">=20.10.0"
23
23
  },
24
24
  "keywords": [
25
25
  "gpu",
26
26
  "cli",
27
27
  "ai",
28
28
  "compute",
29
- "modal",
30
29
  "gateway",
31
- "openai"
30
+ "openai",
31
+ "workload",
32
+ "workspace",
33
+ "llm",
34
+ "inference",
35
+ "fine-tuning"
32
36
  ],
33
- "license": "MIT"
37
+ "license": "Apache-2.0"
34
38
  }
package/src/badgr.js CHANGED
@@ -18,6 +18,8 @@ import { trainCommand } from './commands/train.js';
18
18
  import { transcribeCommand } from './commands/transcribe.js';
19
19
  import { embedCommand } from './commands/embed.js';
20
20
  import { templateCommand } from './commands/template.js';
21
+ import { workloadCommand } from './commands/workload.js';
22
+ import { workspaceCommand } from './commands/workspace.js';
21
23
 
22
24
  const HELP = `
23
25
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
@@ -33,6 +35,10 @@ ${chalk.bold('COMMANDS')}
33
35
  ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
34
36
  ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
35
37
  ${chalk.cyan('badgr billing')} Show balance and add funds
38
+ ${chalk.cyan('badgr workload list')} List saved workloads
39
+ ${chalk.cyan('badgr workload run <name>')} Rerun a saved workload
40
+ ${chalk.cyan('badgr workspace list')} List workspace trackers (job history + cost per named context)
41
+ ${chalk.cyan('badgr workspace create')} Create a workspace tracker (link jobs to a named storage path)
36
42
 
37
43
  ${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common workloads)')}
38
44
  ${chalk.cyan('badgr comfyui run <workflow.json>')} Launch ComfyUI, return endpoint URL
@@ -147,6 +153,8 @@ async function main() {
147
153
  case 'transcribe': return transcribeCommand(config, rest, chalk);
148
154
  case 'embed': return embedCommand(config, rest, chalk);
149
155
  case 'template': return templateCommand(config, rest, chalk);
156
+ case 'workload': return workloadCommand(config, rest, chalk);
157
+ case 'workspace': return workspaceCommand(config, rest, chalk);
150
158
  // legacy aliases kept for compatibility
151
159
  case 'up': return upCommand(config, rest, chalk);
152
160
  case 'config': {
package/src/catalog.js CHANGED
@@ -9,7 +9,7 @@ export const TEMPLATES = [
9
9
  title: 'ComfyUI',
10
10
  description: 'Stable Diffusion image generation with ComfyUI node editor',
11
11
  type: 'endpoint',
12
- image: 'yanwk/comfyui-boot:latest',
12
+ image: process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak',
13
13
  gpu: 'RTX_4090',
14
14
  gpu_count: 1,
15
15
  port: 8188,
@@ -222,6 +222,198 @@ export const TEMPLATES = [
222
222
  'HF_TOKEN required for Llama, Gemma, and other gated models.',
223
223
  ],
224
224
  },
225
+ {
226
+ name: 'auto1111',
227
+ title: 'AUTOMATIC1111',
228
+ description: 'Stable Diffusion web UI — the largest SD ecosystem with ControlNet, LoRA, img2img, inpainting, and upscaling',
229
+ type: 'endpoint',
230
+ image: 'aidockorg/stable-diffusion-webui-cuda:latest',
231
+ gpu: 'RTX_4090',
232
+ gpu_count: 1,
233
+ port: 7860,
234
+ health_path: '/',
235
+ min_vram_gb: 6,
236
+ env: {
237
+ WEBUI_FLAGS: '--xformers',
238
+ },
239
+ notes: [
240
+ 'Web UI at the returned endpoint URL (port 7860).',
241
+ 'Place model checkpoints under /opt/dl-ui/repositories/stable-diffusion-webui/models/Stable-diffusion/.',
242
+ 'Override WEBUI_FLAGS to pass extra launch args (e.g. --medvram for lower VRAM).',
243
+ ],
244
+ },
245
+ {
246
+ name: 'forge',
247
+ title: 'Stable Diffusion WebUI Forge',
248
+ description: 'Optimised fork of A1111 with GPU memory improvements — runs models that OOM on standard A1111',
249
+ type: 'endpoint',
250
+ image: 'aidockorg/stable-diffusion-webui-forge-cuda:latest',
251
+ gpu: 'RTX_4090',
252
+ gpu_count: 1,
253
+ port: 7860,
254
+ health_path: '/',
255
+ min_vram_gb: 4,
256
+ env: {
257
+ WEBUI_FLAGS: '--xformers',
258
+ },
259
+ notes: [
260
+ 'Web UI at the returned endpoint URL (port 7860).',
261
+ 'Forge uses less VRAM than A1111 for the same model — good for 8–12 GB cards.',
262
+ 'Compatible with most A1111 extensions.',
263
+ ],
264
+ },
265
+ {
266
+ name: 'nerfstudio',
267
+ title: 'Nerfstudio',
268
+ description: 'NeRF and 3D Gaussian Splatting training and reconstruction from image captures',
269
+ type: 'job',
270
+ image: 'ghcr.io/nerfstudio-project/nerfstudio:latest',
271
+ gpu: 'RTX_4090',
272
+ gpu_count: 1,
273
+ min_vram_gb: 16,
274
+ env: {
275
+ METHOD: 'nerfacto',
276
+ },
277
+ notes: [
278
+ 'Mount your images to /workspace/data and your output dir to /workspace/outputs.',
279
+ 'Override METHOD to switch between nerfacto, splatfacto, instant-ngp, etc.',
280
+ 'Training a typical scene takes 15–45 min depending on method and image count.',
281
+ ],
282
+ },
283
+ {
284
+ name: 'openfold',
285
+ title: 'OpenFold',
286
+ description: 'Trainable, memory-efficient GPU-friendly PyTorch reproduction of AlphaFold 2 for protein structure prediction',
287
+ type: 'job',
288
+ image: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
289
+ gpu: 'A100',
290
+ gpu_count: 1,
291
+ min_vram_gb: 40,
292
+ env: {
293
+ OPENFOLD_REPO: 'https://github.com/aqlaboratory/openfold',
294
+ },
295
+ notes: [
296
+ 'No pre-built public image exists — this uses a CUDA base; clone and install OpenFold at boot.',
297
+ 'See https://github.com/aqlaboratory/openfold for full install steps.',
298
+ 'Full inference on a single sequence: ~5–20 min on an A100.',
299
+ ],
300
+ },
301
+ {
302
+ name: 'blender-render',
303
+ title: 'Blender GPU Render',
304
+ description: 'Headless Blender GPU rendering via CUDA — render .blend files to image sequences or video',
305
+ type: 'job',
306
+ image: 'blenderkit/headless-blender:blender-4.4',
307
+ gpu: 'RTX_4090',
308
+ gpu_count: 1,
309
+ min_vram_gb: 8,
310
+ env: {
311
+ BLEND_FILE: '/workspace/scene.blend',
312
+ FRAME_START: '1',
313
+ FRAME_END: '250',
314
+ },
315
+ notes: [
316
+ 'Mount your .blend file to /workspace/scene.blend.',
317
+ 'Set FRAME_START and FRAME_END to the frame range to render.',
318
+ 'Output frames are written to /workspace/output/ by default.',
319
+ ],
320
+ },
321
+ {
322
+ name: 'openmm',
323
+ title: 'OpenMM',
324
+ description: 'GPU-accelerated molecular dynamics simulation using the OpenMM toolkit',
325
+ type: 'job',
326
+ image: 'saladtechnologies/openmm:latest',
327
+ gpu: 'RTX_4090',
328
+ gpu_count: 1,
329
+ min_vram_gb: 8,
330
+ env: {
331
+ SIMULATION_SCRIPT: '/workspace/simulate.py',
332
+ },
333
+ notes: [
334
+ 'Mount your Python simulation script to /workspace/simulate.py.',
335
+ 'OpenMM auto-selects CUDA platform when a GPU is present.',
336
+ 'Typical MD simulation runs: minutes to hours depending on system size and steps.',
337
+ ],
338
+ },
339
+ {
340
+ name: 'gromacs',
341
+ title: 'GROMACS',
342
+ description: 'High-performance molecular dynamics package with GPU-accelerated bonded and non-bonded calculations',
343
+ type: 'job',
344
+ image: 'scientiflow/gromacs-gpu:2024.3',
345
+ gpu: 'RTX_4090',
346
+ gpu_count: 1,
347
+ min_vram_gb: 8,
348
+ env: {
349
+ GMX_GPU_DD_COMMS: 'true',
350
+ GMX_GPU_PME_PP_COMMS: 'true',
351
+ },
352
+ notes: [
353
+ 'Mount your .tpr input file and output directory to /workspace.',
354
+ 'GROMACS auto-detects CUDA GPU — no extra flags needed.',
355
+ 'For multi-GPU: increase gpu_count and pass -ntmpi / -ntomp flags via command override.',
356
+ ],
357
+ },
358
+ {
359
+ name: 'lammps',
360
+ title: 'LAMMPS',
361
+ description: 'Large-scale Atomic/Molecular Massively Parallel Simulator with GPU acceleration via Kokkos/CUDA',
362
+ type: 'job',
363
+ image: 'nvcr.io/hpc/lammps:release-29Sep2021',
364
+ gpu: 'RTX_4090',
365
+ gpu_count: 1,
366
+ min_vram_gb: 8,
367
+ env: {
368
+ LAMMPS_INPUT: '/workspace/in.lammps',
369
+ },
370
+ notes: [
371
+ 'Mount your LAMMPS input script to /workspace/in.lammps.',
372
+ 'GPU acceleration uses the Kokkos or GPU package — enable via -pk gpu 1 in your input.',
373
+ 'Simulation runtime scales with system size; use --max-runtime to cap spend.',
374
+ ],
375
+ },
376
+ {
377
+ name: 'diffusers',
378
+ title: 'Hugging Face Diffusers',
379
+ description: 'State-of-the-art diffusion model library for image, video, and audio generation',
380
+ type: 'job',
381
+ image: 'diffusers/diffusers-pytorch-cuda:latest',
382
+ gpu: 'RTX_4090',
383
+ gpu_count: 1,
384
+ min_vram_gb: 16,
385
+ env: {
386
+ HF_TOKEN: '<for-gated-models>',
387
+ MODEL_ID: 'stabilityai/stable-diffusion-xl-base-1.0',
388
+ SCRIPT: '/workspace/run.py',
389
+ },
390
+ notes: [
391
+ 'Mount your generation or training script to /workspace/run.py.',
392
+ 'HF_TOKEN required for gated models.',
393
+ 'Supports SDXL, FLUX, Stable Diffusion 3, ControlNet, IP-Adapter, and more.',
394
+ ],
395
+ },
396
+ {
397
+ name: 'torchtune',
398
+ title: 'torchtune',
399
+ description: 'PyTorch-native post-training library for LLM fine-tuning via config-driven YAML recipes',
400
+ type: 'job',
401
+ image: 'pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel',
402
+ gpu: 'A100',
403
+ gpu_count: 1,
404
+ min_vram_gb: 40,
405
+ env: {
406
+ HF_TOKEN: '<for-gated-models>',
407
+ RECIPE: 'lora_finetune_single_device',
408
+ CONFIG: '/workspace/config.yaml',
409
+ },
410
+ notes: [
411
+ 'Install torchtune at boot: pip install torchtune.',
412
+ 'Mount your YAML recipe config to /workspace/config.yaml.',
413
+ 'HF_TOKEN required for Llama and other gated base models.',
414
+ 'Supports LoRA, QLoRA, DPO, full fine-tune, and quantisation-aware training.',
415
+ ],
416
+ },
225
417
  ];
226
418
 
227
419
  export const TEMPLATE_MAP = Object.fromEntries(TEMPLATES.map(t => [t.name, t]));
@@ -12,7 +12,7 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
12
12
  import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
13
13
  import { formatCliError } from '../errors.js';
14
14
 
15
- const COMFYUI_IMAGE = 'yanwk/comfyui-boot:latest';
15
+ const COMFYUI_IMAGE = process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak';
16
16
  const HEALTH_PATH = '/system_stats';
17
17
  const WAIT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — model downloads on first boot
18
18
  const MAX_WORKFLOW_B = 1 * 1024 * 1024; // 1 MB workflow limit
@@ -5,7 +5,8 @@ function fmtMs(ms) {
5
5
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
6
6
  }
7
7
 
8
- function fmtRuntime(s) {
8
+ export function fmtRuntime(s) {
9
+ if (s == null) return '—';
9
10
  if (s < 60) return `${s}s`;
10
11
  return `${Math.floor(s / 60)}m ${s % 60}s`;
11
12
  }
@@ -14,32 +14,42 @@ import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../cat
14
14
  export function parseRunArgs(args) {
15
15
  const flags = {};
16
16
  const positional = [];
17
+
18
+ // Split at -- so badgr flags and the user command don't collide.
19
+ // Everything before -- is parsed for flags; everything after becomes commandArgv.
20
+ const sepIdx = args.indexOf('--');
21
+ const flagArgs = sepIdx === -1 ? args : args.slice(0, sepIdx);
22
+ const commandArgv = sepIdx === -1 ? null : args.slice(sepIdx + 1);
23
+
17
24
  let i = 0;
18
- while (i < args.length) {
19
- if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
20
- if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
21
- if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
22
- if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
23
- if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
24
- if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
25
- if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
26
- if (args[i] === '--detach') { flags.detach = true; i++; continue; }
27
- if (args[i] === '--fallback') { flags.fallback = args[++i]; i++; continue; }
28
- if (args[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
29
- if (args[i] === '--strict-capacity') { flags.noFallback = true; i++; continue; }
30
- if (args[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
31
- if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
32
- if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
33
- if (args[i] === '--min-vram') { flags.minVram = parseFloat(args[++i]); i++; continue; }
34
- if (args[i] === '--env') {
35
- const kv = args[++i]; i++;
25
+ while (i < flagArgs.length) {
26
+ if (flagArgs[i] === '--gpu') { flags.gpu = flagArgs[++i]; i++; continue; }
27
+ if (flagArgs[i] === '--image') { flags.image = flagArgs[++i]; i++; continue; }
28
+ if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
29
+ if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
30
+ if (flagArgs[i] === '--tier') { flags.tier = flagArgs[++i]; i++; continue; }
31
+ if (flagArgs[i] === '--max-price') { flags.maxPrice = parseFloat(flagArgs[++i]); i++; continue; }
32
+ if (flagArgs[i] === '--name') { flags.name = flagArgs[++i]; i++; continue; }
33
+ if (flagArgs[i] === '--detach') { flags.detach = true; i++; continue; }
34
+ if (flagArgs[i] === '--fallback') { flags.fallback = flagArgs[++i]; i++; continue; }
35
+ if (flagArgs[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
36
+ if (flagArgs[i] === '--strict-capacity') { flags.noFallback = true; i++; continue; }
37
+ if (flagArgs[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
38
+ if (flagArgs[i] === '--max-runtime') { flags.maxRuntime = parseFloat(flagArgs[++i]); i++; continue; }
39
+ if (flagArgs[i] === '--max-cost') { flags.maxCost = parseFloat(flagArgs[++i]); i++; continue; }
40
+ if (flagArgs[i] === '--min-vram') { flags.minVram = parseFloat(flagArgs[++i]); i++; continue; }
41
+ if (flagArgs[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
42
+ if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
43
+ if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
44
+ if (flagArgs[i] === '--env') {
45
+ const kv = flagArgs[++i]; i++;
36
46
  if (!flags.env) flags.env = [];
37
47
  flags.env.push(kv);
38
48
  continue;
39
49
  }
40
- positional.push(args[i++]);
50
+ positional.push(flagArgs[i++]);
41
51
  }
42
- return { flags, positional };
52
+ return { flags, positional, commandArgv };
43
53
  }
44
54
 
45
55
  function parseEnvFlag(envList) {
@@ -332,6 +342,7 @@ const _KNOWN_RUN_FLAGS = new Set([
332
342
  '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
333
343
  '--detach', '--fallback', '--no-fallback', '--strict-capacity',
334
344
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
345
+ '--dry-run',
335
346
  ]);
336
347
 
337
348
  export async function runCommand(config, args, chalk) {
@@ -357,7 +368,7 @@ export async function runCommand(config, args, chalk) {
357
368
  return runCommand(config, expandedArgs, chalk);
358
369
  }
359
370
 
360
- const { flags, positional } = parseRunArgs(args);
371
+ const { flags, positional, commandArgv } = parseRunArgs(args);
361
372
 
362
373
  // Detect flags that ended up in the command because of broken shell line continuation
363
374
  // (e.g. `\ ` with trailing space instead of `\<newline>`).
@@ -376,12 +387,17 @@ export async function runCommand(config, args, chalk) {
376
387
  return;
377
388
  }
378
389
 
379
- if (positional.length === 0 && !flags.image) {
380
- console.error(chalk.red('Usage: badgr run <command...>'));
390
+ if (positional.length === 0 && commandArgv === null && !flags.image) {
391
+ console.error(chalk.red('Usage: badgr run --gpu <GPU> --image <image> --max-cost <n> -- <command...>'));
381
392
  console.error(chalk.red(' badgr run --image my/image:latest'));
382
393
  return;
383
394
  }
384
-
395
+ if (commandArgv !== null && commandArgv.length === 0 && !flags.image) {
396
+ console.error(chalk.red(' ✗ No command after --. Provide a command or --image.'));
397
+ console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --max-cost 1 -- node script.js'));
398
+ process.exitCode = 1;
399
+ return;
400
+ }
385
401
  requireApiKey(config);
386
402
 
387
403
  // ── Validate flags early ───────────────────────────────────────────────────
@@ -405,7 +421,18 @@ export async function runCommand(config, args, chalk) {
405
421
  process.exitCode = 1;
406
422
  return;
407
423
  }
408
- const command = positional.length > 0 ? positional : undefined;
424
+
425
+ if (!flags.maxCost && !flags.dryRun) {
426
+ console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
427
+ console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
428
+ process.exitCode = 1;
429
+ return;
430
+ }
431
+
432
+ // commandArgv is set when -- separator was used; fall back to positional for legacy syntax.
433
+ const command = commandArgv !== null
434
+ ? (commandArgv.length > 0 ? commandArgv : undefined)
435
+ : (positional.length > 0 ? positional : undefined);
409
436
  const cmdStr = command ? command.join(' ') : '';
410
437
  const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
411
438
  const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
@@ -424,6 +451,19 @@ export async function runCommand(config, args, chalk) {
424
451
 
425
452
  const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
426
453
 
454
+ if (flags.dryRun) {
455
+ console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
456
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
457
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
458
+ console.log(` ${chalk.bold('GPU:')} ${gpu || chalk.dim('auto')}`);
459
+ console.log(` ${chalk.bold('Max runtime:')} ${effectiveMaxRuntime}min`);
460
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
461
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
462
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
463
+ console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
464
+ return;
465
+ }
466
+
427
467
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
428
468
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
429
469
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
@@ -441,6 +481,23 @@ export async function runCommand(config, args, chalk) {
441
481
  console.log();
442
482
 
443
483
 
484
+ // Resolve --workspace name → ws_… ID before submitting
485
+ let resolvedWorkspaceId = flags.workspace ?? null;
486
+ if (resolvedWorkspaceId && !resolvedWorkspaceId.startsWith('ws_')) {
487
+ try {
488
+ const wsData = await callApi(`/workspaces?limit=100`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
489
+ const match = (wsData?.workspaces ?? []).find(w => w.name === resolvedWorkspaceId);
490
+ if (!match) {
491
+ console.error(chalk.red(`Workspace not found: ${resolvedWorkspaceId}`));
492
+ process.exitCode = 1; return;
493
+ }
494
+ resolvedWorkspaceId = match.workspace_id;
495
+ } catch (err) {
496
+ console.error(chalk.red(`Could not resolve workspace: ${err.message}`));
497
+ process.exitCode = 1; return;
498
+ }
499
+ }
500
+
444
501
  console.log(chalk.dim(' Finding suitable capacity...'));
445
502
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
446
503
  console.log(chalk.dim(` API: ${config.baseUrl}`));
@@ -461,6 +518,7 @@ export async function runCommand(config, args, chalk) {
461
518
  ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
462
519
  max_runtime_seconds: effectiveMaxRuntime * 60,
463
520
  ...(maxCost ? { max_cost_usd: maxCost } : {}),
521
+ ...(resolvedWorkspaceId ? { workspace_id: resolvedWorkspaceId } : {}),
464
522
  };
465
523
  }
466
524
 
@@ -651,6 +709,34 @@ export async function runCommand(config, args, chalk) {
651
709
  } catch { /* already stopped */ }
652
710
  console.log(chalk.green(`\n ✓ Complete`));
653
711
  console.log(chalk.dim(` Billing ended`));
712
+
713
+ if (flags.save && config.apiKey) {
714
+ try {
715
+ const saved = await callApi('/workloads', {
716
+ method: 'POST',
717
+ apiKey: config.apiKey,
718
+ baseUrl: config.baseUrl,
719
+ body: {
720
+ name: flags.save,
721
+ job_type: 'custom.run',
722
+ config: {
723
+ command: command || [],
724
+ image: image || 'python:3.11-slim',
725
+ gpu: gpu || 'auto',
726
+ ...(flags.minVram ? { min_vram: flags.minVram } : {}),
727
+ gpu_count: flags.count || 1,
728
+ ...(flags.maxPrice ? { max_price_per_hour: flags.maxPrice } : {}),
729
+ ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
730
+ },
731
+ default_max_cost: maxCost || 10.0,
732
+ default_max_runtime_minutes: effectiveMaxRuntime,
733
+ },
734
+ });
735
+ console.log(chalk.cyan(` Saved as workload: ${saved.name} (${saved.workload_id})`));
736
+ } catch (err) {
737
+ console.log(chalk.yellow(` Could not save workload: ${err.message}`));
738
+ }
739
+ }
654
740
  console.log();
655
741
  }
656
742
  }
@@ -2,7 +2,7 @@ import { requireApiKey } from '../config.js';
2
2
  import { callApi, terminateDeployment } from '../api.js';
3
3
  import { addReceipt, generateReceiptId } from '../store.js';
4
4
 
5
- // max $0.80/hr × 2 min ≈ $0.027 total spend cap (smoke / Modal T4 tier)
5
+ // max $0.80/hr × 2 min ≈ $0.027 total spend cap
6
6
  const TEST_MAX_PRICE = 0.80;
7
7
  const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
8
8
  const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
@@ -12,8 +12,8 @@ const TEST_IMAGE = 'python:3.11-alpine';
12
12
  const EXPECTED_OUTPUT = 'hello from badgr';
13
13
 
14
14
  // --provider flag resolves to a backend tier value.
15
- // 'tier1' → managed routing (default), 'tier2' → marketplace routing, 'secondary' → secondary dispatch.
16
- const PROVIDER_TO_TIER = { tier1: '1', tier2: '2', secondary: 'modal' };
15
+ // 'tier1' → managed routing (default), 'tier2' → marketplace routing.
16
+ const PROVIDER_TO_TIER = { tier1: '1', tier2: '2' };
17
17
 
18
18
  export function parseTestArgs(args) {
19
19
  const flags = {};
@@ -95,7 +95,7 @@ export async function testCommand(config, args, chalk) {
95
95
  } catch {
96
96
  routes = null;
97
97
  }
98
- const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.name === 'modal') : null;
98
+ const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.tier === '2') : null;
99
99
  if (secondaryRoute?.available) {
100
100
  step(chalk, true, 'Secondary provider configured');
101
101
  console.log(chalk.green('\n ✓ Secondary dispatch provider is ready\n'));