Browse Source

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.
Colby McHenry 5 days ago
parent
commit
193722de45
2 changed files with 131 additions and 89 deletions
  1. 42 89
      src/bin/codegraph.ts
  2. 89 0
      src/reasoning/login.ts

+ 42 - 89
src/bin/codegraph.ts

@@ -36,8 +36,9 @@ import { installFatalHandlers } from './fatal-handler';
 import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
 import { EXTRACTION_VERSION } from '../extraction/extraction-version';
 import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
-import { writeOffloadConfig, resolveOffload } from '../reasoning/config';
+import { writeOffloadConfig } from '../reasoning/config';
 import { writeOffloadToken } from '../reasoning/credentials';
+import { startDeviceLogin, pollForToken, openBrowser } from '../reasoning/login';
 import { fetchUsage } from '../reasoning/reasoner';
 
 // 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
- * model and returns the synthesized answer instead of the raw source dump.
+ * `login` runs a browser device-authorization flow against the CodeGraph dashboard,
+ * 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
-  .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
+program
   .command('login')
-  .description('Use the managed CodeGraph AI tier (metered) with your account token')
-  .requiredOption('--token <token>', 'Your CodeGraph AI org token')
-  .option('--url <url>', 'Override the managed gateway URL (advanced/testing)')
-  .option('--model <model>', 'Override the model id')
-  .action((opts: { token: string; url?: string; model?: string }) => {
-    // Phase 2: the token is pasted in. A future `codegraph login` device flow will
-    // mint and store it automatically.
-    writeOffloadConfig({ managed: true, url: opts.url, model: opts.model });
-    writeOffloadToken(opts.token);
-    success('Reasoning offload: signed in to CodeGraph AI (managed).');
-    info('  Credits burn from your account. Check the balance with `codegraph offload status`.');
-    info('  Restart your editor/agent session for running MCP servers to pick it up.');
-  });
-
-offloadCmd
-  .command('logout')
-  .description('Sign out of CodeGraph AI and clear the stored token')
-  .action(() => {
-    writeOffloadToken(null);
-    writeOffloadConfig(null);
-    success('Signed out of CodeGraph AI; offload turned off.');
-  });
-
-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).');
+  .description('Sign in to CodeGraph AI for managed reasoning — opens your browser to authorize')
+  .option('--no-browser', "Don't auto-open the browser; just print the URL to visit")
+  .action(async (opts: { browser?: boolean }) => {
+    try {
+      const start = await startDeviceLogin();
+      const url = start.verification_uri_complete ?? start.verification_uri;
+      info('To authorize, open this URL in your browser:');
+      info(`  ${url}`);
+      info(`and confirm the code:  ${start.user_code}`);
+      if (opts.browser !== false) await openBrowser(url);
+      info('Waiting for authorization…  (Ctrl-C to cancel)');
+      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 */
       }
-      return;
+      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);
     }
-    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)')
+program
+  .command('logout')
+  .description('Sign out of CodeGraph AI (clears the saved token and turns off managed reasoning)')
   .action(() => {
+    writeOffloadToken(null);
     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.');
-    }
+    success('Signed out of CodeGraph AI.');
   });
 
 /**

+ 89 - 0
src/reasoning/login.ts

@@ -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 */
+  }
+}