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.
- package/LICENSE +207 -0
- package/README.md +13 -6
- package/package.json +44 -2
- package/src/api.js +16 -0
- package/src/badgr.js +26 -10
- package/src/commands/batch.js +11 -0
- package/src/commands/billing.js +3 -3
- package/src/commands/comfyui.js +31 -15
- package/src/commands/connect.js +4 -1
- package/src/commands/diagnose.js +493 -0
- package/src/commands/embed.js +13 -10
- package/src/commands/job.js +246 -0
- package/src/commands/launch.js +152 -16
- package/src/commands/login.js +75 -20
- package/src/commands/run.js +74 -16
- package/src/commands/sbatch.js +6 -1
- package/src/commands/serve.js +44 -30
- package/src/commands/train.js +8 -12
- package/src/commands/transcribe.js +13 -10
- package/src/credentials.js +33 -0
- package/src/envFlag.js +10 -0
- package/src/fallback.js +13 -2
- package/src/onboarding.js +8 -1
- package/src/progress.js +48 -0
- package/src/commands/task.js +0 -25
- package/tests/agent-images.test.js +0 -17
- package/tests/api.test.js +0 -168
- package/tests/artifactDownload.test.js +0 -113
- package/tests/artifacts.test.js +0 -168
- package/tests/batch.test.js +0 -641
- package/tests/browser.test.js +0 -51
- package/tests/capacity.test.js +0 -68
- package/tests/commands.test.js +0 -417
- package/tests/config.test.js +0 -96
- package/tests/connect.test.js +0 -83
- package/tests/detect.test.js +0 -191
- package/tests/down.test.js +0 -150
- package/tests/errors.test.js +0 -130
- package/tests/fallback-timeout.test.js +0 -41
- package/tests/fanout.test.js +0 -124
- package/tests/gpu-doctor-classifiers.test.js +0 -402
- package/tests/gpu-doctor-doctor.test.js +0 -304
- package/tests/gpu-doctor-probe-cache.test.js +0 -110
- package/tests/gpu-doctor-probes.test.js +0 -257
- package/tests/heartbeat.test.js +0 -70
- package/tests/job-progress-poll.test.js +0 -136
- package/tests/launch-command-argv.test.js +0 -93
- package/tests/launch-readiness.test.js +0 -403
- package/tests/launch.test.js +0 -440
- package/tests/onboarding.test.js +0 -134
- package/tests/productized-dry-run.test.js +0 -141
- package/tests/productized-runners.test.js +0 -237
- package/tests/pull.test.js +0 -266
- package/tests/rerun.test.js +0 -94
- package/tests/restart.test.js +0 -88
- package/tests/router.test.js +0 -98
- package/tests/run-lifecycle.test.js +0 -1054
- package/tests/sbatch.test.js +0 -190
- package/tests/secrets.test.js +0 -16
- package/tests/serve-apps.test.js +0 -189
- package/tests/serve-lifecycle.test.js +0 -931
- package/tests/slurm.test.js +0 -77
- package/tests/spec.test.js +0 -201
- package/tests/status.test.js +0 -73
- package/tests/store.test.js +0 -187
- package/tests/task.test.js +0 -109
- package/tests/template.test.js +0 -556
- package/tests/train-lora-dataset.test.js +0 -176
- package/tests/upload.test.js +0 -79
- package/tests/workload-rerun.test.js +0 -56
- package/tests/workload-spec.test.js +0 -180
- package/tests/workload-templates.test.js +0 -865
- 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
|
+
}
|
package/src/commands/launch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runCommand, parseRunArgs } from './run.js';
|
|
2
|
-
import {
|
|
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],
|
|
47
|
-
claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['claude
|
|
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
|
|
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 ${
|
|
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 ${
|
|
241
|
-
|
|
242
|
-
|
|
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 ${
|
|
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(` ✓ ${
|
|
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
|
|
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
|
|
309
|
-
//
|
|
310
|
-
//
|
|
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;
|
package/src/commands/login.js
CHANGED
|
@@ -1,36 +1,91 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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.
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
console.error(chalk.red('\n ✗ That API key was rejected — not 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
|
}
|