feat(cli): replace offload subcommands with browser device-authorization login / logout

The old `offload` command family required users to paste a token manually (`offload login --token `) and exposed bring-your-own-endpoint plumbing (`set-endpoint`, `status`, `disable`) as top-level CLI surface. This replaces it with a standard OAuth device flow (RFC 8628 shape) against the CodeGraph dashboard.

`codegraph login` calls `/api/cli/device/start`, opens the browser to the returned URL, polls `/api/cli/device/token` until the user approves, then stores the minted token and enables managed reasoning. `codegraph logout` clears it. BYO-endpoint configuration moves entirely to env vars (`CODEGRAPH_OFFLOAD_URL` / `CODEGRAPH_OFFLOAD_KEY` / `CODEGRAPH_OFFLOAD_MODEL`), keeping the CLI surface minimal.
This commit is contained in:
Colby McHenry
2026-06-18 00:15:40 -05:00
parent 8aa05380a2
commit 193722de45
2 changed files with 131 additions and 89 deletions
+42 -89
View File
@@ -36,8 +36,9 @@ import { installFatalHandlers } from './fatal-handler';
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags'; import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
import { EXTRACTION_VERSION } from '../extraction/extraction-version'; import { EXTRACTION_VERSION } from '../extraction/extraction-version';
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry'; import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
import { writeOffloadConfig, resolveOffload } from '../reasoning/config'; import { writeOffloadConfig } from '../reasoning/config';
import { writeOffloadToken } from '../reasoning/credentials'; import { writeOffloadToken } from '../reasoning/credentials';
import { startDeviceLogin, pollForToken, openBrowser } from '../reasoning/login';
import { fetchUsage } from '../reasoning/reasoner'; import { fetchUsage } from '../reasoning/reasoner';
// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast. // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
@@ -1352,103 +1353,55 @@ program
}); });
/** /**
* codegraph offload — configure the reasoning offload (bring-your-own endpoint). * codegraph login / logout — managed reasoning (CodeGraph AI).
* *
* When set, codegraph_explore reasons over its assembled source with a remote * `login` runs a browser device-authorization flow against the CodeGraph dashboard,
* model and returns the synthesized answer instead of the raw source dump. * mints the account's metered org token, and stores it (managed offload on). When
* signed in, codegraph_explore reasons over its assembled source via the managed
* gateway instead of returning the raw source dump. `logout` clears it.
*
* Bring-your-own endpoint is configured via the CODEGRAPH_OFFLOAD_URL /
* CODEGRAPH_OFFLOAD_KEY / CODEGRAPH_OFFLOAD_MODEL env vars (see ../reasoning/config).
*/ */
const offloadCmd = program program
.command('offload')
.description('Configure the reasoning offload — let codegraph_explore answer via your own reasoning model');
offloadCmd
.command('set-endpoint <url>')
.description('Send explore output to an OpenAI-compatible reasoning endpoint (URL ends in /v1)')
.option('--model <model>', 'Model id to request', 'gpt-oss-120b')
.option('--key-env <ENV>', 'Name of the env var holding the API key (the key is never written to disk)')
.option('--effort <effort>', 'reasoning_effort: low | medium | high')
.option('--style <style>', 'Output style: plain | report')
.action((url: string, opts: { model?: string; keyEnv?: string; effort?: string; style?: string }) => {
writeOffloadConfig({
url,
model: opts.model,
keyEnv: opts.keyEnv,
effort: opts.effort,
style: opts.style,
});
success(`Reasoning offload enabled → ${url}`);
info(` model: ${opts.model || 'gpt-oss-120b'}`);
if (opts.keyEnv) info(` key: read from $${opts.keyEnv} at call time`);
else warn(' no API key configured — pass --key-env <ENV> (or set CODEGRAPH_OFFLOAD_KEY) if your endpoint needs auth.');
info(' Restart your editor/agent session for running MCP servers to pick it up.');
});
offloadCmd
.command('login') .command('login')
.description('Use the managed CodeGraph AI tier (metered) with your account token') .description('Sign in to CodeGraph AI for managed reasoning — opens your browser to authorize')
.requiredOption('--token <token>', 'Your CodeGraph AI org token') .option('--no-browser', "Don't auto-open the browser; just print the URL to visit")
.option('--url <url>', 'Override the managed gateway URL (advanced/testing)') .action(async (opts: { browser?: boolean }) => {
.option('--model <model>', 'Override the model id') try {
.action((opts: { token: string; url?: string; model?: string }) => { const start = await startDeviceLogin();
// Phase 2: the token is pasted in. A future `codegraph login` device flow will const url = start.verification_uri_complete ?? start.verification_uri;
// mint and store it automatically. info('To authorize, open this URL in your browser:');
writeOffloadConfig({ managed: true, url: opts.url, model: opts.model }); info(` ${url}`);
writeOffloadToken(opts.token); info(`and confirm the code: ${start.user_code}`);
success('Reasoning offload: signed in to CodeGraph AI (managed).'); if (opts.browser !== false) await openBrowser(url);
info(' Credits burn from your account. Check the balance with `codegraph offload status`.'); info('Waiting for authorization… (Ctrl-C to cancel)');
info(' Restart your editor/agent session for running MCP servers to pick it up.'); const token = await pollForToken(start.device_code, start.interval ?? 5, start.expires_in ?? 600);
writeOffloadConfig({ managed: true });
writeOffloadToken(token);
success('Signed in to CodeGraph AI — managed reasoning is on.');
try {
const usage = await fetchUsage();
if (usage && typeof usage.remaining === 'number') {
info(` credits: ${usage.remaining.toLocaleString()} remaining`);
}
} catch {
/* balance is best-effort */
}
info(' Restart your editor/agent session for running MCP servers to pick it up.');
} catch (err) {
error(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}); });
offloadCmd program
.command('logout') .command('logout')
.description('Sign out of CodeGraph AI and clear the stored token') .description('Sign out of CodeGraph AI (clears the saved token and turns off managed reasoning)')
.action(() => { .action(() => {
writeOffloadToken(null); writeOffloadToken(null);
writeOffloadConfig(null); writeOffloadConfig(null);
success('Signed out of CodeGraph AI; offload turned off.'); success('Signed out of CodeGraph AI.');
});
offloadCmd
.command('status')
.description('Show the current reasoning-offload configuration (and managed balance)')
.action(async () => {
const c = resolveOffload();
if (!c.enabled) {
if (c.managed) info('Reasoning offload: managed, but signed out. Run `codegraph offload login --token <token>`.');
else info('Reasoning offload: off. Enable with `codegraph offload set-endpoint <url>` or `codegraph offload login`.');
return;
}
if (c.managed) {
success(`Reasoning offload: on — CodeGraph AI (managed)`);
info(` endpoint: ${c.url}`);
info(` model: ${c.model}`);
info(` token: present (from ${c.keySource})`);
const usage = await fetchUsage();
if (usage && typeof usage.remaining === 'number') {
const reset = usage.periodEnd ? ` · allowance resets ${new Date(usage.periodEnd).toISOString().slice(0, 10)}` : '';
info(` credits: ${usage.remaining.toLocaleString()} remaining (plan ${usage.plan ?? '—'})${reset}`);
} else {
warn(' credits: could not reach CodeGraph AI to read your balance (the offload still degrades gracefully).');
}
return;
}
success(`Reasoning offload: on (${c.origin === 'env' ? 'from environment' : 'configured'})`);
info(` endpoint: ${c.url}`);
info(` model: ${c.model}`);
info(` key: ${c.apiKey ? `present (from $${c.keySource})` : 'none'}`);
info(` effort: ${c.effort} style: ${c.style}`);
if (!c.apiKey) warn(' no API key resolved — set --key-env <ENV> or CODEGRAPH_OFFLOAD_KEY if your endpoint requires auth.');
});
offloadCmd
.command('disable')
.description('Turn off the reasoning offload (keeps any saved login token)')
.action(() => {
writeOffloadConfig(null);
success('Reasoning offload disabled.');
if (process.env.CODEGRAPH_OFFLOAD_URL) {
warn('Note: CODEGRAPH_OFFLOAD_URL is still set in your environment, which keeps it on. Unset it to fully disable.');
}
}); });
/** /**
+89
View File
@@ -0,0 +1,89 @@
/**
* Managed-login device flow for `codegraph login`.
*
* Opens the user's browser to the CodeGraph dashboard, where they authorize with
* their account; the CLI meanwhile polls for the minted, org-scoped token and
* stores it (see ./credentials + ./config) to turn on managed reasoning.
*
* This talks to the DASHBOARD (app.getcodegraph.com), not the metered gateway —
* it's a plain OAuth-style device handshake (RFC 8628 shape), nothing proprietary.
* The resulting token is what authenticates the managed reasoning calls (./reasoner).
*/
import { spawn } from 'child_process';
const DEFAULT_BASE = 'https://app.getcodegraph.com';
/** Dashboard base for the device-login endpoints; override for testing via CODEGRAPH_LOGIN_URL. */
export function loginBaseUrl(): string {
const raw = process.env.CODEGRAPH_LOGIN_URL?.trim() || DEFAULT_BASE;
return raw.replace(/\/+$/, '');
}
/** The dashboard's response to a device-authorization start request. */
export interface DeviceStart {
device_code: string;
user_code: string;
verification_uri: string;
/** Same URL with the code prefilled, for one-click open. */
verification_uri_complete?: string;
/** Seconds the CLI should wait between polls. */
interval?: number;
/** Seconds until the request expires. */
expires_in?: number;
}
/** Begin a device-authorization request. */
export async function startDeviceLogin(): Promise<DeviceStart> {
const base = loginBaseUrl();
const res = await fetch(`${base}/api/cli/device/start`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
}).catch(() => null);
if (!res) throw new Error(`couldn't reach ${base} — check your connection`);
if (!res.ok) throw new Error(`couldn't start login (HTTP ${res.status})`);
const j = (await res.json().catch(() => null)) as DeviceStart | null;
if (!j?.device_code || !j.user_code) throw new Error('login start returned an unexpected response');
return j;
}
/** Poll until the user approves in the browser; resolves with the org token. */
export async function pollForToken(deviceCode: string, intervalSec: number, expiresInSec: number): Promise<string> {
const deadline = Date.now() + Math.max(30, expiresInSec || 600) * 1000;
let waitMs = Math.max(2, intervalSec || 5) * 1000;
const base = loginBaseUrl();
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, waitMs));
const res = await fetch(`${base}/api/cli/device/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ device_code: deviceCode }),
}).catch(() => null);
if (!res) continue; // transient network blip — keep polling until the deadline
if (res.status === 200) {
const j = (await res.json().catch(() => null)) as { token?: string } | null;
if (j?.token) return j.token;
} else if (res.status === 429) {
waitMs += 2000; // server asked us to slow down
} else if (res.status === 404 || res.status === 410) {
throw new Error('the login request expired — run `codegraph login` again');
}
// 202 (authorization pending) → keep waiting
}
throw new Error('login timed out before you approved — run `codegraph login` again');
}
/** Best-effort: open a URL in the default browser. Never throws — the URL is also printed. */
export async function openBrowser(url: string): Promise<void> {
const [cmd, args] =
process.platform === 'darwin' ? ['open', [url]]
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
try {
const child = spawn(cmd as string, args as string[], { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
} catch {
/* the URL is printed for manual open */
}
}