badgr-cli 1.1.1 → 1.1.3

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.
Files changed (73) hide show
  1. package/LICENSE +207 -0
  2. package/README.md +13 -6
  3. package/package.json +44 -2
  4. package/src/api.js +16 -0
  5. package/src/badgr.js +26 -10
  6. package/src/commands/batch.js +11 -0
  7. package/src/commands/billing.js +3 -3
  8. package/src/commands/comfyui.js +31 -15
  9. package/src/commands/connect.js +4 -1
  10. package/src/commands/diagnose.js +493 -0
  11. package/src/commands/embed.js +13 -10
  12. package/src/commands/job.js +246 -0
  13. package/src/commands/launch.js +152 -16
  14. package/src/commands/login.js +75 -20
  15. package/src/commands/run.js +74 -16
  16. package/src/commands/sbatch.js +6 -1
  17. package/src/commands/serve.js +44 -30
  18. package/src/commands/train.js +8 -12
  19. package/src/commands/transcribe.js +13 -10
  20. package/src/credentials.js +33 -0
  21. package/src/envFlag.js +10 -0
  22. package/src/fallback.js +13 -2
  23. package/src/onboarding.js +8 -1
  24. package/src/progress.js +48 -0
  25. package/src/commands/task.js +0 -25
  26. package/tests/agent-images.test.js +0 -17
  27. package/tests/api.test.js +0 -168
  28. package/tests/artifactDownload.test.js +0 -113
  29. package/tests/artifacts.test.js +0 -168
  30. package/tests/batch.test.js +0 -641
  31. package/tests/browser.test.js +0 -51
  32. package/tests/capacity.test.js +0 -68
  33. package/tests/commands.test.js +0 -417
  34. package/tests/config.test.js +0 -96
  35. package/tests/connect.test.js +0 -83
  36. package/tests/detect.test.js +0 -191
  37. package/tests/down.test.js +0 -150
  38. package/tests/errors.test.js +0 -130
  39. package/tests/fallback-timeout.test.js +0 -41
  40. package/tests/fanout.test.js +0 -124
  41. package/tests/gpu-doctor-classifiers.test.js +0 -402
  42. package/tests/gpu-doctor-doctor.test.js +0 -304
  43. package/tests/gpu-doctor-probe-cache.test.js +0 -110
  44. package/tests/gpu-doctor-probes.test.js +0 -257
  45. package/tests/heartbeat.test.js +0 -70
  46. package/tests/job-progress-poll.test.js +0 -136
  47. package/tests/launch-command-argv.test.js +0 -93
  48. package/tests/launch-readiness.test.js +0 -403
  49. package/tests/launch.test.js +0 -440
  50. package/tests/onboarding.test.js +0 -134
  51. package/tests/productized-dry-run.test.js +0 -141
  52. package/tests/productized-runners.test.js +0 -237
  53. package/tests/pull.test.js +0 -266
  54. package/tests/rerun.test.js +0 -94
  55. package/tests/restart.test.js +0 -88
  56. package/tests/router.test.js +0 -98
  57. package/tests/run-lifecycle.test.js +0 -1054
  58. package/tests/sbatch.test.js +0 -190
  59. package/tests/secrets.test.js +0 -16
  60. package/tests/serve-apps.test.js +0 -189
  61. package/tests/serve-lifecycle.test.js +0 -931
  62. package/tests/slurm.test.js +0 -77
  63. package/tests/spec.test.js +0 -201
  64. package/tests/status.test.js +0 -73
  65. package/tests/store.test.js +0 -187
  66. package/tests/task.test.js +0 -109
  67. package/tests/template.test.js +0 -556
  68. package/tests/train-lora-dataset.test.js +0 -176
  69. package/tests/upload.test.js +0 -79
  70. package/tests/workload-rerun.test.js +0 -56
  71. package/tests/workload-spec.test.js +0 -180
  72. package/tests/workload-templates.test.js +0 -865
  73. package/tests/workload-workspace-paths.test.js +0 -46
