@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
@@ -0,0 +1,160 @@
1
+ /** Kimi Code OAuth flow (device authorization grant). Ported from jawcode oauth/kimi.ts. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import * as os from "node:os";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { getConfigDir } from "../config";
7
+ import type { OAuthController, OAuthCredentials } from "./types";
8
+
9
+ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
10
+ const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
11
+ const DEVICE_ID_FILENAME = "kimi-device-id";
12
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
13
+ const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
14
+ const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000;
15
+ const KIMI_CLI_VERSION = "1.0.0";
16
+
17
+ interface DeviceAuthorizationResponse {
18
+ user_code?: string;
19
+ device_code?: string;
20
+ verification_uri?: string;
21
+ verification_uri_complete?: string;
22
+ expires_in?: number;
23
+ interval?: number;
24
+ }
25
+
26
+ interface TokenResponse {
27
+ access_token?: string;
28
+ refresh_token?: string;
29
+ expires_in?: number;
30
+ error?: string;
31
+ error_description?: string;
32
+ interval?: number;
33
+ }
34
+
35
+ function resolveOAuthHost(): string {
36
+ return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
37
+ }
38
+
39
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
40
+ return new Promise((resolve, reject) => {
41
+ if (signal?.aborted) return reject(new Error("Login cancelled"));
42
+ const t = setTimeout(resolve, ms);
43
+ signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("Login cancelled")); }, { once: true });
44
+ });
45
+ }
46
+
47
+ function getDeviceModel(): string {
48
+ const platform = os.platform();
49
+ const label = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : platform === "linux" ? "Linux" : platform;
50
+ return [label, os.release(), os.arch()].filter(Boolean).join(" ").trim();
51
+ }
52
+
53
+ let deviceIdCache: string | undefined;
54
+ function getDeviceId(): string {
55
+ if (deviceIdCache) return deviceIdCache;
56
+ const p = join(getConfigDir(), DEVICE_ID_FILENAME);
57
+ try {
58
+ const existing = readFileSync(p, "utf-8").trim();
59
+ if (existing) { deviceIdCache = existing; return existing; }
60
+ } catch (e) {
61
+ if ((e as { code?: string })?.code !== "ENOENT") throw e;
62
+ }
63
+ const id = randomUUID().replace(/-/g, "");
64
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
65
+ writeFileSync(p, id + "\n", { mode: 0o600 });
66
+ deviceIdCache = id;
67
+ return id;
68
+ }
69
+
70
+ function getKimiCommonHeaders(): Record<string, string> {
71
+ return {
72
+ "User-Agent": `KimiCLI/${KIMI_CLI_VERSION}`,
73
+ "X-Msh-Platform": "kimi_cli",
74
+ "X-Msh-Version": KIMI_CLI_VERSION,
75
+ "X-Msh-Device-Name": os.hostname(),
76
+ "X-Msh-Device-Model": getDeviceModel(),
77
+ "X-Msh-Os-Version": os.version(),
78
+ "X-Msh-Device-Id": getDeviceId(),
79
+ };
80
+ }
81
+
82
+ async function requestDeviceAuthorization(): Promise<{
83
+ userCode: string; deviceCode: string; verificationUriComplete: string; expiresInMs: number; intervalMs: number;
84
+ }> {
85
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
88
+ body: new URLSearchParams({ client_id: CLIENT_ID }),
89
+ });
90
+ if (!response.ok) {
91
+ throw new Error(`Kimi device authorization failed: ${response.status} ${await response.text()}`);
92
+ }
93
+ const payload = (await response.json()) as DeviceAuthorizationResponse;
94
+ if (!payload.user_code || !payload.device_code || !payload.verification_uri) {
95
+ throw new Error("Kimi device authorization response missing required fields");
96
+ }
97
+ return {
98
+ userCode: payload.user_code,
99
+ deviceCode: payload.device_code,
100
+ verificationUriComplete: payload.verification_uri_complete || payload.verification_uri,
101
+ expiresInMs: typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS,
102
+ intervalMs: typeof payload.interval === "number" && payload.interval > 0 ? payload.interval * 1000 : DEFAULT_POLL_INTERVAL_MS,
103
+ };
104
+ }
105
+
106
+ function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OAuthCredentials {
107
+ if (!payload.access_token || typeof payload.expires_in !== "number") {
108
+ throw new Error("Kimi token response missing required fields");
109
+ }
110
+ const refresh = payload.refresh_token ?? refreshFallback;
111
+ if (!refresh) throw new Error("Kimi token response missing refresh token");
112
+ return { access: payload.access_token, refresh, expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS };
113
+ }
114
+
115
+ async function pollForToken(deviceCode: string, intervalMs: number, expiresInMs: number, signal?: AbortSignal): Promise<OAuthCredentials> {
116
+ const deadline = Date.now() + expiresInMs;
117
+ let waitMs = Math.max(1000, intervalMs);
118
+ while (Date.now() < deadline) {
119
+ if (signal?.aborted) throw new Error("Login cancelled");
120
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
123
+ body: new URLSearchParams({ client_id: CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
124
+ });
125
+ const payload = (await response.json()) as TokenResponse;
126
+ if (response.ok && payload.access_token) return parseTokenPayload(payload);
127
+ const error = payload.error;
128
+ if (error === "authorization_pending") { await sleep(waitMs, signal); continue; }
129
+ if (error === "slow_down") {
130
+ waitMs += 5000;
131
+ const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
132
+ if (retryAfter && retryAfter > waitMs) waitMs = retryAfter;
133
+ await sleep(waitMs, signal);
134
+ continue;
135
+ }
136
+ if (error === "expired_token") throw new Error("Kimi device authorization expired");
137
+ if (error === "access_denied") throw new Error("Kimi device authorization denied");
138
+ throw new Error(`Kimi device flow failed: ${error ?? response.status}${payload.error_description ? `: ${payload.error_description}` : ""}`);
139
+ }
140
+ throw new Error("Kimi device flow timed out");
141
+ }
142
+
143
+ export async function loginKimi(ctrl: OAuthController): Promise<OAuthCredentials> {
144
+ const device = await requestDeviceAuthorization();
145
+ ctrl.onAuth?.({ url: device.verificationUriComplete, instructions: `Enter code: ${device.userCode}` });
146
+ return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
147
+ }
148
+
149
+ export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
150
+ const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
153
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
154
+ });
155
+ if (!response.ok) {
156
+ const payload = (await response.json().catch(() => undefined)) as TokenResponse | undefined;
157
+ throw new Error(`Kimi token refresh failed: ${response.status}${payload?.error_description ? `: ${payload.error_description}` : ""}`);
158
+ }
159
+ return parseTokenPayload((await response.json()) as TokenResponse, refreshToken);
160
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Local token auto-detection — reads an existing Grok CLI credential (~/.grok/auth.json).
3
+ * Read-only: never writes to external credential stores.
4
+ * Ported from jawcode packages/ai/src/utils/oauth/local-token-detect.ts (xAI portion).
5
+ */
6
+ import { execSync } from "node:child_process";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { OAuthCredentials } from "./types";
11
+
12
+ const XAI_AUTH_KEY_PREFIX = "https://auth.x.ai::";
13
+ const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
14
+
15
+ export function detectGrokCliToken(): OAuthCredentials | null {
16
+ const authPath = join(homedir(), ".grok", "auth.json");
17
+ if (!existsSync(authPath)) return null;
18
+
19
+ try {
20
+ const raw = JSON.parse(readFileSync(authPath, "utf8")) as Record<string, Record<string, unknown>>;
21
+
22
+ const entry = Object.entries(raw).find(([key]) => key.startsWith(XAI_AUTH_KEY_PREFIX))?.[1];
23
+ if (!entry?.key || !entry?.refresh_token) return null;
24
+
25
+ const accessToken = entry.key as string;
26
+ const refreshToken = entry.refresh_token as string;
27
+ const expiresAt = entry.expires_at ? new Date(entry.expires_at as string).getTime() : 0;
28
+
29
+ return {
30
+ refresh: refreshToken,
31
+ access: accessToken,
32
+ expires: expiresAt,
33
+ accountId: entry.user_id as string | undefined,
34
+ email: entry.email as string | undefined,
35
+ };
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Read the Claude Code OAuth credential from the OS secure store (macOS keychain / linux secret-tool). */
42
+ function readClaudeSecureStorage(): string | null {
43
+ try {
44
+ if (process.platform === "darwin") {
45
+ return execSync(`security find-generic-password -s "${CLAUDE_KEYCHAIN_SERVICE}" -w`, {
46
+ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
47
+ }).trim();
48
+ }
49
+ if (process.platform === "linux") {
50
+ return execSync(`secret-tool lookup service "${CLAUDE_KEYCHAIN_SERVICE}"`, {
51
+ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
52
+ }).trim();
53
+ }
54
+ return null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function detectClaudeCodeToken(): OAuthCredentials | null {
61
+ const raw = readClaudeSecureStorage();
62
+ if (!raw) return null;
63
+ try {
64
+ const data = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string; expiresAt?: number } };
65
+ const o = data.claudeAiOauth;
66
+ if (!o?.accessToken || !o?.refreshToken) return null;
67
+ return { access: o.accessToken, refresh: o.refreshToken, expires: o.expiresAt ?? 0 };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
@@ -0,0 +1,90 @@
1
+ import * as readline from "node:readline";
2
+ import { exec } from "node:child_process";
3
+ import { loadConfig, readPid, saveConfig } from "../config";
4
+ import { OAUTH_PROVIDERS, runLogin } from "./index";
5
+ import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey } from "./key-providers";
6
+
7
+ function openBrowser(url: string): void {
8
+ const cmd =
9
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
10
+ exec(`${cmd} "${url}"`, () => {});
11
+ }
12
+
13
+ /** Push the new provider into a running proxy's live config so it routes without a restart. */
14
+ async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
15
+ if (!readPid()) return;
16
+ const cfg = loadConfig();
17
+ try {
18
+ await fetch(`http://localhost:${cfg.port}/api/providers`, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify({ name, provider }),
22
+ });
23
+ } catch {
24
+ /* proxy unreachable; disk config loads on next start */
25
+ }
26
+ }
27
+
28
+ export async function handleLogin(provider?: string): Promise<void> {
29
+ const name = (provider ?? "").trim().toLowerCase();
30
+ if (OAUTH_PROVIDERS[name]) return handleOAuthLogin(name);
31
+ if (isKeyLoginProvider(name)) return handleKeyLogin(name);
32
+ console.error(
33
+ `Usage: ocx login <provider>\n` +
34
+ ` OAuth login: ${Object.keys(OAUTH_PROVIDERS).join(", ")}\n` +
35
+ ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`,
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ async function handleOAuthLogin(name: string): Promise<void> {
41
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
42
+ try {
43
+ await runLogin(name, {
44
+ onAuth: ({ url, instructions }) => {
45
+ console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`);
46
+ if (instructions) console.log(instructions);
47
+ openBrowser(url);
48
+ },
49
+ onProgress: (m) => console.log(` ${m}`),
50
+ onManualCodeInput: () =>
51
+ new Promise((res) => rl.question("Paste redirect URL or code (or wait for browser): ", res)),
52
+ });
53
+ } finally {
54
+ rl.close();
55
+ }
56
+ await notifyRunningProxy(name, OAUTH_PROVIDERS[name].providerConfig);
57
+ console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
58
+ }
59
+
60
+ async function handleKeyLogin(name: string): Promise<void> {
61
+ const def = KEY_LOGIN_PROVIDERS[name];
62
+ console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`);
63
+ openBrowser(def.dashboardUrl);
64
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
65
+ const key = (await new Promise<string>((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim();
66
+ rl.close();
67
+ if (!key) {
68
+ console.error("No key entered.");
69
+ process.exit(1);
70
+ }
71
+ process.stdout.write(" validating… ");
72
+ const valid = await validateApiKey(def.baseUrl, key);
73
+ console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)");
74
+ if (valid === false) {
75
+ console.error("Provider rejected the key. Not saved.");
76
+ process.exit(1);
77
+ }
78
+ const provider = {
79
+ adapter: def.adapter,
80
+ baseUrl: def.baseUrl,
81
+ apiKey: key,
82
+ ...(def.defaultModel ? { defaultModel: def.defaultModel } : {}),
83
+ ...(def.models ? { models: def.models } : {}),
84
+ };
85
+ const config = loadConfig();
86
+ config.providers[name] = provider;
87
+ saveConfig(config);
88
+ await notifyRunningProxy(name, provider);
89
+ console.log(`✅ ${def.label} added. Try: ocx sync`);
90
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Generate PKCE code verifier and challenge (S256).
3
+ * Ported verbatim from jawcode packages/ai/src/utils/oauth/pkce.ts.
4
+ */
5
+ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
6
+ const verifierBytes = new Uint8Array(96);
7
+ crypto.getRandomValues(verifierBytes);
8
+ const verifier = Buffer.from(verifierBytes).toString("base64url");
9
+
10
+ const data = new TextEncoder().encode(verifier);
11
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
12
+ const challenge = Buffer.from(hashBuffer).toString("base64url");
13
+
14
+ return { verifier, challenge };
15
+ }
@@ -0,0 +1,39 @@
1
+ /** OAuth token store at ~/.opencodex/auth.json, keyed by provider name. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getConfigDir } from "../config";
5
+ import type { OAuthCredentials } from "./types";
6
+
7
+ const AUTH_PATH = join(getConfigDir(), "auth.json");
8
+ type AuthStore = Record<string, OAuthCredentials>;
9
+
10
+ export function loadAuthStore(): AuthStore {
11
+ if (!existsSync(AUTH_PATH)) return {};
12
+ try {
13
+ return JSON.parse(readFileSync(AUTH_PATH, "utf-8")) as AuthStore;
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ function persist(store: AuthStore): void {
20
+ const dir = getConfigDir();
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ writeFileSync(AUTH_PATH, JSON.stringify(store, null, 2) + "\n", "utf-8");
23
+ }
24
+
25
+ export function getCredential(provider: string): OAuthCredentials | null {
26
+ return loadAuthStore()[provider] ?? null;
27
+ }
28
+
29
+ export function saveCredential(provider: string, cred: OAuthCredentials): void {
30
+ const store = loadAuthStore();
31
+ store[provider] = cred;
32
+ persist(store);
33
+ }
34
+
35
+ export function removeCredential(provider: string): void {
36
+ const store = loadAuthStore();
37
+ delete store[provider];
38
+ persist(store);
39
+ }
@@ -0,0 +1,22 @@
1
+ /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */
2
+ export type OAuthCredentials = {
3
+ refresh: string;
4
+ access: string;
5
+ expires: number; // epoch ms (already skew-adjusted by the provider flow)
6
+ email?: string;
7
+ accountId?: string;
8
+ };
9
+
10
+ export interface OAuthController {
11
+ onAuth?(info: { url: string; instructions?: string }): void;
12
+ onProgress?(message: string): void;
13
+ onManualCodeInput?(): Promise<string>;
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ /**
18
+ * How a login flow may use a locally detected CLI token.
19
+ * "off" goes straight to the real OAuth flow, "fallback" imports a local token when present
20
+ * and falls back to OAuth otherwise, "only" imports without any OAuth fallback.
21
+ */
22
+ export type LocalTokenImportMode = "off" | "fallback" | "only";
@@ -0,0 +1,234 @@
1
+ /** xAI OAuth flow (Grok account login). Ported from jawcode oauth/xai.ts. */
2
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
3
+ import { generatePKCE } from "./pkce";
4
+ import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
5
+
6
+ const XAI_OAUTH_ISSUER = "https://auth.x.ai";
7
+ export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
8
+ export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
9
+ export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
10
+ const XAI_OAUTH_CALLBACK_PORT = 56121;
11
+ const XAI_OAUTH_CALLBACK_PATH = "/callback";
12
+ const XAI_OAUTH_REFRESH_SKEW_MS = 2 * 60 * 1000;
13
+ const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
14
+
15
+ interface XaiDiscovery {
16
+ authorizationEndpoint: string;
17
+ tokenEndpoint: string;
18
+ }
19
+
20
+ interface XaiDiscoveryPayload {
21
+ authorization_endpoint?: unknown;
22
+ token_endpoint?: unknown;
23
+ }
24
+
25
+ interface XaiTokenPayload {
26
+ access_token?: unknown;
27
+ refresh_token?: unknown;
28
+ expires_in?: unknown;
29
+ id_token?: unknown;
30
+ token_type?: unknown;
31
+ }
32
+
33
+ interface XaiJwtPayload {
34
+ sub?: unknown;
35
+ email?: unknown;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
40
+ const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
41
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
42
+ }
43
+
44
+ function validateXaiEndpoint(rawUrl: string): string {
45
+ const parsed = new URL(rawUrl);
46
+ const host = parsed.hostname.toLowerCase();
47
+ if (parsed.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
48
+ throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`);
49
+ }
50
+ return parsed.toString();
51
+ }
52
+
53
+ export async function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise<XaiDiscovery> {
54
+ const response = await fetch(XAI_OAUTH_DISCOVERY_URL, {
55
+ headers: { Accept: "application/json" },
56
+ signal: requestSignal(signal),
57
+ });
58
+ if (!response.ok) {
59
+ throw new Error(`xAI OAuth discovery failed: ${response.status} ${await response.text()}`);
60
+ }
61
+
62
+ const payload = (await response.json()) as XaiDiscoveryPayload;
63
+ if (typeof payload.authorization_endpoint !== "string" || typeof payload.token_endpoint !== "string") {
64
+ throw new Error("xAI OAuth discovery response missing authorization/token endpoints");
65
+ }
66
+
67
+ return {
68
+ authorizationEndpoint: validateXaiEndpoint(payload.authorization_endpoint),
69
+ tokenEndpoint: validateXaiEndpoint(payload.token_endpoint),
70
+ };
71
+ }
72
+
73
+ function decodeJwtPayload(token: string): XaiJwtPayload | undefined {
74
+ const parts = token.split(".");
75
+ const payload = parts[1];
76
+ if (parts.length !== 3 || !payload) return undefined;
77
+ try {
78
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as XaiJwtPayload;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function getTokenIdentity(accessToken: string, idToken: string | undefined): { accountId?: string; email?: string } {
85
+ const payload = (idToken ? decodeJwtPayload(idToken) : undefined) ?? decodeJwtPayload(accessToken);
86
+ const accountId = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined;
87
+ const email =
88
+ typeof payload?.email === "string" && payload.email.length > 0 ? payload.email.toLowerCase() : undefined;
89
+ return { accountId, email };
90
+ }
91
+
92
+ async function postXaiToken(
93
+ tokenEndpoint: string,
94
+ body: Record<string, string>,
95
+ signal?: AbortSignal,
96
+ ): Promise<XaiTokenPayload> {
97
+ const response = await fetch(tokenEndpoint, {
98
+ method: "POST",
99
+ headers: {
100
+ Accept: "application/json",
101
+ "Content-Type": "application/x-www-form-urlencoded",
102
+ },
103
+ body: new URLSearchParams(body).toString(),
104
+ signal: requestSignal(signal),
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`xAI token request failed: ${response.status} ${await response.text()}`);
108
+ }
109
+ return (await response.json()) as XaiTokenPayload;
110
+ }
111
+
112
+ function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials {
113
+ if (typeof payload.access_token !== "string" || payload.access_token.length === 0) {
114
+ throw new Error("xAI token response did not include an access token");
115
+ }
116
+ const refresh =
117
+ typeof payload.refresh_token === "string" && payload.refresh_token.length > 0
118
+ ? payload.refresh_token
119
+ : refreshFallback;
120
+ if (!refresh) {
121
+ throw new Error("xAI token response did not include a refresh token");
122
+ }
123
+ const expiresIn =
124
+ typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600;
125
+ const idToken = typeof payload.id_token === "string" ? payload.id_token : undefined;
126
+ const { accountId, email } = getTokenIdentity(payload.access_token, idToken);
127
+ return {
128
+ refresh,
129
+ access: payload.access_token,
130
+ expires: Date.now() + expiresIn * 1000 - XAI_OAUTH_REFRESH_SKEW_MS,
131
+ accountId,
132
+ email,
133
+ };
134
+ }
135
+
136
+ export class XaiOAuthFlow extends OAuthCallbackFlow {
137
+ #verifier = "";
138
+ #discovery: XaiDiscovery | undefined;
139
+
140
+ constructor(ctrl: OAuthController) {
141
+ super(ctrl, {
142
+ preferredPort: XAI_OAUTH_CALLBACK_PORT,
143
+ callbackPath: XAI_OAUTH_CALLBACK_PATH,
144
+ callbackHostname: "127.0.0.1",
145
+ callbackBindHostname: "127.0.0.1",
146
+ redirectUri: `http://127.0.0.1:${XAI_OAUTH_CALLBACK_PORT}${XAI_OAUTH_CALLBACK_PATH}`,
147
+ } satisfies OAuthCallbackFlowOptions);
148
+ }
149
+
150
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
151
+ const pkce = await generatePKCE();
152
+ this.#verifier = pkce.verifier;
153
+ this.#discovery = await discoverXaiOAuthEndpoints(this.ctrl.signal);
154
+ const params = new URLSearchParams({
155
+ response_type: "code",
156
+ client_id: XAI_OAUTH_CLIENT_ID,
157
+ redirect_uri: redirectUri,
158
+ scope: XAI_OAUTH_SCOPE,
159
+ code_challenge: pkce.challenge,
160
+ code_challenge_method: "S256",
161
+ state,
162
+ nonce: crypto.randomUUID(),
163
+ });
164
+ return {
165
+ url: `${this.#discovery.authorizationEndpoint}?${params.toString()}`,
166
+ instructions:
167
+ "Complete xAI/Grok login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
168
+ };
169
+ }
170
+
171
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
172
+ if (!this.#verifier) {
173
+ throw new Error("xAI OAuth PKCE verifier was not initialized");
174
+ }
175
+ const discovery = this.#discovery ?? (await discoverXaiOAuthEndpoints(this.ctrl.signal));
176
+ const tokenPayload = await postXaiToken(
177
+ discovery.tokenEndpoint,
178
+ {
179
+ grant_type: "authorization_code",
180
+ client_id: XAI_OAUTH_CLIENT_ID,
181
+ code,
182
+ redirect_uri: redirectUri,
183
+ code_verifier: this.#verifier,
184
+ },
185
+ this.ctrl.signal,
186
+ );
187
+ return credentialsFromTokenPayload(tokenPayload);
188
+ }
189
+ }
190
+
191
+ export async function loginXai(
192
+ ctrl: OAuthController,
193
+ opts?: { importLocal?: LocalTokenImportMode },
194
+ ): Promise<OAuthCredentials> {
195
+ const importLocal = opts?.importLocal ?? "off";
196
+ if (importLocal !== "off") {
197
+ const { detectGrokCliToken } = await import("./local-token-detect");
198
+ const local = detectGrokCliToken();
199
+ if (local) {
200
+ ctrl.onProgress?.("Found Grok CLI token, importing automatically");
201
+ if (local.expires >= Date.now() + 60_000) return local;
202
+ try {
203
+ return await refreshXaiToken(local.refresh, ctrl.signal);
204
+ } catch (error) {
205
+ if (importLocal === "only") {
206
+ throw new Error(
207
+ `Grok CLI token is expired and could not be refreshed: ${error instanceof Error ? error.message : String(error)}`,
208
+ );
209
+ }
210
+ }
211
+ } else if (importLocal === "only") {
212
+ throw new Error("No Grok CLI token found at ~/.grok/auth.json. Run 'ocx login xai' for browser OAuth.");
213
+ }
214
+ }
215
+
216
+ return new XaiOAuthFlow(ctrl).login();
217
+ }
218
+
219
+ export async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
220
+ if (!refreshToken) {
221
+ throw new Error("xAI credentials are expired and do not include a refresh token");
222
+ }
223
+ const discovery = await discoverXaiOAuthEndpoints(signal);
224
+ const tokenPayload = await postXaiToken(
225
+ discovery.tokenEndpoint,
226
+ {
227
+ grant_type: "refresh_token",
228
+ client_id: XAI_OAUTH_CLIENT_ID,
229
+ refresh_token: refreshToken,
230
+ },
231
+ signal,
232
+ );
233
+ return credentialsFromTokenPayload(tokenPayload, refreshToken);
234
+ }