@bitkyc08/opencodex 0.1.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +164 -0
  3. package/README.md +165 -0
  4. package/README.zh-CN.md +162 -0
  5. package/gui/README.md +73 -0
  6. package/gui/dist/assets/index-C1wlp1SM.css +1 -0
  7. package/gui/dist/assets/index-C9y3iMF1.js +9 -0
  8. package/gui/dist/favicon.png +0 -0
  9. package/gui/dist/icons.svg +24 -0
  10. package/gui/dist/index.html +15 -0
  11. package/gui/dist/logo.png +0 -0
  12. package/package.json +56 -0
  13. package/scripts/postinstall.mjs +57 -0
  14. package/src/adapters/anthropic.ts +306 -0
  15. package/src/adapters/azure.ts +31 -0
  16. package/src/adapters/base.ts +20 -0
  17. package/src/adapters/google.ts +195 -0
  18. package/src/adapters/image.ts +23 -0
  19. package/src/adapters/openai-chat.ts +265 -0
  20. package/src/adapters/openai-responses.ts +43 -0
  21. package/src/bridge.ts +296 -0
  22. package/src/cli.ts +183 -0
  23. package/src/codex-catalog.ts +318 -0
  24. package/src/codex-inject.ts +186 -0
  25. package/src/config.ts +108 -0
  26. package/src/index.ts +20 -0
  27. package/src/init.ts +163 -0
  28. package/src/model-cache.ts +42 -0
  29. package/src/oauth/anthropic.ts +151 -0
  30. package/src/oauth/callback-server.ts +249 -0
  31. package/src/oauth/index.ts +235 -0
  32. package/src/oauth/key-providers.ts +126 -0
  33. package/src/oauth/kimi.ts +160 -0
  34. package/src/oauth/local-token-detect.ts +71 -0
  35. package/src/oauth/login-cli.ts +90 -0
  36. package/src/oauth/pkce.ts +15 -0
  37. package/src/oauth/store.ts +39 -0
  38. package/src/oauth/types.ts +22 -0
  39. package/src/oauth/xai.ts +234 -0
  40. package/src/responses/parser.ts +402 -0
  41. package/src/responses/schema.ts +145 -0
  42. package/src/router.ts +86 -0
  43. package/src/server.ts +522 -0
  44. package/src/service.ts +130 -0
  45. package/src/star-prompt.ts +50 -0
  46. package/src/types.ts +228 -0
  47. package/src/update.ts +64 -0
  48. package/src/vision/describe.ts +98 -0
  49. package/src/vision/index.ts +141 -0
  50. package/src/web-search/executor.ts +75 -0
  51. package/src/web-search/format-result.ts +45 -0
  52. package/src/web-search/index.ts +62 -0
  53. package/src/web-search/loop.ts +188 -0
  54. package/src/web-search/parse.ts +128 -0
  55. package/src/web-search/synthetic-tool.ts +42 -0
