feat(credentials): add CLI credentials reader for OAuth providers

Add support for reading OAuth credentials from external CLI tools:
- Claude Code: ~/.claude/.credentials.json or macOS Keychain
- Codex: ~/.codex/auth.json or macOS Keychain

Includes provider management with status checking and login instructions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-02 16:42:55 +08:00
parent ebcab2477a
commit 05138029de
3 changed files with 636 additions and 0 deletions

View file

@ -0,0 +1,363 @@
/**
* CLI Credentials Reader
*
* Read OAuth credentials from external CLI tools:
* - Claude Code: ~/.claude/.credentials.json or macOS Keychain
* - Codex: ~/.codex/auth.json or macOS Keychain
*
* Based on OpenClaw's implementation.
*/
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
// ============================================================
// Types
// ============================================================
export type OAuthCredential = {
type: "oauth";
provider: string;
access: string;
refresh: string;
expires: number;
};
export type TokenCredential = {
type: "token";
provider: string;
token: string;
expires: number;
};
export type ClaudeCliCredential = (OAuthCredential | TokenCredential) & {
provider: "anthropic";
};
export type CodexCliCredential = OAuthCredential & {
provider: "openai-codex";
accountId?: string;
};
// ============================================================
// Paths
// ============================================================
const CLAUDE_CLI_CREDENTIALS_PATH = ".claude/.credentials.json";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const CODEX_CLI_KEYCHAIN_SERVICE = "Codex Auth";
function resolveHomePath(relativePath: string): string {
const home = os.homedir();
return path.join(home, relativePath);
}
function resolveCodexHomePath(): string {
const configured = process.env.CODEX_HOME;
const home = configured ? configured.replace(/^~/, os.homedir()) : resolveHomePath(".codex");
try {
return fs.realpathSync(home);
} catch {
return home;
}
}
function computeCodexKeychainAccount(codexHome: string): string {
const hash = createHash("sha256").update(codexHome).digest("hex");
return `cli|${hash.slice(0, 16)}`;
}
// ============================================================
// Claude Code Credentials
// ============================================================
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
if (process.platform !== "darwin") return null;
try {
const result = execSync(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
} catch {
return null;
}
}
function readClaudeCliFileCredentials(): ClaudeCliCredential | null {
const credPath = resolveHomePath(CLAUDE_CLI_CREDENTIALS_PATH);
try {
if (!fs.existsSync(credPath)) return null;
const raw = JSON.parse(fs.readFileSync(credPath, "utf8"));
if (!raw || typeof raw !== "object") return null;
const claudeOauth = raw.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
} catch {
return null;
}
}
/**
* Read Claude Code CLI credentials.
* Priority: macOS Keychain > File (~/.claude/.credentials.json)
*/
export function readClaudeCliCredentials(): ClaudeCliCredential | null {
// Try keychain first (macOS only)
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) return keychainCreds;
// Fall back to file
return readClaudeCliFileCredentials();
}
/**
* Check if Claude Code credentials exist and are valid.
*/
export function hasValidClaudeCliCredentials(): boolean {
const creds = readClaudeCliCredentials();
if (!creds) return false;
// Check if not expired (with 5 minute buffer)
return creds.expires > Date.now() + 5 * 60 * 1000;
}
/**
* Get the access token from Claude Code credentials.
*/
export function getClaudeCliAccessToken(): string | null {
const creds = readClaudeCliCredentials();
if (!creds) return null;
if (creds.type === "oauth") return creds.access;
if (creds.type === "token") return creds.token;
return null;
}
// ============================================================
// Codex CLI Credentials
// ============================================================
function readCodexKeychainCredentials(): CodexCliCredential | null {
if (process.platform !== "darwin") return null;
const codexHome = resolveCodexHomePath();
const account = computeCodexKeychainAccount(codexHome);
try {
const secret = execSync(
`security find-generic-password -s "${CODEX_CLI_KEYCHAIN_SERVICE}" -a "${account}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
).trim();
const parsed = JSON.parse(secret);
const tokens = parsed.tokens;
const accessToken = tokens?.access_token;
const refreshToken = tokens?.refresh_token;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
const lastRefreshRaw = parsed.last_refresh;
const lastRefresh =
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
? new Date(lastRefreshRaw).getTime()
: Date.now();
const expires = Number.isFinite(lastRefresh)
? lastRefresh + 60 * 60 * 1000
: Date.now() + 60 * 60 * 1000;
return {
type: "oauth",
provider: "openai-codex",
access: accessToken,
refresh: refreshToken,
expires,
accountId: typeof tokens?.account_id === "string" ? tokens.account_id : undefined,
};
} catch {
return null;
}
}
function readCodexFileCredentials(): CodexCliCredential | null {
const authPath = path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME);
try {
if (!fs.existsSync(authPath)) return null;
const raw = JSON.parse(fs.readFileSync(authPath, "utf8"));
if (!raw || typeof raw !== "object") return null;
const tokens = raw.tokens;
if (!tokens || typeof tokens !== "object") return null;
const accessToken = tokens.access_token;
const refreshToken = tokens.refresh_token;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
let expires: number;
try {
const stat = fs.statSync(authPath);
expires = stat.mtimeMs + 60 * 60 * 1000;
} catch {
expires = Date.now() + 60 * 60 * 1000;
}
return {
type: "oauth",
provider: "openai-codex",
access: accessToken,
refresh: refreshToken,
expires,
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
};
} catch {
return null;
}
}
/**
* Read Codex CLI credentials.
* Priority: macOS Keychain > File (~/.codex/auth.json)
*/
export function readCodexCliCredentials(): CodexCliCredential | null {
// Try keychain first (macOS only)
const keychainCreds = readCodexKeychainCredentials();
if (keychainCreds) return keychainCreds;
// Fall back to file
return readCodexFileCredentials();
}
/**
* Check if Codex credentials exist and are valid.
*/
export function hasValidCodexCliCredentials(): boolean {
const creds = readCodexCliCredentials();
if (!creds) return false;
return creds.expires > Date.now() + 5 * 60 * 1000;
}
/**
* Get the access token from Codex credentials.
*/
export function getCodexCliAccessToken(): string | null {
const creds = readCodexCliCredentials();
if (!creds) return null;
return creds.access;
}
// ============================================================
// Unified Interface
// ============================================================
export type CliCredentialSource = "claude-code" | "codex";
export interface CliCredentialStatus {
source: CliCredentialSource;
available: boolean;
expires?: number;
expiresIn?: string;
}
/**
* Get status of all CLI credential sources.
*/
export function getCliCredentialStatus(): CliCredentialStatus[] {
const results: CliCredentialStatus[] = [];
// Claude Code
const claudeCreds = readClaudeCliCredentials();
if (claudeCreds) {
const expiresIn = claudeCreds.expires - Date.now();
results.push({
source: "claude-code",
available: expiresIn > 0,
expires: claudeCreds.expires,
expiresIn: formatDuration(expiresIn),
});
} else {
results.push({ source: "claude-code", available: false });
}
// Codex
const codexCreds = readCodexCliCredentials();
if (codexCreds) {
const expiresIn = codexCreds.expires - Date.now();
results.push({
source: "codex",
available: expiresIn > 0,
expires: codexCreds.expires,
expiresIn: formatDuration(expiresIn),
});
} else {
results.push({ source: "codex", available: false });
}
return results;
}
function formatDuration(ms: number): string {
if (ms <= 0) return "expired";
const hours = Math.floor(ms / (60 * 60 * 1000));
const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}

View file

@ -0,0 +1,2 @@
export * from "./cli-credentials.js";
export * from "./providers.js";

View file

@ -0,0 +1,271 @@
/**
* Provider Management
*
* Manage LLM providers with support for:
* - API Key authentication (traditional)
* - OAuth authentication (Claude Code, Codex)
*/
import { credentialManager } from "../credentials.js";
import {
readClaudeCliCredentials,
readCodexCliCredentials,
hasValidClaudeCliCredentials,
hasValidCodexCliCredentials,
type ClaudeCliCredential,
type CodexCliCredential,
} from "./cli-credentials.js";
// ============================================================
// Types
// ============================================================
export type AuthMethod = "api-key" | "oauth";
export interface ProviderInfo {
id: string;
name: string;
authMethod: AuthMethod;
available: boolean;
configured: boolean;
current: boolean;
models: string[];
loginUrl?: string;
loginCommand?: string;
}
export interface ProviderConfig {
provider: string;
model?: string;
apiKey?: string;
baseUrl?: string;
// OAuth specific
accessToken?: string;
refreshToken?: string;
expires?: number;
}
// ============================================================
// Provider Registry
// ============================================================
const PROVIDER_INFO: Record<string, Omit<ProviderInfo, "available" | "configured" | "current">> = {
"anthropic": {
id: "anthropic",
name: "Anthropic (API Key)",
authMethod: "api-key",
models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"],
loginUrl: "https://console.anthropic.com/",
},
"claude-code": {
id: "claude-code",
name: "Claude Code (OAuth)",
authMethod: "oauth",
models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
loginCommand: "claude login",
},
"openai": {
id: "openai",
name: "OpenAI",
authMethod: "api-key",
models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"],
loginUrl: "https://platform.openai.com/api-keys",
},
"openai-codex": {
id: "openai-codex",
name: "Codex (OAuth)",
authMethod: "oauth",
models: ["gpt-5.1", "gpt-5.1-codex-max"],
loginCommand: "codex login",
},
"kimi-coding": {
id: "kimi-coding",
name: "Kimi Code",
authMethod: "api-key",
models: ["kimi-k2-thinking", "k2p5"],
loginUrl: "https://kimi.moonshot.cn/",
},
"google": {
id: "google",
name: "Google AI",
authMethod: "api-key",
models: ["gemini-2.0-flash", "gemini-1.5-pro"],
loginUrl: "https://aistudio.google.com/apikey",
},
"groq": {
id: "groq",
name: "Groq",
authMethod: "api-key",
models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
loginUrl: "https://console.groq.com/keys",
},
"mistral": {
id: "mistral",
name: "Mistral",
authMethod: "api-key",
models: ["mistral-large-latest", "codestral-latest"],
loginUrl: "https://console.mistral.ai/api-keys",
},
"xai": {
id: "xai",
name: "xAI (Grok)",
authMethod: "api-key",
models: ["grok-beta", "grok-vision-beta"],
loginUrl: "https://console.x.ai/",
},
"openrouter": {
id: "openrouter",
name: "OpenRouter",
authMethod: "api-key",
models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"],
loginUrl: "https://openrouter.ai/keys",
},
};
// ============================================================
// Provider Status
// ============================================================
/**
* Check if a provider is configured with API key in credentials.json5
*/
function isApiKeyConfigured(providerId: string): boolean {
const config = credentialManager.getLlmProviderConfig(providerId);
return !!config?.apiKey;
}
/**
* Check if OAuth provider has valid credentials
*/
function isOAuthAvailable(providerId: string): boolean {
if (providerId === "claude-code") {
return hasValidClaudeCliCredentials();
}
if (providerId === "openai-codex") {
return hasValidCodexCliCredentials();
}
return false;
}
/**
* Get current provider from credentials
*/
export function getCurrentProvider(): string {
return credentialManager.getLlmProvider() ?? "kimi-coding";
}
/**
* Get list of all providers with their status
*/
export function getProviderList(): ProviderInfo[] {
const currentProvider = getCurrentProvider();
return Object.values(PROVIDER_INFO).map((info) => {
const isOAuth = info.authMethod === "oauth";
const available = isOAuth ? isOAuthAvailable(info.id) : isApiKeyConfigured(info.id);
const configured = isOAuth ? isOAuthAvailable(info.id) : isApiKeyConfigured(info.id);
// Check if this is the current provider
// For claude-code, check if current is "anthropic" and OAuth is available
let isCurrent = currentProvider === info.id;
if (info.id === "claude-code" && currentProvider === "anthropic") {
// If anthropic is current and claude-code OAuth is available, mark both
isCurrent = hasValidClaudeCliCredentials();
}
return {
...info,
available,
configured,
current: isCurrent,
};
});
}
/**
* Get available providers only
*/
export function getAvailableProviders(): ProviderInfo[] {
return getProviderList().filter((p) => p.available);
}
// ============================================================
// Provider Resolution
// ============================================================
/**
* Get provider config for making API calls
*/
export function resolveProviderConfig(providerId: string): ProviderConfig | null {
const info = PROVIDER_INFO[providerId];
if (!info) return null;
if (info.authMethod === "oauth") {
if (providerId === "claude-code") {
const creds = readClaudeCliCredentials();
if (!creds) return null;
const accessToken = creds.type === "oauth" ? creds.access : creds.token;
return {
provider: "anthropic", // Use anthropic API
apiKey: accessToken,
accessToken,
refreshToken: creds.type === "oauth" ? creds.refresh : undefined,
expires: creds.expires,
};
}
if (providerId === "openai-codex") {
const creds = readCodexCliCredentials();
if (!creds) return null;
return {
provider: "openai-codex",
accessToken: creds.access,
refreshToken: creds.refresh,
expires: creds.expires,
};
}
}
// API Key based
const config = credentialManager.getLlmProviderConfig(providerId);
if (!config?.apiKey) return null;
return {
provider: providerId,
model: config.model,
apiKey: config.apiKey,
baseUrl: config.baseUrl,
};
}
/**
* Format provider for display
*/
export function formatProviderStatus(provider: ProviderInfo): string {
const status = provider.available ? "✓" : "✗";
const current = provider.current ? " (current)" : "";
const auth = provider.authMethod === "oauth" ? " [OAuth]" : "";
return `${status} ${provider.name}${auth}${current}`;
}
/**
* Get login instructions for a provider
*/
export function getLoginInstructions(providerId: string): string {
const info = PROVIDER_INFO[providerId];
if (!info) return `Unknown provider: ${providerId}`;
if (info.authMethod === "oauth") {
if (info.loginCommand) {
return `Run: ${info.loginCommand}\nThen restart Super Multica to use the credentials.`;
}
}
if (info.loginUrl) {
return `Get your API key at: ${info.loginUrl}\nThen add it to ~/.super-multica/credentials.json5`;
}
return "No login instructions available.";
}