badgr-cli 1.0.40 → 1.0.42

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
@@ -17,11 +17,15 @@ badgr login
17
17
  # 2. Verify the stack end-to-end
18
18
  badgr test
19
19
 
20
- # 3. Serve an OpenAI-compatible inference endpoint
21
- badgr serve meta-llama/Llama-3.1-8B-Instruct
20
+ # 3. Run a project folder on a GPU
21
+ badgr run . --cmd "python train.py" --max-cost 5
22
22
 
23
- # 4. Use the endpoint with any OpenAI SDK client
24
- # client = OpenAI(api_key="sk-...", base_url="https://dep-a1b2c3.aibadgr.com/v1")
23
+ # 4. Serve an OpenAI-compatible inference endpoint
24
+ badgr serve meta-llama/Llama-3.1-8B-Instruct --max-cost 10
25
+
26
+ # 5. Use the endpoint with any OpenAI SDK client
27
+ # export BADGR_ENDPOINT=<URL printed by badgr serve>
28
+ # client = OpenAI(api_key=os.environ["BADGR_API_KEY"], base_url=os.environ["BADGR_ENDPOINT"])
25
29
 
26
30
  # 5. View cost, route, and retry receipts
27
31
  badgr receipts
@@ -92,25 +96,36 @@ badgr serve meta-llama/Llama-3.1-8B-Instruct --gpu L40S --region EU
92
96
 
93
97
  ## `badgr run` options
94
98
 
99
+ Three source patterns:
100
+
95
101
  ```bash
96
- badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 60
102
+ # Flow 1 local project folder (primary)
103
+ badgr run . --cmd "python train.py" --max-cost 5
104
+
105
+ # Flow 2 — public GitHub repo
106
+ badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
107
+
108
+ # Flow 3 — custom Docker image (advanced)
109
+ badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
97
110
  ```
98
111
 
112
+ 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.
113
+
99
114
  | Flag | Default | Description |
100
115
  |------|---------|-------------|
116
+ | `--cmd <command>` | — | Command to run inside the uploaded project or cloned repo (required for folder/GitHub flows) |
101
117
  | `--gpu <type>` | auto | GPU type override — Badgr Auto selects if omitted |
102
118
  | `--min-vram <GB>` | — | Minimum VRAM in GB — optional constraint for Auto routing |
103
- | `--image <img>` | python:3.11-slim | Docker image |
119
+ | `--image <img>` | | Custom Docker image — bypasses the runner. Mutually exclusive with workspace auto-detection. |
104
120
  | `--env KEY=VALUE` | — | Environment variable (repeatable) |
105
121
  | `--tier 1\|2` | 1 | `1` = reliable execution (default); `2` = lower-cost burst capacity |
106
122
  | `--count <n>` | 1 | Number of GPUs |
107
123
  | `--region US\|EU\|AU` | — | Optional region preference. If omitted, Badgr chooses best available capacity. |
108
124
  | `--max-price <$/hr>` | — | Hard spend cap per GPU-hour |
109
- | `--max-runtime <min>` | — | Auto-stop after N minutes (recommended) |
110
- | `--max-cost <$>` | — | Auto-stop when total spend reaches this amount |
125
+ | `--max-runtime <min>` | — | Auto-stop after N minutes |
126
+ | `--max-cost <$>` | — | Auto-stop when total spend reaches this amount (required) |
111
127
  | `--detach` | — | Launch and return immediately, don't stream logs |
112
128
  | `--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) |
114
129
 
115
130
  ---
116
131
 
@@ -216,7 +231,7 @@ A workload is a saved job configuration. Once saved, you can rerun it by name in
216
231
  **Save a workload** by adding `--save <name>` to any `badgr run` call:
217
232
 
218
233
  ```bash
