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:
parent
ebcab2477a
commit
05138029de
3 changed files with 636 additions and 0 deletions
363
src/agent/credentials/cli-credentials.ts
Normal file
363
src/agent/credentials/cli-credentials.ts
Normal 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`;
|
||||
}
|
||||
2
src/agent/credentials/index.ts
Normal file
2
src/agent/credentials/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./cli-credentials.js";
|
||||
export * from "./providers.js";
|
||||
271
src/agent/credentials/providers.ts
Normal file
271
src/agent/credentials/providers.ts
Normal 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.";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue