import { spawn } from "node:child_process"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getCapabilities, Input } from "@earendil-works/pi-tui"; import { dbg, diagnosticKind } from "./debug.js"; import { openUrl } from "./pi-ext-browser-open.js"; import { type ClipboardState, renderScene, SCENE_INTRO_TICKS, SCENE_TICK_MS, type ScenePhase, type SceneState, sceneOutcomeTicks, } from "./setup-scene.js"; export { openerCandidates } from "./pi-ext-browser-open.js"; // Same public client as Claude Code and Pi's own Anthropic OAuth flow. const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); export const AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize"; export const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; export const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; const SCOPE = "user:inference"; export const EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60; export type Pkce = { verifier: string; challenge: string; state: string }; function base64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64url"); } export async function generatePkce(): Promise { const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(verifier), ); return { verifier, challenge: base64url(new Uint8Array(digest)), state: base64url(crypto.getRandomValues(new Uint8Array(32))), }; } export function buildAuthorizeUrl(pkce: Pkce): string { const params = new URLSearchParams({ code: "true", client_id: CLIENT_ID, response_type: "code", redirect_uri: REDIRECT_URI, scope: SCOPE, code_challenge: pkce.challenge, code_challenge_method: "S256", state: pkce.state, }); return `${AUTHORIZE_URL}?${params}`; } export type ParsedAuthorization = { code: string; state?: string }; /** Accepts the callback page's `code#state`, a redirect URL, a query string, or a bare code. */ export function parseAuthorizationInput( input: string, ): ParsedAuthorization | undefined { const value = input.trim(); if (!value) return undefined; try { const url = new URL(value); const code = url.searchParams.get("code"); return code ? { code, state: url.searchParams.get("state") ?? undefined } : undefined; } catch {} if (value.includes("#")) { const [code, state] = value.split("#", 2); return code ? { code, state: state || undefined } : undefined; } if (value.includes("code=")) { const params = new URLSearchParams(value); const code = params.get("code"); return code ? { code, state: params.get("state") ?? undefined } : undefined; } return { code: value }; } export type FetchLike = (url: string, init: RequestInit) => Promise; export type MintedToken = { token: string; expiresInSeconds?: number }; export async function exchangeCode( parsed: ParsedAuthorization, pkce: Pkce, fetchFn: FetchLike = fetch, ): Promise { if (parsed.state && parsed.state !== pkce.state) throw new Error("OAuth state mismatch; start over."); dbg?.("setupToken.exchange.start"); const response = await fetchFn(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", code: parsed.code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: pkce.verifier, state: pkce.state, expires_in: EXPIRES_IN_SECONDS, }), signal: AbortSignal.timeout(30_000), }); const body = await response.text(); dbg?.("setupToken.exchange.response"); if (!response.ok) { throw new Error( response.status === 401 ? "Anthropic rejected the authorization code." : `Token exchange failed (${response.status}): ${body.slice(0, 200)}`, ); } let data: { access_token?: unknown; expires_in?: unknown }; try { data = JSON.parse(body) as typeof data; } catch { throw new Error("Token exchange returned invalid JSON."); } if (typeof data.access_token !== "string" || !data.access_token) throw new Error("Token exchange returned no access token."); return { token: data.access_token, expiresInSeconds: typeof data.expires_in === "number" ? data.expires_in : undefined, }; } export function clipboardCandidates( platform: NodeJS.Platform = process.platform, ): string[][] { switch (platform) { case "darwin": return [["pbcopy"]]; case "win32": return [["clip"]]; case "android": return [["termux-clipboard-set"]]; default: return [ ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"], ]; } } export type ClipboardRunner = ( command: string[], text: string, ) => Promise; export async function copyToClipboard( text: string, run: ClipboardRunner, platform: NodeJS.Platform = process.platform, ): Promise { for (const command of clipboardCandidates(platform)) { if (await run(command, text)) return true; } return false; } export function defaultRunner( command: string[], text: string, ): Promise { return new Promise((resolve) => { const child = spawn(command[0] ?? "", command.slice(1), { stdio: ["pipe", "ignore", "ignore"], }); child.on("error", () => resolve(false)); child.on("close", (code) => resolve(code === 0)); child.stdin?.on("error", () => resolve(false)); child.stdin?.end(text); }); } async function openBrowser(url: string): Promise { if (process.env.NODE_ENV === "test" || process.env.VITEST) return false; return (await openUrl(url, {})) !== undefined; } function fail(message: string): string { return `Klaus could not collect a token: ${message}`; } export type SetupTokenDeps = { pkce?: () => Promise; fetchFn?: FetchLike; copy?: typeof copyToClipboard; run?: ClipboardRunner; openUrl?: (url: string) => Promise; }; let inFlight = false; export async function runSetupTokenCommand( ctx: ExtensionContext, deps: SetupTokenDeps = {}, ): Promise { if (!ctx.hasUI) return; if (inFlight) { ctx.ui.notify("Klaus is already minting a token.", "warning"); return; } inFlight = true; dbg?.("setupToken.command.start"); try { if (ctx.mode === "tui") { await runCounterScene(ctx, deps); return; } await runDialogFlow(ctx, deps); } finally { inFlight = false; } } /** RPC and other UI hosts without a TUI: plain dialogs, no scene. */ async function runDialogFlow( ctx: ExtensionContext, deps: SetupTokenDeps, ): Promise { const pkce = await (deps.pkce ?? generatePkce)(); const url = buildAuthorizeUrl(pkce); const answer = await ctx.ui.input( `Klaus: sign in at ${url} and paste the code`, "code#state", ); const parsed = answer === undefined ? undefined : parseAuthorizationInput(answer); if (!parsed) { dbg?.("setupToken.command.cancelled"); ctx.ui.notify("Klaus: application withdrawn.", "info"); return; } ctx.ui.setStatus("klaus", "minting token…"); let minted: MintedToken; try { minted = await exchangeCode(parsed, pkce, deps.fetchFn); } catch (error) { ctx.ui.notify( fail(error instanceof Error ? error.message : String(error)), "error", ); return; } finally { ctx.ui.setStatus("klaus", undefined); } const copied = await (deps.copy ?? copyToClipboard)( minted.token, deps.run ?? defaultRunner, ); dbg?.("setupToken.command.minted", { copied }); ctx.ui.notify( copied ? "Token in clipboard." : `No clipboard. Token: ${minted.token}`, copied ? "info" : "warning", ); } /** * TUI: one focused component owns the whole visit, from filing the application * through the sign-in code to the stamp, so the animation frames the flow * instead of interrupting it. */ async function runCounterScene( ctx: ExtensionContext, deps: SetupTokenDeps, ): Promise { let pkce = await (deps.pkce ?? generatePkce)(); let url = buildAuthorizeUrl(pkce); void (deps.openUrl ?? openBrowser)(url); const outcome = await ctx.ui.custom<"cancelled" | "closed">( (tui, theme, keybindings, done) => { const field = new Input({ prompt: "Code: ", placeholder: "code#state", placeholderStyle: (text) => theme.fg("dim", text), }); field.focused = true; let tick = 0; let phaseTick = 0; let phase: ScenePhase = "signin"; let clipboard: ClipboardState = "idle"; let token: string | undefined; let tokenCopied = false; let expiresInSeconds: number | undefined; let failure: string | undefined; let settled = false; let timer: NodeJS.Timeout | undefined; let interval = 0; const outcomeTicks = () => sceneOutcomeTicks(sceneState([], "")); const outcomeSettled = () => (phase === "approved" || phase === "denied") && phaseTick >= outcomeTicks(); function sceneState(input: string[], hints: string): SceneState { return { tick, phaseTick, phase, input, hyperlinks: getCapabilities().hyperlinks, url, clipboard, hints, token, tokenCopied, expiresInSeconds, error: failure, }; } function hints(): string { if (phase === "signin") return [ "enter submit", field.getValue() === "" ? "c copy link" : "", "esc cancel", ] .filter(Boolean) .join(" · "); if (phase === "exchanging") return "esc cancel"; if (!outcomeSettled()) return "any key to skip"; return phase === "denied" ? "r retry · esc close" : "any key to close"; } /** Fast during animation, slow while the human is signing in, off when static. */ function schedule(): void { const desired = tick < SCENE_INTRO_TICKS ? SCENE_TICK_MS : phase === "exchanging" ? 90 : phase === "signin" ? 400 : outcomeSettled() ? 0 : SCENE_TICK_MS; if (desired === interval) return; interval = desired; if (timer) clearInterval(timer); timer = undefined; if (desired === 0) return; timer = setInterval(() => { tick++; phaseTick++; schedule(); tui.requestRender(); }, desired); timer.unref?.(); } function stop(): void { if (timer) clearInterval(timer); timer = undefined; interval = 0; } function finish(result: "cancelled" | "closed"): void { if (settled) return; settled = true; stop(); done(result); } function enterPhase(next: ScenePhase): void { phase = next; phaseTick = 0; schedule(); tui.requestRender(); } async function submit(value: string): Promise { const parsed = parseAuthorizationInput(value); if (!parsed) return; enterPhase("exchanging"); try { const minted = await exchangeCode(parsed, pkce, deps.fetchFn); token = minted.token; expiresInSeconds = minted.expiresInSeconds; tokenCopied = await (deps.copy ?? copyToClipboard)( minted.token, deps.run ?? defaultRunner, ); dbg?.("setupToken.command.minted", { copied: tokenCopied }); enterPhase("approved"); } catch (error) { failure = error instanceof Error ? error.message : String(error); dbg?.("setupToken.command.denied", { kind: diagnosticKind(error) }); enterPhase("denied"); } } async function retry(): Promise { pkce = await (deps.pkce ?? generatePkce)(); url = buildAuthorizeUrl(pkce); failure = undefined; clipboard = "idle"; field.setValue(""); void (deps.openUrl ?? openBrowser)(url); enterPhase("signin"); } function copyLink(): void { void (deps.copy ?? copyToClipboard)( url, deps.run ?? defaultRunner, ).then((ok) => { clipboard = ok ? "copied" : "failed"; tui.requestRender(); }); } field.onSubmit = (value) => { if (value.trim()) void submit(value); }; field.onEscape = () => finish("cancelled"); schedule(); return { render(width: number) { // Leave room for the centering indent the scene adds around the form block. const input = phase === "signin" ? field.render(Math.max(Math.min(width, 52), 12)) : []; return renderScene(sceneState(input, hints()), width, theme); }, invalidate() { field.invalidate(); }, handleInput(data: string) { const cancel = keybindings.matches(data, "app.interrupt") || keybindings.matches(data, "tui.select.cancel"); if (phase === "signin") { if (cancel) { finish("cancelled"); return; } if (data === "c" && field.getValue() === "") { copyLink(); return; } field.handleInput(data); schedule(); tui.requestRender(); return; } if (phase === "exchanging") { if (cancel) finish("cancelled"); return; } if (!outcomeSettled()) { phaseTick = outcomeTicks(); schedule(); tui.requestRender(); return; } if (phase === "denied" && data === "r") { void retry(); return; } finish("closed"); }, dispose() { stop(); }, }; }, ); if (outcome === "cancelled") { dbg?.("setupToken.command.cancelled"); ctx.ui.notify("Klaus: application withdrawn.", "info"); } }