219
- badgr run python train.py --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-runtime 120 --save my-training-job
234
+ badgr run . --cmd "python train.py" --gpu A100 --env HF_TOKEN=$HF_TOKEN --max-cost 10 --save my-training-job
220
235
  ```
221
236
 
222
237
  **Rerun a saved workload:**
@@ -245,11 +260,11 @@ badgr workload delete my-training-job
245
260
 
246
261
  ## Workspaces
247
262
 
248
- A workspace tracker groups jobs by a named context useful for tracking cost and job history across a project or storage path.
263
+ Workspaces are an advanced infrastructure concept most users never need to manage them directly. When you run `badgr run .`, Badgr handles code upload, dependency caching, and artifact storage automatically. Workspaces are only needed when you want to group jobs under a named context for cost tracking, or link jobs to persistent S3/GCS storage.
249
264
 
250
265
  ```bash
251
266
  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
267
+ badgr run . --cmd "python eval.py" --workspace my-project --max-cost 5
253
268
  badgr workspace info my-project # jobs, total cost, files
254
269
  badgr workspace list
255
270
  badgr workspace delete my-project
@@ -298,11 +313,13 @@ Each receipt includes: receipt ID, GPU type, provisioning latency, price/hr, ret
298
313
  `badgr serve` provisions a vLLM endpoint that is fully OpenAI-compatible:
299
314
 
300
315
  ```python
316
+ import os
301
317
  from openai import OpenAI
302
318
 
319
+ # Export BADGR_ENDPOINT from the URL printed by `badgr serve`
303
320
  client = OpenAI(
304
- api_key="your-badgr-api-key",
305
- base_url="https://dep-a1b2c3.aibadgr.com/v1", # from badgr serve output
321
+ api_key=os.environ["BADGR_API_KEY"],
322
+ base_url=os.environ["BADGR_ENDPOINT"],
306
323
  )
307
324
  resp = client.chat.completions.create(
308
325
  model="meta-llama/Llama-3.1-8B-Instruct",
@@ -314,7 +331,7 @@ resp = client.chat.completions.create(
314
331
  import OpenAI from "openai";
315
332
  const client = new OpenAI({
316
333
  apiKey: process.env.BADGR_API_KEY,
317
- baseURL: "https://dep-a1b2c3.aibadgr.com/v1",
334
+ baseURL: process.env.BADGR_ENDPOINT, // URL printed by `badgr serve`
318
335
  });
319
336
  ```
320
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.40",
3
+ "version": "1.0.42",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,8 @@
12
12
  "test:watch": "vitest"
13
13
  },
14
14
  "dependencies": {
15
- "chalk": "^5.3.0",
16
- "@inquirer/prompts": "^8.5.2"
15
+ "@inquirer/prompts": "^8.5.2",
16
+ "chalk": "^5.3.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "vitest": "^4.1.8"
package/src/catalog.js CHANGED
@@ -40,6 +40,30 @@ export const TEMPLATES = [
40
40
  'Pass HF_TOKEN via --env or BADGR_HF_TOKEN env var.',
41
41
  ],
42
42
  },