@@ -0,0 +1,246 @@
1
+ import { callApi } from '../api.js';
2
+ import { requireApiKey } from '../config.js';
3
+
4
+ /**
5
+ * badgr job <agent> "<instruction>" --check "npm test"
6
+ *
7
+ * Submits a bounded coding-agent job via POST /v1/jobs (type: "agent"),
8
+ * which creates a Job record and runs it on a GPU/CPU VM.
9
+ * All three interfaces (website, CLI, POST /v1/jobs) share the same
10
+ * execution path on the backend (backend/jobs_routes.py).
11
+ *
12
+ * Usage:
13
+ * badgr job cline "Fix the checkout bug" --check "npm test"
14
+ * badgr job claude "Add pagination" --check "npm run test:e2e" --max-cost 3
15
+ * badgr job codex "Refactor auth" --check "pytest tests/" --repo https://github.com/org/repo
16
+ */
17
+
18
+ const _BOOL_FLAGS = { '--dry-run': 'dryRun', '--detach': 'detach' };
19
+ const _VALUE_FLAGS = {
20
+ '--check': 'check', '--eval': 'check', '--eval-command': 'check',
21
+ '--repo': 'repository', '--repository': 'repository',
22
+ '--ref': 'ref',
23
+ '--agent': 'agent',
24
+ '--provider': 'provider',
25
+ '--model': 'model',
26
+ '--max-cost': 'maxCost',
27
+ '--max-runtime': 'maxRuntime',
28
+ };
29
+
30
+ // Scan the arg list, pull out known flags wherever they appear, and collect
31
+ // the remaining tokens as positionals. This lets flags appear anywhere:
32
+ // badgr job cline "Fix it" --check "npm test"
33
+ // badgr job "Fix it" --check "npm test" --agent cline
34
+ function parseJobArgs(rawArgs) {
35
+ const flags = {};
36
+ const positional = [];
37
+ let i = 0;
38
+ while (i < rawArgs.length) {
39
+ const tok = rawArgs[i];
40
+ if (tok in _BOOL_FLAGS) { flags[_BOOL_FLAGS[tok]] = true; i += 1; continue; }
41
+ if (tok in _VALUE_FLAGS) { flags[_VALUE_FLAGS[tok]] = rawArgs[i + 1]; i += 2; continue; }
42
+ positional.push(tok);
43
+ i += 1;
44
+ }
45
+ return { flags, positional };
46
+ }
47
+
48
+ // Providers the agent Job type accepts (backend jobs_routes._VALID_AGENT_PROVIDERS).
49
+ // The BYOK/OpenAI-compatible providers `badgr launch --provider` supports
50
+ // (openrouter, deepseek, glm, custom) are NOT among them: the Jobs API
51
+ // resolves credentials server-side from stored provider credentials and has
52
+ // nowhere to put a custom base URL, so those must be rejected in the CLI
53
+ // rather than sent on to a 400 from the server.
54
+ export const JOB_API_PROVIDERS = ['badgr', 'openai', 'anthropic'];
55
+
56
+ export const JOB_AGENTS = ['cline', 'claude', 'claude-code', 'codex', 'playwright'];
57
+
58
+ export async function jobCommand(config, args, chalk) {
59
+ // Support both:
60
+ // badgr job <agent> "<instruction>" --check "..."
61
+ // badgr job "<instruction>" --check "..." --agent cline (fallback)
62
+ const { flags, positional } = parseJobArgs(args);
63
+
64
+ let agentName = null;
65
+ let instructionText = null;
66
+
67
+ const knownAgents = new Set(JOB_AGENTS);
68
+
69
+ if (positional.length >= 2 && knownAgents.has(positional[0])) {
70
+ agentName = positional[0];
71
+ instructionText = positional.slice(1).join(' ').trim();
72
+ } else if (positional.length >= 1) {
73
+ instructionText = positional.join(' ').trim();
74
+ agentName = flags.agent ?? 'cline';
75
+ }
76
+
77
+ if (!instructionText || !agentName) {
78
+ console.error(chalk.red('\nUsage: badgr job <agent> "<instruction>" --check "<command>"\n'));
79
+ console.error(chalk.dim(' Agents: cline (default), claude-code, codex, playwright'));
80
+ console.error(chalk.dim(' Example: badgr job cline "Fix the checkout bug" --check "npm test"'));
81
+ console.error(chalk.dim(' Example: badgr job claude-code "Add pagination" --check "pytest tests/" --max-cost 3\n'));
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ if (!flags.check) {
87
+ console.error(chalk.red('\n ✗ --check <command> is required — it verifies the job succeeded.\n'));
88
+ console.error(chalk.dim(' Example: badgr job cline "Fix the bug" --check "npm test"\n'));
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ return runJob(config, {
94
+ agent: agentName,
95
+ instruction: instructionText,
96
+ check: flags.check,
97
+ provider: flags.provider,
98
+ model: flags.model,
99
+ repository: flags.repository,
100
+ ref: flags.ref,
101
+ maxCost: flags.maxCost,
102
+ maxRuntime: flags.maxRuntime,
103
+ dryRun: flags.dryRun,
104
+ detach: flags.detach,
105
+ }, chalk);
106
+ }
107
+
108
+ /**
109
+ * Submit one agent job to POST /v1/jobs and (unless detached) poll it to a
110
+ * terminal state. Takes an already-resolved options object rather than argv
111
+ * so callers that have parsed their own flags — `badgr launch <agent>
112
+ * --eval-command ...` — can reuse this path directly instead of
113
+ * re-serializing their flags back into an argv for jobCommand to reparse.
114
+ */
115
+ export async function runJob(config, opts, chalk) {
116
+ const { agent: agentName, instruction: instructionText, check } = opts;
117
+
118
+ // Require API key — jobs are tracked under the user account.
119
+ const apiKey = requireApiKey(config, chalk);
120
+ if (!apiKey) return;
121
+
122
+ const provider = opts.provider ?? null;
123
+ const model = opts.model ?? null;
124
+ const repository = opts.repository ?? '.';
125
+ const ref = opts.ref ?? null;
126
+ const maxCostUsd = opts.maxCost != null ? Number(opts.maxCost) : 2.0;
127
+ const maxRuntimeSeconds = opts.maxRuntime != null ? Number(opts.maxRuntime) : 1800;
128
+
129
+ if (provider && !JOB_API_PROVIDERS.includes(provider)) {
130
+ console.error(chalk.red(`\n ✗ The Jobs API does not accept --provider ${provider}.`));
131
+ console.error(chalk.dim(` Supported: ${JOB_API_PROVIDERS.join(', ')}`));
132
+ console.error(chalk.dim(' BYOK providers (openrouter, deepseek, glm, custom) work with'));
133
+ console.error(chalk.dim(' badgr launch without --eval-command.\n'));
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+
138
+ // Print plan.
139
+ console.log('');
140
+ console.log(chalk.bold(` Job: ${instructionText}`));
141
+ console.log(chalk.dim(` Agent: ${agentName}${provider ? ` via ${provider}` : ''}${model ? ` / ${model}` : ''}`));
142
+ console.log(chalk.dim(` Check: ${check}`));
143
+ console.log(chalk.dim(` Max: $${maxCostUsd.toFixed(2)} / ${Math.round(maxRuntimeSeconds / 60)} min`));
144
+ console.log('');
145
+
146
+ if (opts.dryRun) {
147
+ console.log(chalk.yellow(' (dry run — no job submitted)\n'));
148
+ return;
149
+ }
150
+
151
+ // Submit job.
152
+ let job;
153
+ try {
154
+ job = await callApi('/v1/jobs', {
155
+ method: 'POST',
156
+ apiKey,
157
+ baseUrl: config.baseUrl,
158
+ body: {
159
+ type: 'agent',
160
+ input: {
161
+ repository,
162
+ ref,
163
+ agent: agentName,
164
+ provider,
165
+ model,
166
+ instruction: instructionText,
167
+ check,
168
+ },
169
+ policy: {
170
+ max_cost: maxCostUsd,
171
+ max_runtime_minutes: Math.round(maxRuntimeSeconds / 60),
172
+ },
173
+ },
174
+ });
175
+ } catch (err) {
176
+ console.error(chalk.red(`\n ✗ Could not submit job: ${err.message}\n`));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+
181
+ if (job?.detail || job?.error) {
182
+ console.error(chalk.red(`\n ✗ ${job.detail ?? job.error}\n`));
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+
187
+ const jobId = job?.job_id;
188
+ if (!jobId) {
189
+ console.error(chalk.red('\n ✗ Unexpected response from server.\n'));
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+
194
+ console.log(chalk.green(` ✓ Job submitted`));
195
+ console.log(chalk.dim(` ID: ${jobId}`));
196
+
197
+ if (opts.detach) {
198
+ console.log(chalk.dim(` Status: badgr status (or GET /v1/jobs/${jobId})\n`));
199
+ return;
200
+ }
201
+
202
+ // Poll until terminal.
203
+ console.log(chalk.dim(' Waiting for job to complete…\n'));
204
+ const pollMs = 10_000;
205
+ const maxWaitMs = (maxRuntimeSeconds + 60) * 1000;
206
+ const startMs = Date.now();
207
+
208
+ while (true) {
209
+ await new Promise(r => setTimeout(r, pollMs));
210
+ if (Date.now() - startMs > maxWaitMs) {
211
+ console.error(chalk.yellow(`\n ⚠ Timed out waiting. Job ${jobId} is still running.\n`));
212
+ console.log(chalk.dim(` Check: badgr status (or GET /v1/jobs/${jobId})\n`));
213
+ break;
214
+ }
215
+ let latest;
216
+ try {
217
+ latest = await callApi(`/v1/jobs/${jobId}`, { apiKey, baseUrl: config.baseUrl });
218
+ } catch {
219
+ continue;
220
+ }
221
+ if (!latest?.job_id) continue;
222
+ if (latest.status === 'running' && latest.stage) {
223
+ process.stdout.write(chalk.dim(`\r ${latest.stage}…`));
224
+ }
225
+ if (latest.status === 'completed' || latest.status === 'failed' || latest.status === 'canceled') {
226
+ console.log('');
227
+ if (latest.status === 'completed') {
228
+ console.log(chalk.green(`\n ✓ Job succeeded`));
229
+ if (latest.output?.exit_code != null) {
230
+ console.log(chalk.green(` Check: ${latest.output.exit_code === 0 ? 'passed' : 'failed'}`));
231
+ }
232
+ } else if (latest.status === 'failed') {
233
+ console.error(chalk.red(`\n ✗ Job failed`));
234
+ if (latest.error?.message) console.error(chalk.dim(` ${latest.error.message}`));
235
+ process.exitCode = 1;
236
+ } else {
237
+ console.log(chalk.yellow(`\n Job ${latest.status}`));
238
+ }
239
+ if (latest.charged_usd != null) {
240
+ console.log(chalk.dim(` Cost: $${latest.charged_usd.toFixed(4)}`));
241
+ }
242
+ console.log('');
243
+ break;
244
+ }
245
+ }
246
+ }
@@ -1,5 +1,6 @@
1
1
  import { runCommand, parseRunArgs } from './run.js';
2
- import { getCredential, setCredential, PROVIDER_ENV_KEYS } from '../credentials.js';
2
+ import { runJob } from './job.js';
3
+ import { getCredential, setCredential, PROVIDER_ENV_KEYS, MODEL_PROVIDERS, KNOWN_MODEL_PROVIDERS, ENV_KEYS_FOR_API_KIND } from '../credentials.js';
3
4
  import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
4
5
 
5
6
  // Phase 1 supports these coding/testing workloads via shorthand. Do not add
@@ -43,8 +44,8 @@ import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
43
44
  // images/badgr-job-runner/entrypoint.py, which subprocess.run executes
44
45
  // directly with shell=False — no shlex.split() involved for this path).
45
46
  const AGENT_WORKLOADS = {
46
- cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task], provider: null },
47
- claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['claude', '-p', task], provider: 'anthropic' },
47
+ cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task], provider: null },
48
+ claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['badgr-claude-run', task], provider: 'anthropic' },
48
49
  codex: { image: process.env.BADGR_AGENT_IMAGE_CODEX || 'ghcr.io/michaelmanly/badgr-agent-codex:latest', buildCmd: task => ['badgr-codex-run', task], provider: 'openai' },
49
50
  };