package/src/config.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { OcxConfig } from "./types";
5
+
6
+ /**
7
+ * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
8
+ * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
9
+ */
10
+ export function atomicWriteFile(path: string, content: string): void {
11
+ const tmp = `${path}.ocx.tmp`;
12
+ writeFileSync(tmp, content, "utf-8");
13
+ renameSync(tmp, path);
14
+ }
15
+
16
+ const OCX_DIR = join(homedir(), ".opencodex");
17
+ const CONFIG_PATH = join(OCX_DIR, "config.json");
18
+ const PID_PATH = join(OCX_DIR, "ocx.pid");
19
+
20
+ /**
21
+ * Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels`
22
+ * is unset. Codex's spawn_agent advertises the first 5 featured catalog entries; these are the GPT
23
+ * natives the installed Codex actually ships. The user can remove any in the GUI — once they set the
24
+ * list (even to []), it is respected, so removals persist (start-up only seeds the UNSET case).
25
+ * Kept to ids ChatGPT accepts; the start-up seed prefers the live catalog's native slugs.
26
+ */
27
+ export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
28
+
29
+ export function getConfigDir(): string {
30
+ return OCX_DIR;
31
+ }
32
+
33
+ export function getConfigPath(): string {
34
+ return CONFIG_PATH;
35
+ }
36
+
37
+ export function getPidPath(): string {
38
+ return PID_PATH;
39
+ }
40
+
41
+ export function loadConfig(): OcxConfig {
42
+ if (!existsSync(CONFIG_PATH)) {
43
+ return getDefaultConfig();
44
+ }
45
+ try {
46
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
47
+ return JSON.parse(raw) as OcxConfig;
48
+ } catch {
49
+ return getDefaultConfig();
50
+ }
51
+ }
52
+
53
+ export function saveConfig(config: OcxConfig): void {
54
+ if (!existsSync(OCX_DIR)) {
55
+ mkdirSync(OCX_DIR, { recursive: true });
56
+ }
57
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
58
+ }
59
+
60
+ export function getDefaultConfig(): OcxConfig {
61
+ // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
62
+ // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
63
+ // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
64
+ return {
65
+ port: 10100,
66
+ providers: {
67
+ openai: {
68
+ adapter: "openai-responses",
69
+ baseUrl: "https://chatgpt.com/backend-api/codex",
70
+ authMode: "forward",
71
+ },
72
+ },
73
+ defaultProvider: "openai",
74
+ subagentModels: [...DEFAULT_SUBAGENT_MODELS],
75
+ };
76
+ }
77
+
78
+ export function resolveEnvValue(value: string | undefined): string | undefined {
79
+ if (!value) return undefined;
80
+ const match = value.match(/^\$\{(\w+)\}$/);
81
+ if (match) return process.env[match[1]];
82
+ if (value.startsWith("$")) return process.env[value.slice(1)];
83
+ return value;
84
+ }
85
+
86
+ export function writePid(pid: number): void {
87
+ if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
88
+ writeFileSync(PID_PATH, String(pid), "utf-8");
89
+ }
90
+
91
+ export function readPid(): number | null {
92
+ if (!existsSync(PID_PATH)) return null;
93
+ try {
94
+ const raw = readFileSync(PID_PATH, "utf-8").trim();
95
+ const pid = parseInt(raw, 10);
96
+ if (isNaN(pid)) return null;
97
+ try { process.kill(pid, 0); return pid; } catch { return null; }
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function removePid(): void {
104
+ try {
105
+ const { unlinkSync } = require("node:fs");
106
+ unlinkSync(PID_PATH);
107
+ } catch { /* ignore */ }
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { startServer } from "./server";
2
+ export { parseRequest } from "./responses/parser";
3
+ export { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
4
+ export { createAnthropicAdapter } from "./adapters/anthropic";
5
+ export { createAzureAdapter } from "./adapters/azure";
6
+ export { createGoogleAdapter } from "./adapters/google";
7
+ export { createOpenAIChatAdapter } from "./adapters/openai-chat";
8
+ export { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
9
+ export { loadConfig, saveConfig } from "./config";
10
+ export type { ProviderAdapter } from "./adapters/base";
11
+ export type {
12
+ OcxConfig,
13
+ OcxContext,
14
+ OcxMessage,
15
+ OcxParsedRequest,
16
+ OcxProviderConfig,
17
+ OcxRequestOptions,
18
+ OcxTool,
19
+ AdapterEvent,
20
+ } from "./types";
package/src/init.ts ADDED
@@ -0,0 +1,163 @@
1
+ import * as readline from "node:readline";
2
+ import { injectCodexConfig } from "./codex-inject";
3
+ import { getDefaultConfig, saveConfig } from "./config";
4
+ import { KEY_LOGIN_PROVIDERS, enrichProviderFromCatalog } from "./oauth/key-providers";
5
+ import { OAUTH_PROVIDERS } from "./oauth";
6
+ import type { OcxConfig, OcxProviderConfig } from "./types";
7
+
8
+ function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
+ return {
11
+ ask(question: string): Promise<string> {
12
+ return new Promise(resolve => rl.question(question, resolve));
13
+ },
14
+ close() { rl.close(); },
15
+ };
16
+ }
17
+
18
+ type InitKind = "forward" | "oauth" | "key" | "local";
19
+ export interface InitProvider {
20
+ id: string;
21
+ label: string;
22
+ adapter: string;
23
+ baseUrl: string;
24
+ kind: InitKind;
25
+ dashboardUrl?: string;
26
+ defaultModel?: string;
27
+ }
28
+
29
+ const OAUTH_LABELS: Record<string, string> = {
30
+ xai: "xAI (Grok)", anthropic: "Anthropic (Claude)", kimi: "Kimi (Moonshot)",
31
+ };
32
+
33
+ /**
34
+ * The full CLI provider menu, built from the SAME registries the GUI uses (OAUTH_PROVIDERS +
35
+ * KEY_LOGIN_PROVIDERS) plus the ChatGPT-forward, a few non-catalog key providers, and local servers —
36
+ * so `ocx init` reaches provider parity with the GUI. Exported for verification.
37
+ */
38
+ export function buildInitProviders(): InitProvider[] {
39
+ const out: InitProvider[] = [];
40
+ // ChatGPT login (no key) — the default forward provider.
41
+ out.push({ id: "openai", label: "OpenAI — ChatGPT login (no key)", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", kind: "forward" });
42
+ // Real account logins (OAuth).
43
+ for (const id of Object.keys(OAUTH_PROVIDERS)) {
44
+ const pc = OAUTH_PROVIDERS[id].providerConfig;
45
+ out.push({ id, label: `${OAUTH_LABELS[id] ?? id} — account login`, adapter: pc.adapter, baseUrl: pc.baseUrl, kind: "oauth", defaultModel: pc.defaultModel });
46
+ }
47
+ // Key providers not in the catalog (native adapters / well-known endpoints).
48
+ out.push({ id: "openai-apikey", label: "OpenAI (API key)", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", kind: "key", dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5" });
49
+ out.push({ id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", kind: "key", dashboardUrl: "https://openrouter.ai/keys" });
50
+ out.push({ id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", kind: "key", dashboardUrl: "https://console.groq.com/keys" });
51
+ out.push({ id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", kind: "key", dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro" });
52
+ out.push({ id: "azure-openai", label: "Azure OpenAI", adapter: "azure", baseUrl: "https://{resource}.openai.azure.com/openai/deployments/{deployment}", kind: "key", dashboardUrl: "https://portal.azure.com" });
53
+ // The full API-key catalog (deepseek, mistral, kilo, minimax, … — same set the GUI shows).
54
+ for (const [id, p] of Object.entries(KEY_LOGIN_PROVIDERS)) {
55
+ out.push({ id, label: p.label, adapter: p.adapter, baseUrl: p.baseUrl, kind: "key", dashboardUrl: p.dashboardUrl, defaultModel: p.defaultModel });
56
+ }
57
+ // Local servers (usually no key).
58
+ out.push({ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", kind: "local" });
59
+ out.push({ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", kind: "local" });
60
+ out.push({ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", kind: "local" });
61
+ return out;
62
+ }
63
+
64
+ const KIND_HEADING: Record<InitKind, string> = {
65
+ forward: "ChatGPT login",
66
+ oauth: "Account login (OAuth — then run: ocx login <id>)",
67
+ key: "API key (paste a key from the provider's dashboard)",
68
+ local: "Local servers (usually no key)",
69
+ };
70
+
71
+ function printMenu(providers: InitProvider[]): void {
72
+ console.log("Available providers:");
73
+ let lastKind: InitKind | null = null;
74
+ providers.forEach((p, i) => {
75
+ if (p.kind !== lastKind) { console.log(`\n ${KIND_HEADING[p.kind]}:`); lastKind = p.kind; }
76
+ console.log(` ${String(i + 1).padStart(2)}. ${p.label}`);
77
+ });
78
+ console.log(`\n ${providers.length + 1}. custom (enter URL manually)`);
79
+ }
80
+
81
+ const envKeyFor = (id: string) => `${id.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
82
+
83
+ export async function runInit(): Promise<void> {
84
+ const prompt = createPrompt();
85
+ console.log("\n🔧 opencodex (ocx) setup\n");
86
+
87
+ const providers = buildInitProviders();
88
+ printMenu(providers);
89
+
90
+ const choice = await prompt.ask("\nSelect provider (number): ");
91
+ const idx = parseInt(choice, 10) - 1;
92
+
93
+ let providerName: string;
94
+ let providerConfig: OcxProviderConfig;
95
+ let oauthHint = false;
96
+
97
+ if (idx >= 0 && idx < providers.length) {
98
+ const p = providers[idx];
99
+ providerName = p.id;
100
+ console.log(`\n📡 ${p.label}`);
101
+ console.log(` Base URL: ${p.baseUrl}`);
102
+
103
+ if (p.kind === "forward") {
104
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
105
+ console.log(" No API key needed — forwards your existing `codex login`.");
106
+ } else if (p.kind === "oauth") {
107
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
108
+ oauthHint = true;
109
+ } else {
110
+ // key + local: collect a key (local usually blank).
111
+ if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
112
+ const env = envKeyFor(p.id);
113
+ const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
114
+ const apiKey = (await prompt.ask(`\n${hint}`)).trim();
115
+ const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
116
+ const defaultModel = modelChoice || p.defaultModel;
117
+ providerConfig = {
118
+ adapter: p.adapter,
119
+ baseUrl: p.baseUrl,
120
+ ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
121
+ ...(defaultModel ? { defaultModel } : {}),
122
+ };
123
+ // Apply the catalog's models / vision classification (same enrichment as the GUI).
124
+ enrichProviderFromCatalog(p.id, providerConfig);
125
+ }
126
+ } else {
127
+ providerName = await prompt.ask("Provider name: ");
128
+ const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
129
+ const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
130
+ const apiKey = await prompt.ask("API key (optional): ");
131
+ const defaultModel = await prompt.ask("Default model: ");
132
+ providerConfig = {
133
+ adapter: adapter.trim(),
134
+ baseUrl: baseUrl.trim(),
135
+ ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
136
+ ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
137
+ };
138
+ }
139
+
140
+ const portStr = await prompt.ask("\nProxy port [10100]: ");
141
+ const port = parseInt(portStr, 10) || 10100;
142
+
143
+ const config: OcxConfig = {
144
+ ...getDefaultConfig(),
145
+ port,
146
+ providers: { [providerName]: providerConfig },
147
+ defaultProvider: providerName,
148
+ };
149
+
150
+ saveConfig(config);
151
+ console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
152
+ if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
153
+
154
+ const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
155
+ if (injectAnswer.trim().toLowerCase() !== "n") {
156
+ console.log("Fetching available models from provider...");
157
+ const result = await injectCodexConfig(port, config);
158
+ console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
159
+ }
160
+
161
+ console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
162
+ prompt.close();
163
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * In-memory, per-provider TTL cache for live `/models` results.
3
+ *
4
+ * Ported in spirit from jawcode's packages/ai/src/model-manager.ts (the "always load the latest
5
+ * model list" resolver): live fetch when the cache is stale, serve the cache while it is fresh,
6
+ * and fall back to the last-known-good list when a live fetch fails. opencodex's proxy is a single
7
+ * long-running process and the on-disk Codex catalog already persists the last sync across
8
+ * restarts, so an in-memory cache is sufficient here (no SQLite layer needed).
9
+ */
10
+ import type { CatalogModel } from "./codex-catalog";
11
+
12
+ /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
13
+ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
14
+
15
+ interface CacheEntry {
16
+ models: CatalogModel[];
17
+ fetchedAt: number;
18
+ }
19
+
20
+ const cache = new Map<string, CacheEntry>();
21
+
22
+ /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */
23
+ export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null {
24
+ const entry = cache.get(provider);
25
+ if (!entry) return null;
26
+ return now - entry.fetchedAt < ttlMs ? entry.models : null;
27
+ }
28
+
29
+ /** Last-known-good models regardless of age — the fallback when a live fetch fails. */
30
+ export function getStaleCached(provider: string): CatalogModel[] | null {
31
+ return cache.get(provider)?.models ?? null;
32
+ }
33
+
34
+ export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void {
35
+ cache.set(provider, { models, fetchedAt: now });
36
+ }
37
+
38
+ /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
39
+ export function clearModelCache(provider?: string): void {
40
+ if (provider) cache.delete(provider);
41
+ else cache.clear();
42
+ }
@@ -0,0 +1,151 @@
1
+ /** Anthropic OAuth flow (Claude Pro/Max). Ported from jawcode oauth/anthropic.ts. */
2
+ import { OAuthCallbackFlow } from "./callback-server";
3
+ import { generatePKCE } from "./pkce";
4
+ import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
5
+
6
+ const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
7
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
8
+ const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
9
+ const CALLBACK_PORT = 54545;
10
+ const CALLBACK_PATH = "/callback";
11
+ const SCOPES = "org:create_api_key user:profile user:inference";
12
+
13
+ // ── OAuth-request requirements applied by the anthropic adapter when authMode==="oauth" ──
14
+ export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
15
+ export const CLAUDE_CODE_SYSTEM_INSTRUCTION = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
16
+ const CLAUDE_TOOL_PREFIX = "proxy_";
17
+ const ANTHROPIC_BUILTIN_TOOLS = new Set(["web_search", "code_execution", "text_editor", "computer"]);
18
+
19
+ /** OAuth tokens reject arbitrary tool names; prefix custom tools (Anthropic builtins are exempt). */
20
+ export function applyClaudeToolPrefix(name: string): string {
21
+ if (ANTHROPIC_BUILTIN_TOOLS.has(name.toLowerCase()) || name.toLowerCase().startsWith(CLAUDE_TOOL_PREFIX)) return name;
22
+ return CLAUDE_TOOL_PREFIX + name;
23
+ }
24
+
25
+ /** Strip the proxy_ prefix from a returned tool_use name so the caller (Codex) sees the original. */
26
+ export function stripClaudeToolPrefix(name: string): string {
27
+ return name.startsWith(CLAUDE_TOOL_PREFIX) ? name.slice(CLAUDE_TOOL_PREFIX.length) : name;
28
+ }
29
+
30
+ interface AnthropicTokenResponse {
31
+ access_token: string;
32
+ refresh_token: string;
33
+ expires_in: number;
34
+ account?: { uuid?: string; email_address?: string };
35
+ }
36
+
37
+ async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
38
+ const response = await fetch(url, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
41
+ body: JSON.stringify(body),
42
+ signal: AbortSignal.timeout(30_000),
43
+ });
44
+ const responseBody = await response.text();
45
+ if (!response.ok) {
46
+ throw new Error(`Anthropic OAuth HTTP ${response.status}: ${responseBody}`);
47
+ }
48
+ return responseBody;
49
+ }
50
+
51
+ function parseTokenResponse(responseBody: string): AnthropicTokenResponse {
52
+ try {
53
+ return JSON.parse(responseBody) as AnthropicTokenResponse;
54
+ } catch {
55
+ throw new Error(`Anthropic OAuth returned invalid JSON: ${responseBody.slice(0, 200)}`);
56
+ }
57
+ }
58
+
59
+ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAuthCredentials {
60
+ const accountUuid = data.account?.uuid;
61
+ const email = data.account?.email_address;
62
+ return {
63
+ refresh: data.refresh_token || refreshFallback || "",
64
+ access: data.access_token,
65
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
66
+ accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined,
67
+ email: typeof email === "string" && email.length > 0 ? email : undefined,
68
+ };
69
+ }
70
+
71
+ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
72
+ #verifier = "";
73
+
74
+ constructor(ctrl: OAuthController) {
75
+ super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
76
+ }
77
+
78
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
79
+ const pkce = await generatePKCE();
80
+ this.#verifier = pkce.verifier;
81
+ const authParams = new URLSearchParams({
82
+ code: "true",
83
+ client_id: CLIENT_ID,
84
+ response_type: "code",
85
+ redirect_uri: redirectUri,
86
+ scope: SCOPES,
87
+ code_challenge: pkce.challenge,
88
+ code_challenge_method: "S256",
89
+ state,
90
+ });
91
+ return {
92
+ url: `${AUTHORIZE_URL}?${authParams.toString()}`,
93
+ instructions:
94
+ "Complete Claude login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
95
+ };
96
+ }
97
+
98
+ async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
99
+ let exchangeCode = code;
100
+ let exchangeState = state;
101
+ const hash = code.indexOf("#");
102
+ if (hash >= 0) {
103
+ exchangeCode = code.slice(0, hash);
104
+ const frag = code.slice(hash + 1);
105
+ if (frag.length > 0) exchangeState = frag;
106
+ }
107
+ const responseBody = await postJson(TOKEN_URL, {
108
+ grant_type: "authorization_code",
109
+ client_id: CLIENT_ID,
110
+ code: exchangeCode,
111
+ state: exchangeState,
112
+ redirect_uri: redirectUri,
113
+ code_verifier: this.#verifier,
114
+ });
115
+ return credsFrom(parseTokenResponse(responseBody));
116
+ }
117
+ }
118
+
119
+ export async function loginAnthropic(
120
+ ctrl: OAuthController,
121
+ opts?: { importLocal?: LocalTokenImportMode },
122
+ ): Promise<OAuthCredentials> {
123
+ const importLocal = opts?.importLocal ?? "off";
124
+ if (importLocal !== "off") {
125
+ const { detectClaudeCodeToken } = await import("./local-token-detect");
126
+ const local = detectClaudeCodeToken();
127
+ if (local) {
128
+ ctrl.onProgress?.("Found Claude Code token, importing automatically");
129
+ if (local.expires >= Date.now() + 60_000) return local;
130
+ try {
131
+ return await refreshAnthropicToken(local.refresh);
132
+ } catch (error) {
133
+ if (importLocal === "only") {
134
+ throw new Error(`Claude Code token expired and could not be refreshed: ${error instanceof Error ? error.message : String(error)}`);
135
+ }
136
+ }
137
+ } else if (importLocal === "only") {
138
+ throw new Error("No Claude Code token found in the keychain. Run 'ocx login anthropic' for browser OAuth.");
139
+ }
140
+ }
141
+ return new AnthropicOAuthFlow(ctrl).login();
142
+ }
143
+
144
+ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
145
+ const responseBody = await postJson(TOKEN_URL, {
146
+ grant_type: "refresh_token",
147
+ client_id: CLIENT_ID,
148
+ refresh_token: refreshToken,
149
+ });
150
+ return credsFrom(parseTokenResponse(responseBody), refreshToken);
151
+ }