43
+ {
44
+ name: 'batch-inference',
45
+ title: 'Batch Inference',
46
+ description: 'Run a Python script against many inputs on GPU — eval, scoring, embedding, generation',
47
+ type: 'job',
48
+ image: 'pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime',
49
+ gpu: 'RTX_4090',
50
+ gpu_count: 1,
51
+ min_vram_gb: 16,
52
+ env: {
53
+ HF_TOKEN: '<your-hf-token>',
54
+ INPUT_FILE: 'inputs.jsonl',
55
+ OUTPUT_FILE: 'outputs.jsonl',
56
+ MODEL: 'meta-llama/Llama-3.1-8B-Instruct',
57
+ HF_HUB_CACHE: '/workspace/hf-cache',
58
+ BADGR_OUTPUT_DIR: '/workspace/outputs',
59
+ },
60
+ notes: [
61
+ 'Put your script and inputs.jsonl in the project folder.',
62
+ 'Write outputs to $BADGR_OUTPUT_DIR — they are uploaded as artifacts (48h TTL).',
63
+ 'Download results: `badgr artifacts <job-id>`',
64
+ 'Example: `badgr run . --cmd "python batch.py" --max-cost 5 --save my-batch`',
65
+ ],
66
+ },
43
67
  {
44
68
  name: 'unsloth',
45
69
  title: 'Unsloth Fine-Tuning',
@@ -418,6 +442,44 @@ export const TEMPLATES = [
418
442
 
419
443
  export const TEMPLATE_MAP = Object.fromEntries(TEMPLATES.map(t => [t.name, t]));
420
444
 
445
+ // ---------------------------------------------------------------------------
446
+ // Productized runner catalogs
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /** Blessed vLLM model aliases. `badgr serve qwen-7b` expands to the full model ID. */
450
+ export const BLESSED_VLLM_MODELS = {
451
+ 'qwen-7b': {
452
+ model_id: 'Qwen/Qwen2.5-7B-Instruct',
453
+ gpu_type: 'RTX_4090',
454
+ image: 'vllm/vllm-openai:latest',
455
+ health_path: '/v1/models',
456
+ description: 'Qwen 2.5 7B Instruct — fast, multilingual',
457
+ },
458
+ 'llama-8b': {
459
+ model_id: 'meta-llama/Llama-3.1-8B-Instruct',
460
+ gpu_type: 'RTX_4090',
461
+ image: 'vllm/vllm-openai:latest',
462
+ health_path: '/v1/models',
463
+ description: 'Llama 3.1 8B Instruct — Meta flagship 8B',
464
+ },
465
+ 'qwen-coder-7b': {
466
+ model_id: 'Qwen/Qwen2.5-Coder-7B-Instruct',
467
+ gpu_type: 'RTX_4090',
468
+ image: 'vllm/vllm-openai:latest',
469
+ health_path: '/v1/models',
470
+ description: 'Qwen 2.5 Coder 7B — code generation specialist',
471
+ },
472
+ };
473
+
474
+ /** Blessed ComfyUI workflows accepted by `POST /v1/jobs` comfy.batch. */
475
+ export const BLESSED_COMFY_WORKFLOWS = {
476
+ 'sdxl-basic': {
477
+ description: 'SDXL text-to-image with default sampler settings',
478
+ gpu_type: 'RTX_4090',
479
+ output_type: 'images',
480
+ },
481
+ };
482
+
421
483
  /**
422
484
  * Build the args array passed to serveCommand / runCommand.
423
485
  * Template defaults are applied first; CLI overrides win.
@@ -110,11 +110,143 @@ async function validateComfyNodes(endpointUrl, nodeList, chalk) {
110
110
  }
111
111
  }
112
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // badgr comfyui batch — productized batch via POST /v1/jobs comfy.batch
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export function parseComfyBatchArgs(args) {
118
+ const flags = {};
119
+ let i = 0;
120
+ while (i < args.length) {
121
+ const a = args[i];
122
+ if (a === '--workflow') { flags.workflow = args[++i]; i++; continue; }
123
+ if (a === '--prompts') { flags.prompts = args[++i]; i++; continue; }
124
+ if (a === '--prompt') { if (!flags.inlinePrompts) flags.inlinePrompts = []; flags.inlinePrompts.push(args[++i]); i++; continue; }
125
+ if (a === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
126
+ if (a === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
127
+ if (a === '--tier') { flags.tier = args[++i]; i++; continue; }
128
+ if (a === '--gpu-type') { flags.gpuType = args[++i]; i++; continue; }
129
+ i++;
130
+ }
131
+ return flags;
132
+ }
133
+
134
+ export async function comfyBatchCommand(config, args, chalk) {
135
+ const { readFileSync, existsSync } = await import('fs');
136
+ const { callApi } = await import('../api.js');
137
+ const { addReceipt, generateReceiptId } = await import('../store.js');
138
+ const flags = parseComfyBatchArgs(args);
139
+
140
+ if (!flags.workflow) {
141
+ console.error(chalk.red('\n Usage: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
142
+ console.error(chalk.dim(' Runs a batch of prompts through a blessed ComfyUI workflow and returns image URLs.\n'));
143
+ console.error(chalk.dim(' Blessed workflows: sdxl-basic\n'));
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+
148
+ if (!flags.maxCost) {
149
+ console.error(chalk.red('\n ✗ --max-cost is required.\n'));
150
+ process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ requireApiKey(config);
155
+
156
+ // Collect prompts from file or --prompt flags
157
+ let prompts = flags.inlinePrompts || [];
158
+ if (flags.prompts) {
159
+ if (!existsSync(flags.prompts)) {
160
+ console.error(chalk.red(`\n ✗ Prompts file not found: ${flags.prompts}\n`));
161
+ process.exitCode = 1;
162
+ return;
163
+ }
164
+ const lines = readFileSync(flags.prompts, 'utf8')
165
+ .split('\n')
166
+ .map(l => l.trim())
167
+ .filter(Boolean);
168
+ prompts = prompts.concat(lines);
169
+ }
170
+
171
+ if (prompts.length === 0) {
172
+ console.error(chalk.red('\n ✗ No prompts provided. Use --prompts file.txt or --prompt "text"\n'));
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+
177
+ if (prompts.length > 20) {
178
+ console.error(chalk.red(`\n ✗ Max 20 prompts per batch (got ${prompts.length})\n`));
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+
183
+ const input = { workflow_id: flags.workflow, prompts };
184
+ if (flags.gpuType) input.gpu_type = flags.gpuType;
185
+
186
+ const rcptId = generateReceiptId();
187
+ const maxRuntime = flags.maxRuntime ?? 60;
188
+
189
+ console.log(chalk.bold('\n⚡ Starting ComfyUI batch\n'));
190
+ console.log(` ${chalk.bold('Workflow:')} ${flags.workflow}`);
191
+ console.log(` ${chalk.bold('Prompts:')} ${prompts.length}`);
192
+ console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost}`);
193
+ console.log(` ${chalk.bold('Max runtime:')} ${maxRuntime} min\n`);
194
+
195
+ let job;
196
+ try {
197
+ job = await callApi(config, 'POST', '/v1/jobs', {
198
+ type: 'comfy.batch',
199
+ input,
200
+ policy: { max_cost: flags.maxCost, max_runtime_minutes: maxRuntime, tier: flags.tier },
201
+ });
202
+ } catch (err) {
203
+ console.error(chalk.red(`\n ✗ Failed to submit job: ${err.message}\n`));
204
+ process.exitCode = 1;
205
+ return;
206
+ }
207
+
208
+ addReceipt({ id: rcptId, type: 'comfy.batch', job_id: job.job_id, started_at: Date.now() });
209
+ console.log(` ${chalk.bold('Job ID:')} ${job.job_id}`);
210
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
211
+ console.log(chalk.dim('\n Polling for completion…\n'));
212
+
213
+ const startMs = Date.now();
214
+ const maxMs = maxRuntime * 60 * 1000;
215
+ while (Date.now() - startMs < maxMs) {
216
+ await new Promise(r => setTimeout(r, 15_000));
217
+ let detail;
218
+ try { detail = await callApi(config, 'GET', `/v1/jobs/${job.job_id}`); } catch { continue; }
219
+ process.stdout.write(`\r Status: ${detail.status} elapsed: ${Math.floor((Date.now() - startMs) / 1000)}s `);
220
+ if (detail.status === 'completed') {
221
+ const out = detail.output || {};
222
+ console.log(chalk.green('\n\n ✓ Batch complete\n'));
223
+ if (out.image_urls && out.image_urls.length > 0) {
224
+ console.log(` ${chalk.bold('Images (${out.image_urls.length}):')}`);
225
+ out.image_urls.forEach((url, i) => console.log(` ${i + 1}. ${url}`));
226
+ }
227
+ console.log(`\n ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}\n`);
228
+ return;
229
+ }
230
+ if (detail.status === 'failed') {
231
+ console.error(chalk.red(`\n\n ✗ Batch failed: ${detail.error_code || ''} — ${detail.error_message || ''}\n`));
232
+ process.exitCode = 1;
233
+ return;
234
+ }
235
+ }
236
+ console.error(chalk.yellow('\n\n Batch still running — detached. Check status:\n'));
237
+ console.error(chalk.dim(` badgr status\n`));
238
+ }
239
+
113
240
  export async function comfyuiCommand(config, args, chalk) {
241
+ // Route subcommands
242
+ const sub = args[0];
243
+ if (sub === 'batch') return comfyBatchCommand(config, args.slice(1), chalk);
244
+
114
245
  const { workflow, flags } = parseComfyuiArgs(args);
115
246
 
116
247
  if (!workflow) {
117
248
  console.error(chalk.red('\n Usage: badgr comfyui run workflow.json\n'));
249
+ console.error(chalk.dim(' Batch mode: badgr comfyui batch --workflow sdxl-basic --prompts prompts.txt --max-cost 10\n'));
118
250
  console.error(chalk.dim(' Launches ComfyUI, queues your workflow, and returns the URL.\n'));
119
251
  process.exitCode = 1;
120
252
  return;
@@ -1,3 +1,7 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { createWriteStream } from 'fs';
1
5
  import { requireApiKey } from '../config.js';
2
6
  import { callApi, terminateDeployment } from '../api.js';
3
7
  import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
@@ -6,10 +10,19 @@ import { formatCliError } from '../errors.js';
6
10
  import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
7
11
 
8
12
  /**
9
- * badgr run python train.py # gpu=auto, attached
10
- * badgr run python train.py --gpu A100 # specific GPU
11
- * badgr run --image my/image:latest --gpu L40S --detach
12
- * badgr run python train.py --env HF_TOKEN=abc --env DATASET=my/data
13
+ * Flow 1 local project (primary):
14
+ * badgr run . --cmd "python train.py" --max-cost 5 --save my-training-job
15
+ * badgr run ./my-project --cmd "python train.py" --max-cost 5
16
+ *
17
+ * Flow 2 — public GitHub repo:
18
+ * badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5
19
+ *
20
+ * Flow 3 — custom image (advanced):
21
+ * badgr run . --image mycompany/custom:latest --cmd "python train.py" --max-cost 5
22
+ *
23
+ * Legacy / direct command:
24
+ * badgr run python train.py --gpu A100
25
+ * badgr run --image my/image:latest --gpu L40S --detach
13
26
  */
14
27
  export function parseRunArgs(args) {
15
28
  const flags = {};
@@ -41,6 +54,7 @@ export function parseRunArgs(args) {
41
54
  if (flagArgs[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
42
55
  if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
43
56
  if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
57
+ if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
44
58
  if (flagArgs[i] === '--env') {
45
59
  const kv = flagArgs[++i]; i++;
46
60
  if (!flags.env) flags.env = [];
@@ -342,9 +356,117 @@ const _KNOWN_RUN_FLAGS = new Set([
342
356
  '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
343
357
  '--detach', '--fallback', '--no-fallback', '--strict-capacity',
344
358
  '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
345
- '--dry-run',
359
+ '--dry-run', '--cmd', '--save', '--workspace',
346
360
  ]);
347
361
 
362
+ // Directories and files always excluded from project zip uploads.
363
+ const _ZIP_EXCLUDES = new Set([
364
+ '.git', 'node_modules', '__pycache__', '.venv', 'venv', '.env',
365
+ 'dist', 'build', '.next', '.nuxt', 'coverage', '.pytest_cache',
366
+ '.mypy_cache', '.ruff_cache', '.DS_Store',
367
+ ]);
368
+
369
+ function _shouldExclude(name) {
370
+ return _ZIP_EXCLUDES.has(name) || name.startsWith('.') && name !== '.gitignore';
371
+ }
372
+
373
+ function _isLocalPath(arg) {
374
+ // '.' or './' or relative/absolute paths that exist on disk
375
+ if (arg === '.') return true;
376
+ if (arg.startsWith('./') || arg.startsWith('../') || arg.startsWith('/')) return true;
377
+ return false;
378
+ }
379
+
380
+ function _isGitHubUrl(arg) {
381
+ return /^https?:\/\/(www\.)?github\.com\//.test(arg);
382
+ }
383
+
384
+ /**
385
+ * Zip a local directory into a temp file, returning the temp file path.
386
+ * Uses Node's built-in APIs — no external zip library required.
387
+ */
388
+ async function _zipDirectory(dirPath, chalk) {
389
+ // Use archiver if available, otherwise fall back to a manual approach via
390
+ // a child process calling `zip` (available on Linux/macOS) or PowerShell on Windows.
391
+ const absDir = path.resolve(dirPath);
392
+ if (!fs.existsSync(absDir)) {
393
+ throw new Error(`Directory not found: ${absDir}`);
394
+ }
395
+
396
+ const tmpFile = path.join(os.tmpdir(), `badgr-upload-${Date.now()}.zip`);
397
+
398
+ // Attempt to use archiver (optional peer dep) first; fall back to system zip.
399
+ try {
400
+ const { default: archiver } = await import('archiver');
401
+ await new Promise((resolve, reject) => {
402
+ const output = createWriteStream(tmpFile);
403
+ const archive = archiver('zip', { zlib: { level: 6 } });
404
+ output.on('close', resolve);
405
+ archive.on('error', reject);
406
+ archive.pipe(output);
407
+ archive.glob('**/*', {
408
+ cwd: absDir,
409
+ dot: false,
410
+ ignore: [..._ZIP_EXCLUDES].map(e => `**/${e}/**`).concat([..._ZIP_EXCLUDES].map(e => e)),
411
+ });
412
+ archive.finalize();
413
+ });
414
+ return tmpFile;
415
+ } catch {
416
+ // archiver not installed — fall back to system zip command
417
+ }
418
+
419
+ const { spawn } = await import('child_process');
420
+ await new Promise((resolve, reject) => {
421
+ // Build exclude args — zip uses -x patterns
422
+ const excludeArgs = [];
423
+ for (const ex of _ZIP_EXCLUDES) {
424
+ excludeArgs.push('-x', `*/${ex}/*`, '-x', `${ex}/*`, '-x', `${ex}`);
425
+ }
426
+ const child = spawn('zip', ['-r', '-q', tmpFile, '.', ...excludeArgs], { cwd: absDir });
427
+ child.on('close', code => code === 0 ? resolve() : reject(new Error(`zip exited ${code}`)));
428
+ child.on('error', reject);
429
+ });
430
+
431
+ return tmpFile;
432
+ }
433
+
434
+ export async function _uploadCodeZip(config, dirPath, chalk) {
435
+ process.stdout.write(chalk.dim(' Packing project...'));
436
+ const tmpFile = await _zipDirectory(dirPath, chalk);
437
+ const stat = fs.statSync(tmpFile);
438
+ const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
439
+ process.stdout.write(chalk.dim(` ${sizeMb} MB\n`));
440
+
441
+ process.stdout.write(chalk.dim(' Uploading project...'));
442
+ // POST zip directly to the Badgr backend — no S3, no presigned URLs.
443
+ // Built-in FormData/Blob/fetch (Node >=18) keep the CLI dependency-free for
444
+ // multipart uploads; fetch sets the multipart boundary header automatically.
445
+ const fileData = fs.readFileSync(tmpFile);
446
+ const form = new FormData();
447
+ form.append('file', new Blob([fileData], { type: 'application/zip' }), 'project.zip');
448
+
449
+ const baseUrl = config.baseUrl.replace(/\/v1\/?$/, '');
450
+ let uploadResp;
451
+ try {
452
+ uploadResp = await fetch(`${baseUrl}/v1/uploads`, {
453
+ method: 'POST',
454
+ body: form,
455
+ headers: { 'Authorization': `Bearer ${config.apiKey}` },
456
+ });
457
+ } finally {
458
+ fs.unlinkSync(tmpFile);
459
+ }
460
+
461
+ if (!uploadResp.ok) {
462
+ const text = await uploadResp.text().catch(() => '');
463
+ throw new Error(`Upload failed: ${uploadResp.status} ${uploadResp.statusText}${text ? ` — ${text}` : ''}`);
464
+ }
465
+ const { code_uri: codeUri } = await uploadResp.json();
466
+ process.stdout.write(chalk.dim(' done\n'));
467
+ return codeUri;
468
+ }
469
+
348
470
  export async function runCommand(config, args, chalk) {
349
471
  // `badgr run template <name> [flags]` — expand template defaults then re-dispatch
350
472
  if (args[0] === 'template') {
@@ -387,12 +509,37 @@ export async function runCommand(config, args, chalk) {
387
509
  return;
388
510
  }
389
511
 
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...>'));
392
- console.error(chalk.red(' badgr run --image my/image:latest'));
512
+ // ── Detect source type ─────────────────────────────────────────────────────
513
+ // positional[0] may be: '.' | './dir' | 'https://github.com/...' | 'python' | nothing
514
+ const firstArg = positional[0] ?? null;
515
+ const isLocalPath = firstArg && _isLocalPath(firstArg);
516
+ const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
517
+ const isCodeSource = isLocalPath || isGitHubUrl;
518
+
519
+ if (isCodeSource && !flags.cmd) {
520
+ console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
521
+ if (isLocalPath) {
522
+ console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
523
+ } else {
524
+ console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
525
+ }
526
+ process.exitCode = 1;
527
+ return;
528
+ }
529
+
530
+ // Legacy direct-command mode: positional args form the command
531
+ const isDirectCommand = !isCodeSource && (positional.length > 0 || commandArgv !== null || flags.image);
532
+
533
+ if (!isCodeSource && positional.length === 0 && commandArgv === null && !flags.image) {
534
+ console.error(chalk.red('\nUsage:'));
535
+ console.error(chalk.dim(' badgr run . --cmd "python train.py" --max-cost 5'));
536
+ console.error(chalk.dim(' badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5'));
537
+ console.error(chalk.dim(' badgr run python train.py --max-cost 5'));
538
+ console.error(chalk.dim(' badgr run --image my/image:latest --max-cost 5'));
539
+ console.error('');
393
540
  return;
394
541
  }
395
- if (commandArgv !== null && commandArgv.length === 0 && !flags.image) {
542
+ if (commandArgv !== null && commandArgv.length === 0 && !flags.image && !isCodeSource) {
396
543
  console.error(chalk.red(' ✗ No command after --. Provide a command or --image.'));
397
544
  console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --max-cost 1 -- node script.js'));
398
545
  process.exitCode = 1;
@@ -430,13 +577,17 @@ export async function runCommand(config, args, chalk) {
430
577
  }
431
578
 
432
579
  // 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);
436
- const cmdStr = command ? command.join(' ') : '';
437
- const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
438
- const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
439
- const image = flags.image || (command ? inferredImage : undefined);
580
+ // For code-source flows (local path / GitHub), the "command" field is empty — the runner
581
+ // receives it via BADGR_CMD env var instead.
582
+ const command = isCodeSource
583
+ ? undefined
584
+ : (commandArgv !== null
585
+ ? (commandArgv.length > 0 ? commandArgv : undefined)
586
+ : (positional.length > 0 ? positional : undefined));
587
+ const cmdStr = isCodeSource ? (flags.cmd || '') : (command ? command.join(' ') : '');
588
+ const isSmoke = !isCodeSource && cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
589
+ const inferredImage = isSmoke ? 'python:3.11-alpine' : undefined; // code-source flows use runner image
590
+ const image = flags.image || (command && !isCodeSource ? (isSmoke ? 'python:3.11-alpine' : undefined) : undefined);
440
591
  const detach = flags.detach || false;
441
592
 
442
593
  // Default max-runtime of 60 minutes — always applied unless overridden.
@@ -453,6 +604,9 @@ export async function runCommand(config, args, chalk) {
453
604
 
454
605
  if (flags.dryRun) {
455
606
  console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
607
+ if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)} (local project)`);
608
+ if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg} (GitHub)`);
609
+ if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
456
610
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
457
611
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
458
612
  console.log(` ${chalk.bold('GPU:')} ${gpu || chalk.dim('auto')}`);
@@ -465,10 +619,13 @@ export async function runCommand(config, args, chalk) {
465
619
  }
466
620
 
467
621
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
468
- if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
469
- if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
470
- if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
471
- else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
622
+ if (isLocalPath) console.log(` ${chalk.bold('Source:')} ${path.resolve(firstArg)}`);
623
+ if (isGitHubUrl) console.log(` ${chalk.bold('Source:')} ${firstArg}`);
624
+ if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
625
+ if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
626
+ if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
627
+ if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
628
+ else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
472
629
  if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
473
630
  const runtimeLabel = isDefaultRuntime
474
631
  ? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
@@ -498,6 +655,18 @@ export async function runCommand(config, args, chalk) {
498
655
  }
499
656
  }
500
657
 
658
+ // ── Upload local project zip (Flow 1) ─────────────────────────────────────
659
+ let codeUri = null;
660
+ if (isLocalPath) {
661
+ try {
662
+ codeUri = await _uploadCodeZip(config, firstArg, chalk);
663
+ } catch (err) {
664
+ console.error(chalk.red(`\n ✗ Failed to upload project: ${err.message}\n`));
665
+ process.exitCode = 1;
666
+ return;
667
+ }
668
+ }
669
+
501
670
  console.log(chalk.dim(' Finding suitable capacity...'));
502
671
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
503
672
  console.log(chalk.dim(` API: ${config.baseUrl}`));
@@ -506,8 +675,8 @@ export async function runCommand(config, args, chalk) {
506
675
  function buildBody(gpuOverride, tierOverride) {
507
676
  const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
508
677
  return {
509
- command,
510
- image,
678
+ ...(command ? { command } : {}),
679
+ ...(image ? { image } : {}),
511
680
  gpu: gpuOverride || gpu || 'auto',
512
681
  ...(flags.minVram ? { min_vram: flags.minVram } : {}),
513
682
  gpu_count: flags.count || 1,
@@ -519,6 +688,10 @@ export async function runCommand(config, args, chalk) {
519
688
  max_runtime_seconds: effectiveMaxRuntime * 60,
520
689
  ...(maxCost ? { max_cost_usd: maxCost } : {}),
521
690
  ...(resolvedWorkspaceId ? { workspace_id: resolvedWorkspaceId } : {}),
691
+ // Code-source fields
692
+ ...(codeUri ? { code_uri: codeUri } : {}),
693
+ ...(isGitHubUrl ? { github_url: firstArg } : {}),
694
+ ...(flags.cmd ? { cmd: flags.cmd } : {}),
522
695
  };
523
696
  }
524
697
 
@@ -720,8 +893,12 @@ export async function runCommand(config, args, chalk) {
720
893
  name: flags.save,
721
894
  job_type: 'custom.run',
722
895
  config: {
723
- command: command || [],
724
- image: image || 'python:3.11-slim',
896
+ ...(command ? { command } : {}),
897
+ ...(image ? { image } : {}),
898
+ ...(flags.cmd ? { cmd: flags.cmd } : {}),
899
+ // For local-path workloads, code_uri is a snapshot — user can re-upload on next run.
900
+ // GitHub URL is stable and stored directly.
901
+ ...(isGitHubUrl ? { github_url: firstArg } : {}),
725
902
  gpu: gpu || 'auto',
726
903
  ...(flags.minVram ? { min_vram: flags.minVram } : {}),
727
904
  gpu_count: flags.count || 1,