50
51
 
@@ -100,9 +101,25 @@ const _VALUE_FLAGS = {
100
101
  '--max-runtime': 'maxRuntime', '--max-cost': 'maxCost', '--save': 'save',
101
102
  '--workspace': 'workspace', '--output': 'output', '--checkpoint': 'checkpoint',
102
103
  '--resume-cmd': 'resumeCmd', '--size': 'size',
104
+ // BYOK / OpenAI-compatible model selection — `badgr launch cline` only,
105
+ // see resolveModelProviderEnv below.
106
+ '--provider': 'provider', '--model': 'model', '--base-url': 'baseUrl',
107
+ // When present, routes to POST /v1/jobs (type: agent) instead of the
108
+ // plain badgr-launch GPU/CPU run path.
109
+ '--eval-command': 'evalCommand', '--eval': 'evalCommand',
103
110
  };
104
111
  const _REPEATABLE_FLAGS = { '--env': 'env', '--artifacts': 'artifacts' };
105
112
 
113
+ // `badgr launch --eval-command` hands the run to the agent Job type
114
+ // (POST /v1/jobs, type: agent), which provisions and configures the VM
115
+ // server-side. These VM-shaping flags have no equivalent there, so they are
116
+ // called out instead of being silently dropped.
117
+ const JOB_API_IGNORED_FLAGS = [
118
+ ['--base-url', 'baseUrl'], ['--env', 'env'], ['--artifacts', 'artifacts'],
119
+ ['--workspace', 'workspace'], ['--output', 'output'], ['--size', 'size'],
120
+ ['--image', 'image'], ['--region', 'region'], ['--tier', 'tier'],
121
+ ];
122
+
106
123
  const _PASSTHROUGH_FLAGS = [
107
124
  ['--image', 'image'], ['--count', 'count'], ['--region', 'region'],
108
125
  ['--tier', 'tier'], ['--max-price', 'maxPrice'], ['--name', 'name'],
@@ -229,28 +246,32 @@ function resolveVmSize(flags, workloadName, chalk) {
229
246
  return true;
230
247
  }
231
248
 
232
- async function resolveMissingCredential(provider, chalk) {
233
- const providerLabel = provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
249
+ async function resolveMissingCredential(provider, chalk, label) {
250
+ const displayLabel = label ?? (provider === 'anthropic' ? 'Anthropic' : 'OpenAI');
234
251
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
235
- console.error(chalk.red(`\n ✗ No ${provider} credential found.\n`));
252
+ console.error(chalk.red(`\n ✗ No ${displayLabel} credential found.\n`));
236
253
  console.error(chalk.dim(` Run: badgr connect ${provider}\n`));
237
254
  return null;
238
255
  }
239
256
 
240
- console.log(chalk.yellow(`\n ${providerLabel} is not connected.`));
241
- console.log(chalk.dim(` ${providerLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${providerLabel} account for model usage.`));
242
- console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
257
+ console.log(chalk.yellow(`\n ${displayLabel} is not connected.`));
258
+ if (label) {
259
+ console.log(chalk.dim(` Your own ${displayLabel} API key pays for model usage — Badgr credits still pay for the disposable VM.\n`));
260
+ } else {
261
+ console.log(chalk.dim(` ${displayLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${displayLabel} account for model usage.`));
262
+ console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
263
+ }
243
264
 
244
265
  try {
245
266
  const { password } = await import('@inquirer/prompts');
246
267
  const key = await password({
247
- message: `Enter your ${providerLabel} API key:`,
268
+ message: `Enter your ${displayLabel} API key:`,
248
269
  validate: v => v.trim() ? true : 'API key is required',
249
270
  });
250
271
  const trimmed = key.trim();
251
272
  if (!trimmed) return null;
252
273
  setCredential(provider, trimmed);
253
- console.log(chalk.green(` ✓ ${providerLabel} connected\n`));
274
+ console.log(chalk.green(` ✓ ${displayLabel} connected\n`));
254
275
  return trimmed;
255
276
  } catch {
256
277
  // Ctrl+C or a non-interactive stdin that lied about isTTY.
@@ -258,6 +279,53 @@ async function resolveMissingCredential(provider, chalk) {
258
279
  }
259
280
  }
260
281
 
282
+ /**
283
+ * BYOK / OpenAI-compatible or Anthropic-compatible model selection.
284
+ *
285
+ * `badgr launch <cline|codex> --provider <name> --model <id> [--base-url <url>]`
286
+ * uses apiKind='openai' (injects OPENAI_API_KEY/OPENAI_BASE_URL/MODEL).
287
+ * `badgr launch claude --provider <name> --model <id> [--base-url <url>]`
288
+ * uses apiKind='anthropic' (injects ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL/MODEL).
289
+ *
290
+ * Validates before provisioning — unknown provider, missing --model, or
291
+ * missing --base-url for `custom` all hard-fail here, no VM created.
292
+ * Returns the extra env pairs to inject, or null on validation failure.
293
+ */
294
+ async function resolveModelProviderEnv(flags, chalk, apiKind = 'openai') {
295
+ const provider = flags.provider;
296
+ if (!(provider in MODEL_PROVIDERS)) {
297
+ console.error(chalk.red(`\n ✗ Unknown model provider: ${provider}`));
298
+ console.error(chalk.dim(` Supported: ${KNOWN_MODEL_PROVIDERS.join(', ')}\n`));
299
+ return null;
300
+ }
301
+ if (!flags.model) {
302
+ console.error(chalk.red(`\n ✗ --provider ${provider} requires --model <model-id> — no default model is assumed.\n`));
303
+ return null;
304
+ }
305
+ const baseUrl = flags.baseUrl || MODEL_PROVIDERS[provider].defaultBaseUrl;
306
+ if (!baseUrl) {
307
+ console.error(chalk.red(`\n ✗ --provider custom requires --base-url <url>.\n`));
308
+ return null;
309
+ }
310
+
311
+ const envKeys = ENV_KEYS_FOR_API_KIND[apiKind];
312
+ const userEnvKeys = new Set((flags.env || []).map(kv => kv.split('=')[0]));
313
+ const env = [];
314
+
315
+ if (!userEnvKeys.has(envKeys.apiKey)) {
316
+ let credential = getCredential(provider);
317
+ if (!credential) {
318
+ credential = await resolveMissingCredential(provider, chalk, MODEL_PROVIDERS[provider].label);
319
+ if (!credential) return null;
320
+ }
321
+ env.push(`${envKeys.apiKey}=${credential}`);
322
+ }
323
+ if (!userEnvKeys.has(envKeys.model)) env.push(`${envKeys.model}=${flags.model}`);
324
+ if (!userEnvKeys.has(envKeys.baseUrl)) env.push(`${envKeys.baseUrl}=${baseUrl}`);
325
+
326
+ return env;
327
+ }
328
+
261
329
  async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
262
330
  const { flags, task, gpuRejected } = parseWorkloadShorthandArgs(rawArgs);
263
331
 
@@ -275,6 +343,32 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
275
343
 
276
344
  warnIfTaskMayHaveSwallowedAFlag(task, chalk);
277
345
 
346
+ // When --eval-command is given, route to the agent Job type
347
+ // (POST /v1/jobs, type: agent) which tracks check results and stores a
348
+ // receipt — same UX path, different backend record type. Runs after the
349
+ // --gpu / empty-task guards above so those still apply, and calls runJob
350
+ // directly with the already-parsed flags rather than re-serializing them
351
+ // into an argv (which silently dropped everything the re-serializer
352
+ // forgot to list).
353
+ if (flags.evalCommand) {
354
+ const ignored = JOB_API_IGNORED_FLAGS.filter(([, key]) => flags[key] !== undefined);
355
+ if (ignored.length) {
356
+ console.error(chalk.yellow(`\n ⚠ ${ignored.map(([f]) => f).join(', ')} ${ignored.length === 1 ? 'is' : 'are'} not supported with --eval-command and will be ignored.`));
357
+ console.error(chalk.dim(' The Jobs API provisions and configures the VM itself.\n'));
358
+ }
359
+ return runJob(config, {
360
+ agent: agentName,
361
+ instruction: task,
362
+ check: flags.evalCommand,
363
+ provider: flags.provider,
364
+ model: flags.model,
365
+ maxCost: flags.maxCost,
366
+ maxRuntime: flags.maxRuntime,
367
+ dryRun: flags.dryRun,
368
+ detach: flags.detach,
369
+ }, chalk);
370
+ }
371
+
278
372
  if (!resolveVmSize(flags, agentName, chalk)) {
279
373
  process.exitCode = 1;
280
374
  return;
@@ -282,19 +376,40 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
282
376
 
283
377
  const spec = AGENT_WORKLOADS[agentName];
284
378
 
285
- if (spec.provider) {
379
+ if (spec.provider && flags.provider) {
380
+ // BYOK override for a fixed-provider workload (claude → anthropic,
381
+ // codex → openai). Skip the managed credential flow and inject the
382
+ // BYOK model provider env vars instead. The apiKind determines which
383
+ // env var names the agent image reads (ANTHROPIC_* vs OPENAI_*).
384
+ const apiKind = spec.provider === 'anthropic' ? 'anthropic' : 'openai';
385
+ const modelEnv = await resolveModelProviderEnv(flags, chalk, apiKind);
386
+ if (!modelEnv) {
387
+ process.exitCode = 1;
388
+ return;
389
+ }
390
+ if (!flags.env) flags.env = [];
391
+ flags.env.push(...modelEnv);
392
+ flags.authRequired = { provider: flags.provider, status: 'connected' };
393
+ } else if (spec.provider) {
394
+ // Standard managed-credential flow: anthropic → ANTHROPIC_API_KEY,
395
+ // openai → OPENAI_API_KEY. Prompts inline on first use in a TTY.
286
396
  const envKey = PROVIDER_ENV_KEYS[spec.provider];
287
397
  const userSuppliedKey = flags.env?.some(kv => kv.startsWith(`${envKey}=`));
288
398
  let credential = userSuppliedKey ? null : getCredential(spec.provider);
399
+ flags.authRequired = { provider: spec.provider, status: credential || userSuppliedKey ? 'connected' : 'missing' };
289
400
  if (!credential && !userSuppliedKey) {
290
401
  // Missing credential no longer forces a separate `badgr connect` +
291
402
  // rerun in an interactive terminal — prompt inline and continue the
292
- // same launch. Non-interactive contexts still hard-fail as before.
403
+ // same launch (including under --dry-run, so the dry-run preview
404
+ // reflects the connected state the real launch will actually use, and
405
+ // a follow-up real launch never needs a second prompt). Non-interactive
406
+ // contexts still hard-fail as before.
293
407
  credential = await resolveMissingCredential(spec.provider, chalk);
294
408
  if (!credential) {
295
409
  process.exitCode = 1;
296
410
  return;
297
411
  }
412
+ flags.authRequired.status = 'connected';
298
413
  }
299
414
  // An explicit --env always wins over the stored credential — never
300
415
  // append the injected default alongside a user-supplied value for the
@@ -304,10 +419,24 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
304
419
  if (!flags.env) flags.env = [];
305
420
  flags.env.push(`${envKey}=${credential}`);
306
421
  }
422
+ } else if (flags.provider) {
423
+ // BYOK for cline (spec.provider is null) — injects OPENAI_API_KEY,
424
+ // OPENAI_BASE_URL, MODEL. The backend skips its managed model token
425
+ // when it sees an existing OPENAI_API_KEY (see jobs_routes.py).
426
+ const modelEnv = await resolveModelProviderEnv(flags, chalk, 'openai');
427
+ if (!modelEnv) {
428
+ process.exitCode = 1;
429
+ return;
430
+ }
431
+ if (!flags.env) flags.env = [];
432
+ flags.env.push(...modelEnv);
433
+ flags.authRequired = { provider: flags.provider, status: 'connected' };
434
+ } else {
435
+ flags.authRequired = { provider: null, status: 'none' };
307
436
  }
308
- // cline has no `provider` Badgr mints and injects its own short-lived
309
- // job-scoped model token server-side (see backend jobs_routes.py); the
310
- // CLI never handles or displays that token.
437
+ // cline with no --provider: Badgr mints a short-lived job-scoped model
438
+ // token server-side (see backend jobs_routes.py); the CLI never handles
439
+ // or displays that token. --provider switches cline to BYOK instead.
311
440
 
312
441
  const cmdArgv = spec.buildCmd(task);
313
442
  flags.cmd = displayCmd(cmdArgv);
@@ -329,6 +458,13 @@ async function launchDirectWorkload(config, workloadName, rawArgs, chalk) {
329
458
  // there's no natural-language interface to hand it to, unlike the agent
330
459
  // workloads above. Read but intentionally not passed to buildCmd().
331
460
 
461
+ if (flags.provider) {
462
+ console.error(chalk.red(`\n ✗ --provider is not supported for \`badgr launch ${workloadName}\` — it is a test runner with no LLM component.\n`));
463
+ console.error(chalk.dim(` Use \`badgr launch claude/cline/codex\` for AI agent workloads.\n`));
464
+ process.exitCode = 1;
465
+ return;
466
+ }
467
+
332
468
  if (gpuRejected) {
333
469
  console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
334
470
  process.exitCode = 1;
@@ -1,36 +1,91 @@
1
- import { input } from '@inquirer/prompts';
2
- import { DEFAULTS } from '../config.js';
1
+ import { DEFAULTS, loadConfig } from '../config.js';
3
2
  import { callApi } from '../api.js';
3
+ import { ensureLoggedIn } from '../onboarding.js';
4
4
 
5
- export async function loginCommand(chalk, saveConfigFn) {
5
+ const API_KEYS_URL = 'https://aibadgr.com/dashboard/api-keys';
6
+
7
+ function parseArgs(args) {
8
+ const flags = {};
9
+ for (let i = 0; i < args.length; i++) {
10
+ if (args[i] === '--key') flags.key = args[++i];
11
+ }
12
+ return flags;
13
+ }
14
+
15
+ /**
16
+ * badgr login — interactive TTY: if a saved key is already
17
+ * valid, reports that and stops (no browser
18
+ * round-trip). Otherwise opens a browser login
19
+ * link and polls until it completes (same flow
20
+ * `badgr run`/`launch`/`serve`/`comfyui` trigger
21
+ * just-in-time — see onboarding.js's
22
+ * ensureLoggedIn).
23
+ * badgr login --key <key> — non-interactive: paste an existing key directly
24
+ * (CI, scripts, or anyone who already has one).
25
+ *
26
+ * The pasted-key path validates against a live API call before saving —
27
+ * a confirmed-invalid key (401/403) is never written to
28
+ * ~/.badgr/config.json. A network failure during validation still saves
29
+ * the key (with a warning), since that failure says nothing about whether
30
+ * the key itself is valid.
31
+ */
32
+ export async function loginCommand(chalk, saveConfigFn, args = []) {
6
33
  console.log(chalk.bold('\nBadgr Login\n'));
7
34
 
8
- const apiKey = await input({
9
- message: 'Enter your Badgr API key:',
10
- validate: v => v.trim() ? true : 'API key is required',
11
- });
35
+ const flags = parseArgs(args);
12
36
 
13
- const config = saveConfigFn({
14
- apiKey: apiKey.trim(),
15
- baseUrl: DEFAULTS.baseUrl,
16
- });
37
+ if (!flags.key) {
38
+ if (process.stdin.isTTY && process.stdout.isTTY) {
39
+ const existing = loadConfig();
40
+ if (existing.apiKey) {
41
+ try {
42
+ await callApi('/models', { apiKey: existing.apiKey, baseUrl: existing.baseUrl });
43
+ console.log(chalk.green('✓ Already logged in'));
44
+ console.log(chalk.dim(` Run ${chalk.cyan('badgr login --key <value>')} to switch accounts.\n`));
45
+ return existing;
46
+ } catch (err) {
47
+ if (err.httpStatus !== 401 && err.httpStatus !== 403) {
48
+ console.log(chalk.yellow(' ⚠ Could not reach the API to confirm the saved key — logging in again.'));
49
+ }
50
+ // Confirmed-invalid or unverifiable — fall through to re-auth below.
51
+ }
52
+ }
17
53
 
18
- console.log(chalk.green('\n✓ Logged in'));
19
- console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
54
+ const config = await ensureLoggedIn({ ...DEFAULTS }, chalk);
55
+ console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
56
+ return config;
57
+ }
58
+
59
+ console.error(chalk.red('\n ✗ --key <value> is required in non-interactive mode.\n'));
60
+ console.error(chalk.dim(` Get an API key: ${API_KEYS_URL}`));
61
+ console.error(chalk.dim(' Example: badgr login --key bdgr_...\n'));
62
+ process.exitCode = 1;
63
+ return null;
64
+ }
65
+
66
+ const apiKey = flags.key.trim();
67
+ if (!apiKey) {
68
+ console.error(chalk.red('\n ✗ API key is required.\n'));
69
+ process.exitCode = 1;
70
+ return null;
71
+ }
20
72
 
21
- // Verify the key works against the live API
22
73
  try {
23
- await callApi('/models', { apiKey: config.apiKey, baseUrl: config.baseUrl });
24
- console.log(chalk.green('✓ API reachable'));
25
- console.log(chalk.green('✓ API key valid\n'));
74
+ await callApi('/models', { apiKey, baseUrl: DEFAULTS.baseUrl });
26
75
  } catch (err) {
27
76
  if (err.httpStatus === 401 || err.httpStatus === 403) {
28
- console.log(chalk.yellow(' API key may be invalid double-check and run badgr login again\n'));
29
- } else {
30
- console.log(chalk.yellow('⚠ Could not reach the API right now — check your internet connection\n'));
77
+ console.error(chalk.red('\n ✗ That API key was rejectednot saved.'));
78
+ console.error(chalk.dim(` Get a valid key: ${API_KEYS_URL}\n`));
79
+ process.exitCode = 1;
80
+ return null;
31
81
  }
82
+ console.log(chalk.yellow('\n ⚠ Could not reach the API to verify the key right now — saving anyway.'));
32
83
  }
33
84
 
85
+ const config = saveConfigFn({ apiKey, baseUrl: DEFAULTS.baseUrl });
86
+
87
+ console.log(chalk.green('\n✓ Logged in'));
88
+ console.log(chalk.dim(` Config saved to ~/.badgr/config.json`));
34
89
  console.log(chalk.dim(` Run ${chalk.cyan('badgr run python train.py')} to launch your first job.\n`));
35
90
  return config;
36
91
  }