From 32dd62747d8f4952c442664be2843ac129bd4ce9 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 2 Feb 2026 15:17:17 +0800 Subject: [PATCH 01/18] fix(agent): soften tool errors --- src/agent/cli/output.test.ts | 8 +++++ src/agent/cli/output.ts | 19 +++++++++-- src/agent/tools.ts | 65 ++++++++++++++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/agent/cli/output.test.ts b/src/agent/cli/output.test.ts index 3a6f6030..f292dd0e 100644 --- a/src/agent/cli/output.test.ts +++ b/src/agent/cli/output.test.ts @@ -112,6 +112,14 @@ describe("output", () => { expect(extractResultDetails(result)).toEqual(result); }); + it("should prefer details when present", () => { + const result = { + content: [{ type: "text", text: "not json" }], + details: { count: 3, truncated: false }, + }; + expect(extractResultDetails(result)).toEqual({ count: 3, truncated: false }); + }); + it("should return direct object if no content array", () => { const result = { count: 10, truncated: true }; expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true }); diff --git a/src/agent/cli/output.ts b/src/agent/cli/output.ts index 3c6c9835..f7b2437d 100644 --- a/src/agent/cli/output.ts +++ b/src/agent/cli/output.ts @@ -118,6 +118,11 @@ export function extractResultDetails(result: unknown): Record | } } + const withDetails = result as { details?: unknown }; + if (withDetails.details && typeof withDetails.details === "object") { + return withDetails.details as Record; + } + // Try direct object access return result as Record; } @@ -252,8 +257,18 @@ export function createAgentOutput(params: { } case "tool_execution_end": { // Stop spinner and show final result with summary - if (event.isError) { - const errorText = extractText(event.result) || "Tool failed"; + const details = extractResultDetails(event.result); + const errorField = details?.error; + const hasError = + event.isError || + Boolean(errorField) || + details?.success === false; + if (hasError) { + const errorText = + (typeof details?.message === "string" && details.message) || + (typeof errorField === "string" && errorField) || + extractText(event.result) || + "Tool failed"; const bullet = colors.toolError("โœ—"); const title = colors.toolName(toolDisplayName(event.toolName)); spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 6578a238..967a2d54 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,13 +1,14 @@ import type { AgentOptions } from "./types.js"; import { getModel } from "@mariozechner/pi-ai"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; import { createMemoryTools } from "./tools/memory/index.js"; import { filterTools } from "./tools/policy.js"; +import { isMulticaError, isRetryableError } from "../shared/errors.js"; export function resolveModel(options: AgentOptions) { if (options.provider && options.model) { @@ -29,6 +30,66 @@ export interface CreateToolsOptions { profileBaseDir?: string; } +type ToolErrorPayload = { + error: true; + message: string; + name?: string; + code?: string; + retryable?: boolean; + details?: Record; +}; + +function toToolErrorPayload(error: unknown): ToolErrorPayload { + if (isMulticaError(error)) { + return { + error: true, + message: error.message, + name: error.name, + code: error.code, + retryable: error.retryable, + details: error.details, + }; + } + + if (error instanceof Error) { + return { + error: true, + message: error.message, + name: error.name, + retryable: isRetryableError(error), + }; + } + + return { + error: true, + message: String(error), + }; +} + +function toolErrorResult(error: unknown): AgentToolResult { + const payload = toToolErrorPayload(error); + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + }; +} + +function wrapTool( + tool: AgentTool, +): AgentTool { + const execute = tool.execute; + return { + ...tool, + execute: async (...args) => { + try { + return await execute(...args); + } catch (error) { + return toolErrorResult(error) as AgentToolResult; + } + }, + }; +} + /** * Create all available tools. * This returns the full set before policy filtering. @@ -95,7 +156,7 @@ export function resolveTools(options: AgentOptions): AgentTool[] { isSubagent: options.isSubagent, }); - return filtered; + return filtered.map((tool) => wrapTool(tool)); } /** From 05138029de420bcf631e580cf450ccd09fa8e6a7 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:42:55 +0800 Subject: [PATCH 02/18] 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 --- src/agent/credentials/cli-credentials.ts | 363 +++++++++++++++++++++++ src/agent/credentials/index.ts | 2 + src/agent/credentials/providers.ts | 271 +++++++++++++++++ 3 files changed, 636 insertions(+) create mode 100644 src/agent/credentials/cli-credentials.ts create mode 100644 src/agent/credentials/index.ts create mode 100644 src/agent/credentials/providers.ts diff --git a/src/agent/credentials/cli-credentials.ts b/src/agent/credentials/cli-credentials.ts new file mode 100644 index 00000000..2d1da4e0 --- /dev/null +++ b/src/agent/credentials/cli-credentials.ts @@ -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`; +} diff --git a/src/agent/credentials/index.ts b/src/agent/credentials/index.ts new file mode 100644 index 00000000..9d740a39 --- /dev/null +++ b/src/agent/credentials/index.ts @@ -0,0 +1,2 @@ +export * from "./cli-credentials.js"; +export * from "./providers.js"; diff --git a/src/agent/credentials/providers.ts b/src/agent/credentials/providers.ts new file mode 100644 index 00000000..7a1d80a5 --- /dev/null +++ b/src/agent/credentials/providers.ts @@ -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> = { + "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."; +} From b9ce4da28f9d5b5766a3043084091f426b1ff649 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:43:00 +0800 Subject: [PATCH 03/18] feat(runner): support OAuth providers (claude-code, openai-codex) - Add provider alias mapping (claude-code -> anthropic) - Resolve API key from OAuth credentials for claude-code and codex - Add default models for each provider Co-Authored-By: Claude Opus 4.5 --- src/agent/runner.ts | 17 ++++++++++++++++- src/agent/tools.ts | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index bf73268f..368d0437 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -7,6 +7,7 @@ import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; import { credentialManager, getCredentialsPath } from "./credentials.js"; +import { resolveProviderConfig } from "./credentials/providers.js"; import { checkContextWindow, DEFAULT_CONTEXT_TOKENS, @@ -16,10 +17,24 @@ import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; /** * Get API Key based on provider. - * Priority: explicit key > provider-specific env var > generic env var format. + * Priority: explicit key > OAuth credentials > credentials.json5 config. + * + * Supports OAuth providers like "claude-code" and "openai-codex" by + * reading credentials from their respective CLI tools. */ function resolveApiKey(provider: string, explicitKey?: string): string | undefined { if (explicitKey) return explicitKey; + + // Try OAuth providers first (claude-code, openai-codex) + const providerConfig = resolveProviderConfig(provider); + if (providerConfig?.apiKey) { + return providerConfig.apiKey; + } + if (providerConfig?.accessToken) { + return providerConfig.accessToken; + } + + // Fall back to credentials.json5 return credentialManager.getLlmProviderConfig(provider)?.apiKey; } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 6578a238..8cec06b3 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -9,14 +9,52 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; import { createMemoryTools } from "./tools/memory/index.js"; import { filterTools } from "./tools/policy.js"; +/** + * Provider alias mapping for OAuth providers. + * Maps friendly names to actual pi-ai provider names. + */ +const PROVIDER_ALIAS: Record = { + "claude-code": "anthropic", // Claude Code OAuth uses anthropic API +}; + +/** + * Default models for each provider. + */ +const DEFAULT_MODELS: Record = { + "anthropic": "claude-sonnet-4-20250514", + "claude-code": "claude-sonnet-4-20250514", + "openai": "gpt-4o", + "openai-codex": "gpt-5.1", + "kimi-coding": "kimi-k2-thinking", + "google": "gemini-2.0-flash", + "groq": "llama-3.3-70b-versatile", + "mistral": "mistral-large-latest", +}; + export function resolveModel(options: AgentOptions) { if (options.provider && options.model) { + // Map provider alias (e.g., claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + // Type assertion needed because provider/model come from dynamic user config return (getModel as (p: string, m: string) => ReturnType)( - options.provider, + actualProvider, options.model, ); } + + // If only provider specified, use default model for that provider + if (options.provider) { + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + const defaultModel = DEFAULT_MODELS[options.provider] ?? DEFAULT_MODELS[actualProvider]; + if (defaultModel) { + return (getModel as (p: string, m: string) => ReturnType)( + actualProvider, + defaultModel, + ); + } + } + return getModel("kimi-coding", "kimi-k2-thinking"); } From 9504eeab629929e5d607372af2f63a18b72163d5 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:43:05 +0800 Subject: [PATCH 04/18] feat(cli): add /provider command to show and switch providers The /provider command shows: - Current provider and model - List of available providers (OAuth and API Key) - Status of each provider (configured/not configured) - Instructions for switching providers Co-Authored-By: Claude Opus 4.5 --- src/agent/cli/commands/chat.ts | 64 +++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index b9eaf7d8..e50c159d 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -11,7 +11,13 @@ import { Agent } from "../../runner.js"; import type { AgentOptions } from "../../types.js"; import { SkillManager } from "../../skills/index.js"; import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js"; -import { colors, dim, cyan, brightCyan, yellow, green, gray } from "../colors.js"; +import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js"; +import { + getProviderList, + getCurrentProvider, + getLoginInstructions, + type ProviderInfo, +} from "../../credentials/providers.js"; type ChatOptions = { profile?: string; @@ -31,6 +37,7 @@ const COMMANDS = { session: "Show current session ID", new: "Start a new session", multiline: "Toggle multi-line input mode (end with a line containing only '.')", + provider: "Show current provider and available options", }; function printHelp() { @@ -455,6 +462,10 @@ class InteractiveCLI { } return true; + case "provider": + this.showProviderStatus(); + return true; + default: const invocation = this.skillManager.resolveCommand(input); if (invocation) { @@ -468,6 +479,57 @@ class InteractiveCLI { } } + private showProviderStatus() { + const providers = getProviderList(); + const currentProvider = this.opts.provider ?? getCurrentProvider(); + + console.log(`\n${cyan("๐Ÿ”Œ Provider Status")}\n`); + console.log(`${dim("Current:")} ${green(currentProvider)}`); + if (this.opts.model) { + console.log(`${dim("Model:")} ${yellow(this.opts.model)}`); + } + + console.log(`\n${dim("Available Providers:")}`); + + // Group by auth method + const apiKeyProviders = providers.filter(p => p.authMethod === "api-key"); + const oauthProviders = providers.filter(p => p.authMethod === "oauth"); + + // OAuth providers first (more interesting) + for (const p of oauthProviders) { + const status = p.available ? green("โœ“") : red("โœ—"); + const current = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available) ? yellow(" (current)") : ""; + const authLabel = cyan("[OAuth]"); + const statusLabel = p.available ? green("ready") : dim("not logged in"); + console.log(` ${status} ${p.name.padEnd(20)} ${authLabel.padEnd(18)} ${statusLabel}${current}`); + } + + // API Key providers + for (const p of apiKeyProviders) { + const status = p.available ? green("โœ“") : red("โœ—"); + const current = p.id === currentProvider ? yellow(" (current)") : ""; + const authLabel = dim("[API Key]"); + const statusLabel = p.available ? green("configured") : dim("not configured"); + console.log(` ${status} ${p.name.padEnd(20)} ${authLabel.padEnd(18)} ${statusLabel}${current}`); + } + + console.log(`\n${dim("To switch provider:")}`); + console.log(` ${dim("โ€ข")} ${cyan("OAuth:")} Run login command (e.g., ${yellow("claude login")}), then restart`); + console.log(` ${dim("โ€ข")} ${cyan("API Key:")} Add to ${yellow("~/.super-multica/credentials.json5")}`); + console.log(` ${dim("โ€ข")} ${cyan("Session:")} Use ${yellow("--provider ")} flag when starting chat`); + + // If user hasn't logged into Claude Code, show instructions + const claudeCode = providers.find(p => p.id === "claude-code"); + if (claudeCode && !claudeCode.available) { + console.log(`\n${cyan("๐Ÿ’ก Tip:")} To use Claude Code (free with Claude subscription):`); + console.log(` 1. Install Claude Code: ${yellow("npm install -g @anthropic-ai/claude-code")}`); + console.log(` 2. Login: ${yellow("claude login")}`); + console.log(` 3. Restart multica with: ${yellow("multica chat --provider claude-code")}`); + } + + console.log(""); + } + private async handleInput(input: string) { try { console.log(""); From 307b381e6cafa826551794e8fae597b88bf7943e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:43:10 +0800 Subject: [PATCH 05/18] feat(skills): add provider skill for LLM provider management Provides instructions for viewing and switching LLM providers, including OAuth (Claude Code, Codex) and API Key based providers. Co-Authored-By: Claude Opus 4.5 --- skills/provider/SKILL.md | 116 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 skills/provider/SKILL.md diff --git a/skills/provider/SKILL.md b/skills/provider/SKILL.md new file mode 100644 index 00000000..5d90fbfe --- /dev/null +++ b/skills/provider/SKILL.md @@ -0,0 +1,116 @@ +--- +name: Provider Manager +description: View and switch LLM providers (Claude Code, Kimi, OpenAI, etc.) +version: 1.0.0 +metadata: + emoji: "๐Ÿ”Œ" + tags: + - provider + - settings + - oauth +--- + +## Instructions + +When the user invokes `/provider`, help them manage their LLM provider settings. + +### Available Providers + +Display the current provider status using this format: + +``` +Current Provider: [provider-name] + +Available Providers: +โœ“ Kimi Code [API Key] (current) +โœ“ Claude Code [OAuth] - logged in +โœ— Anthropic [API Key] - not configured +โœ— OpenAI [API Key] - not configured +โœ— Codex [OAuth] - not logged in +... +``` + +### Provider Types + +**API Key Providers** (require manual configuration): +- `anthropic` - Anthropic (console.anthropic.com) +- `openai` - OpenAI (platform.openai.com) +- `kimi-coding` - Kimi Code (kimi.moonshot.cn) +- `google` - Google AI (aistudio.google.com) +- `groq` - Groq (console.groq.com) +- `mistral` - Mistral (console.mistral.ai) +- `xai` - xAI/Grok (console.x.ai) +- `openrouter` - OpenRouter (openrouter.ai) + +**OAuth Providers** (login via CLI): +- `claude-code` - Uses credentials from Claude Code CLI (`claude login`) +- `openai-codex` - Uses credentials from Codex CLI (`codex login`) + +### Switching Providers + +When user wants to switch provider: + +1. **For OAuth providers** (claude-code, openai-codex): + - Check if credentials exist in `~/.claude/.credentials.json` or `~/.codex/auth.json` + - If not logged in, instruct user to run the appropriate login command: + - Claude Code: `claude login` + - Codex: `codex login` + - After login, user needs to restart the session + +2. **For API Key providers**: + - Check if API key is configured in `~/.super-multica/credentials.json5` + - If not configured, show the URL where they can get an API key + - Instruct them to add the key to credentials.json5 + +### Example Session + +User: `/provider` + +Response: +``` +๐Ÿ”Œ Provider Status + +Current: kimi-coding (Kimi Code) +Model: kimi-k2-thinking + +Available Providers: +โœ“ kimi-coding Kimi Code [API Key] (current) +โœ“ claude-code Claude Code [OAuth] ready +โœ— anthropic Anthropic [API Key] not configured +โœ— openai OpenAI [API Key] not configured +โœ— openai-codex Codex [OAuth] not logged in + +To switch provider: +- OAuth: Run login command, then restart session +- API Key: Add key to ~/.super-multica/credentials.json5 + +Which provider would you like to use? +``` + +User: "Switch to Claude Code" + +Response: +``` +Switching to Claude Code... + +โœ“ Found valid OAuth credentials from Claude Code CLI + Expires in: 23h 45m + +To use Claude Code as your default provider, update ~/.super-multica/credentials.json5: + +{ + llm: { + provider: "claude-code", + // No API key needed - uses OAuth from Claude Code CLI + } +} + +Or restart with: multica chat --provider claude-code +``` + +### Important Notes + +- OAuth credentials are read-only (we read from Claude Code/Codex, don't write) +- Session provider can be changed with `--provider` flag +- Default provider is set in `~/.super-multica/credentials.json5` +- OAuth tokens may expire and require re-login From da11e65d35305c799753de96cb0ca6fdedf281b3 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:52:04 +0800 Subject: [PATCH 06/18] refactor(oauth): rename credentials/ to oauth/ for clarity Separate OAuth credential reading (external CLI tools) from the main CredentialManager (API Key configuration in credentials.json5). - credentials.ts: API Key configuration management - oauth/: External OAuth credential reading (Claude Code, Codex) Co-Authored-By: Claude Opus 4.5 --- src/agent/cli/commands/chat.ts | 2 +- src/agent/{credentials => oauth}/cli-credentials.ts | 0 src/agent/{credentials => oauth}/index.ts | 0 src/agent/{credentials => oauth}/providers.ts | 12 ++++++------ src/agent/runner.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/agent/{credentials => oauth}/cli-credentials.ts (100%) rename src/agent/{credentials => oauth}/index.ts (100%) rename src/agent/{credentials => oauth}/providers.ts (97%) diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index e50c159d..feae83f5 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -17,7 +17,7 @@ import { getCurrentProvider, getLoginInstructions, type ProviderInfo, -} from "../../credentials/providers.js"; +} from "../../oauth/providers.js"; type ChatOptions = { profile?: string; diff --git a/src/agent/credentials/cli-credentials.ts b/src/agent/oauth/cli-credentials.ts similarity index 100% rename from src/agent/credentials/cli-credentials.ts rename to src/agent/oauth/cli-credentials.ts diff --git a/src/agent/credentials/index.ts b/src/agent/oauth/index.ts similarity index 100% rename from src/agent/credentials/index.ts rename to src/agent/oauth/index.ts diff --git a/src/agent/credentials/providers.ts b/src/agent/oauth/providers.ts similarity index 97% rename from src/agent/credentials/providers.ts rename to src/agent/oauth/providers.ts index 7a1d80a5..737c5439 100644 --- a/src/agent/credentials/providers.ts +++ b/src/agent/oauth/providers.ts @@ -36,13 +36,13 @@ export interface ProviderInfo { export interface ProviderConfig { provider: string; - model?: string; - apiKey?: string; - baseUrl?: string; + model?: string | undefined; + apiKey?: string | undefined; + baseUrl?: string | undefined; // OAuth specific - accessToken?: string; - refreshToken?: string; - expires?: number; + accessToken?: string | undefined; + refreshToken?: string | undefined; + expires?: number | undefined; } // ============================================================ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 368d0437..0fcc7543 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -7,7 +7,7 @@ import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; import { credentialManager, getCredentialsPath } from "./credentials.js"; -import { resolveProviderConfig } from "./credentials/providers.js"; +import { resolveProviderConfig } from "./oauth/providers.js"; import { checkContextWindow, DEFAULT_CONTEXT_TOKENS, From 9b8aebdf76f72dacc777eab799ed524f4e1308eb Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:54:42 +0800 Subject: [PATCH 07/18] chore(skills): remove redundant provider skill The /provider command is now a built-in CLI command in chat.ts, which directly calls oauth/providers.ts for accurate status. The skill was redundant and could produce inconsistent results since it relied on LLM reading files instead of using the API. Co-Authored-By: Claude Opus 4.5 --- skills/provider/SKILL.md | 116 --------------------------------------- 1 file changed, 116 deletions(-) delete mode 100644 skills/provider/SKILL.md diff --git a/skills/provider/SKILL.md b/skills/provider/SKILL.md deleted file mode 100644 index 5d90fbfe..00000000 --- a/skills/provider/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: Provider Manager -description: View and switch LLM providers (Claude Code, Kimi, OpenAI, etc.) -version: 1.0.0 -metadata: - emoji: "๐Ÿ”Œ" - tags: - - provider - - settings - - oauth ---- - -## Instructions - -When the user invokes `/provider`, help them manage their LLM provider settings. - -### Available Providers - -Display the current provider status using this format: - -``` -Current Provider: [provider-name] - -Available Providers: -โœ“ Kimi Code [API Key] (current) -โœ“ Claude Code [OAuth] - logged in -โœ— Anthropic [API Key] - not configured -โœ— OpenAI [API Key] - not configured -โœ— Codex [OAuth] - not logged in -... -``` - -### Provider Types - -**API Key Providers** (require manual configuration): -- `anthropic` - Anthropic (console.anthropic.com) -- `openai` - OpenAI (platform.openai.com) -- `kimi-coding` - Kimi Code (kimi.moonshot.cn) -- `google` - Google AI (aistudio.google.com) -- `groq` - Groq (console.groq.com) -- `mistral` - Mistral (console.mistral.ai) -- `xai` - xAI/Grok (console.x.ai) -- `openrouter` - OpenRouter (openrouter.ai) - -**OAuth Providers** (login via CLI): -- `claude-code` - Uses credentials from Claude Code CLI (`claude login`) -- `openai-codex` - Uses credentials from Codex CLI (`codex login`) - -### Switching Providers - -When user wants to switch provider: - -1. **For OAuth providers** (claude-code, openai-codex): - - Check if credentials exist in `~/.claude/.credentials.json` or `~/.codex/auth.json` - - If not logged in, instruct user to run the appropriate login command: - - Claude Code: `claude login` - - Codex: `codex login` - - After login, user needs to restart the session - -2. **For API Key providers**: - - Check if API key is configured in `~/.super-multica/credentials.json5` - - If not configured, show the URL where they can get an API key - - Instruct them to add the key to credentials.json5 - -### Example Session - -User: `/provider` - -Response: -``` -๐Ÿ”Œ Provider Status - -Current: kimi-coding (Kimi Code) -Model: kimi-k2-thinking - -Available Providers: -โœ“ kimi-coding Kimi Code [API Key] (current) -โœ“ claude-code Claude Code [OAuth] ready -โœ— anthropic Anthropic [API Key] not configured -โœ— openai OpenAI [API Key] not configured -โœ— openai-codex Codex [OAuth] not logged in - -To switch provider: -- OAuth: Run login command, then restart session -- API Key: Add key to ~/.super-multica/credentials.json5 - -Which provider would you like to use? -``` - -User: "Switch to Claude Code" - -Response: -``` -Switching to Claude Code... - -โœ“ Found valid OAuth credentials from Claude Code CLI - Expires in: 23h 45m - -To use Claude Code as your default provider, update ~/.super-multica/credentials.json5: - -{ - llm: { - provider: "claude-code", - // No API key needed - uses OAuth from Claude Code CLI - } -} - -Or restart with: multica chat --provider claude-code -``` - -### Important Notes - -- OAuth credentials are read-only (we read from Claude Code/Codex, don't write) -- Session provider can be changed with `--provider` flag -- Default provider is set in `~/.super-multica/credentials.json5` -- OAuth tokens may expire and require re-login From 051e56dbf6023f9e7caa88300e5c0a272256c95a Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:10:05 +0800 Subject: [PATCH 08/18] feat(cli): improve /provider command display with ID column - Add ID column to show actual provider keys for --provider flag - Add usage examples with actual command syntax - Improve visual formatting with table headers --- src/agent/cli/commands/chat.ts | 35 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index feae83f5..244c6e94 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -490,6 +490,8 @@ class InteractiveCLI { } console.log(`\n${dim("Available Providers:")}`); + console.log(` ${dim("ID".padEnd(16))} ${dim("Name".padEnd(20))} ${dim("Auth".padEnd(12))} ${dim("Status")}`); + console.log(` ${dim("โ”€".repeat(70))}`); // Group by auth method const apiKeyProviders = providers.filter(p => p.authMethod === "api-key"); @@ -498,33 +500,40 @@ class InteractiveCLI { // OAuth providers first (more interesting) for (const p of oauthProviders) { const status = p.available ? green("โœ“") : red("โœ—"); - const current = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available) ? yellow(" (current)") : ""; - const authLabel = cyan("[OAuth]"); + const isCurrent = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available); + const current = isCurrent ? yellow(" (current)") : ""; + const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16); + const authLabel = cyan("OAuth"); const statusLabel = p.available ? green("ready") : dim("not logged in"); - console.log(` ${status} ${p.name.padEnd(20)} ${authLabel.padEnd(18)} ${statusLabel}${current}`); + console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`); } // API Key providers for (const p of apiKeyProviders) { const status = p.available ? green("โœ“") : red("โœ—"); - const current = p.id === currentProvider ? yellow(" (current)") : ""; - const authLabel = dim("[API Key]"); + const isCurrent = p.id === currentProvider; + const current = isCurrent ? yellow(" (current)") : ""; + const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16); + const authLabel = dim("API Key"); const statusLabel = p.available ? green("configured") : dim("not configured"); - console.log(` ${status} ${p.name.padEnd(20)} ${authLabel.padEnd(18)} ${statusLabel}${current}`); + console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`); } - console.log(`\n${dim("To switch provider:")}`); - console.log(` ${dim("โ€ข")} ${cyan("OAuth:")} Run login command (e.g., ${yellow("claude login")}), then restart`); - console.log(` ${dim("โ€ข")} ${cyan("API Key:")} Add to ${yellow("~/.super-multica/credentials.json5")}`); - console.log(` ${dim("โ€ข")} ${cyan("Session:")} Use ${yellow("--provider ")} flag when starting chat`); + console.log(`\n${dim("Usage:")}`); + console.log(` ${yellow("multica --provider ")} ${dim("Start chat with specific provider")}`); + console.log(` ${yellow("multica --provider --model ")} ${dim("Specify model too")}`); + + console.log(`\n${dim("Examples:")}`); + console.log(` ${yellow("multica --provider claude-code")} ${dim("Use Claude Code OAuth")}`); + console.log(` ${yellow("multica --provider openai")} ${dim("Use OpenAI with API Key")}`); // If user hasn't logged into Claude Code, show instructions const claudeCode = providers.find(p => p.id === "claude-code"); if (claudeCode && !claudeCode.available) { console.log(`\n${cyan("๐Ÿ’ก Tip:")} To use Claude Code (free with Claude subscription):`); - console.log(` 1. Install Claude Code: ${yellow("npm install -g @anthropic-ai/claude-code")}`); - console.log(` 2. Login: ${yellow("claude login")}`); - console.log(` 3. Restart multica with: ${yellow("multica chat --provider claude-code")}`); + console.log(` 1. Install: ${yellow("npm install -g @anthropic-ai/claude-code")}`); + console.log(` 2. Login: ${yellow("claude login")}`); + console.log(` 3. Use: ${yellow("multica --provider claude-code")}`); } console.log(""); From 35b13f976ddcf2fd31bc7b603ef7d50e58f81631 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:10:10 +0800 Subject: [PATCH 09/18] feat(runner): validate provider credentials on startup - Add isOAuthProvider() and isProviderAvailable() helpers - Show login instructions for OAuth providers without credentials - Show configuration example for API Key providers without keys - Fail early with helpful error message instead of cryptic API errors --- src/agent/oauth/providers.ts | 21 +++++++++++++++++++++ src/agent/runner.ts | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/agent/oauth/providers.ts b/src/agent/oauth/providers.ts index 737c5439..53363cb7 100644 --- a/src/agent/oauth/providers.ts +++ b/src/agent/oauth/providers.ts @@ -269,3 +269,24 @@ export function getLoginInstructions(providerId: string): string { return "No login instructions available."; } + +/** + * Check if a provider uses OAuth authentication + */ +export function isOAuthProvider(providerId: string): boolean { + const info = PROVIDER_INFO[providerId]; + return info?.authMethod === "oauth"; +} + +/** + * Check if provider is available (has valid credentials) + */ +export function isProviderAvailable(providerId: string): boolean { + const info = PROVIDER_INFO[providerId]; + if (!info) return false; + + if (info.authMethod === "oauth") { + return isOAuthAvailable(providerId); + } + return isApiKeyConfigured(providerId); +} diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 0fcc7543..e6478849 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -7,7 +7,7 @@ import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; import { credentialManager, getCredentialsPath } from "./credentials.js"; -import { resolveProviderConfig } from "./oauth/providers.js"; +import { resolveProviderConfig, isOAuthProvider, getLoginInstructions } from "./oauth/providers.js"; import { checkContextWindow, DEFAULT_CONTEXT_TOKENS, @@ -79,10 +79,37 @@ export class Agent { const resolvedModel = resolveModelId(resolvedProvider, options.model); const apiKey = resolveApiKey(resolvedProvider, options.apiKey); + // Validate credentials before proceeding + if (!apiKey) { + if (isOAuthProvider(resolvedProvider)) { + // OAuth provider without valid credentials - show login instructions + const instructions = getLoginInstructions(resolvedProvider); + throw new Error( + `Provider "${resolvedProvider}" requires authentication.\n\n` + + `${instructions}\n\n` + + `After logging in, run: multica --provider ${resolvedProvider}`, + ); + } + // API Key provider without key - show configuration instructions + throw new Error( + `Provider "${resolvedProvider}" requires an API key.\n\n` + + `Add your API key to: ${getCredentialsPath()}\n\n` + + `Example:\n` + + `{\n` + + ` "llm": {\n` + + ` "provider": "${resolvedProvider}",\n` + + ` "providers": {\n` + + ` "${resolvedProvider}": {\n` + + ` "apiKey": "your-api-key-here"\n` + + ` }\n` + + ` }\n` + + ` }\n` + + `}`, + ); + } + this.agent = new PiAgentCore( - apiKey - ? { getApiKey: (_provider: string) => apiKey } - : {}, + { getApiKey: (_provider: string) => apiKey }, ); // Load Agent Profile (if profileId is specified) From 5952f22ca21e5c4e876e571af24d85e6f2cbaf6f Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:10:14 +0800 Subject: [PATCH 10/18] docs: add LLM providers documentation - Document OAuth providers (claude-code, openai-codex) - Document API Key providers - Add /provider command usage example - Add OAuth login instructions --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README.md b/README.md index e25d1936..5aee5c5e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,63 @@ Optional overrides: - `SMC_CREDENTIALS_PATH` โ€” custom path for `credentials.json5` - `SMC_SKILLS_ENV_PATH` โ€” custom path for `skills.env.json5` +### LLM Providers + +Super Multica supports multiple LLM providers with two authentication methods: + +**OAuth Providers** (use external CLI login): +- `claude-code` โ€” Claude Code OAuth (requires `claude login`) +- `openai-codex` โ€” OpenAI Codex OAuth (requires `codex login`) + +**API Key Providers** (configure in `credentials.json5`): +- `anthropic`, `openai`, `kimi-coding`, `google`, `groq`, `mistral`, `xai`, `openrouter` + +#### Check Provider Status + +```bash +# In interactive mode +/provider + +# Output shows all providers with status +๐Ÿ”Œ Provider Status + +Current: kimi-coding + +Available Providers: + ID Name Auth Status + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + โœ“ claude-code Claude Code (OAuth) OAuth ready + โœ— openai-codex Codex (OAuth) OAuth not logged in + โœ“ kimi-coding Kimi Code API Key configured (current) + ... +``` + +#### Using OAuth Providers + +```bash +# 1. Install and login to Claude Code +npm install -g @anthropic-ai/claude-code +claude login + +# 2. Start multica with claude-code provider +multica --provider claude-code +``` + +#### Using API Key Providers + +Add your API key to `~/.super-multica/credentials.json5`: + +```json5 +{ + llm: { + provider: "openai", + providers: { + openai: { apiKey: "sk-xxx" } + } + } +} +``` + ### Configuration Priority Each setting is resolved in order (first match wins): From d04bed81754c099c14f62d5453458cc9fdd0db67 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:18:10 +0800 Subject: [PATCH 11/18] feat(agent): add streaming support for AI message generation AsyncAgent now subscribes to pi-agent-core events (message_start, message_update, message_end) and forwards incremental text deltas through a stream callback. Hub registers the callback and sends stream payloads to the requesting client via Gateway. Co-Authored-By: Claude Opus 4.5 --- packages/sdk/src/actions/index.ts | 2 +- packages/sdk/src/actions/stream.ts | 17 ++++++-- src/agent/async-agent.ts | 69 +++++++++++++++++++++++++++--- src/agent/cli/output.ts | 11 +---- src/agent/extract-text.ts | 12 ++++++ src/agent/runner.ts | 5 +++ src/hub/hub.ts | 11 ++++- 7 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 src/agent/extract-text.ts diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index d39cc5b0..2f6ab2e3 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -27,4 +27,4 @@ export { type UpdateGatewayResult, } from "./rpc"; -export { StreamAction, type StreamPayload } from "./stream"; +export { StreamAction, type StreamState, type StreamPayload } from "./stream"; diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index 98f52423..da8dae31 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -2,10 +2,19 @@ export const StreamAction = "stream" as const; +/** ๆตๆถˆๆฏ็Šถๆ€ */ +export type StreamState = "delta" | "final" | "error"; + /** ๆตๆถˆๆฏ payload */ -export interface StreamPayload { - /** ๆต ID๏ผŒ็”จไบŽๅ…ณ่”ๅŒไธ€ไธชๆต็š„ๆ‰€ๆœ‰ๆถˆๆฏ */ +export interface StreamPayload { + /** ๆต ID๏ผˆๅณ messageId๏ผ‰๏ผŒๅ…ณ่”ๅŒไธ€ไธชๆต็š„ๆ‰€ๆœ‰ๆถˆๆฏ */ streamId: string; - /** ๆ•ฐๆฎ */ - data: T; + /** ๆ‰€ๅฑž agent ID */ + agentId: string; + /** ๆต็Šถๆ€ */ + state: StreamState; + /** ็ดฏ่ฎกๆ–‡ๆœฌๅ†…ๅฎน๏ผˆdelta/final ๆ—ถ๏ผ‰ */ + content?: string; + /** ้”™่ฏฏไฟกๆฏ๏ผˆerror ๆ—ถ๏ผ‰ */ + error?: string; } diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 06f753bb..d6443b8b 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -1,7 +1,9 @@ import { v7 as uuidv7 } from "uuid"; import { Agent } from "./runner.js"; import { Channel } from "./channel.js"; +import { extractText } from "./extract-text.js"; import type { AgentOptions, Message } from "./types.js"; +import type { StreamPayload } from "@multica/sdk"; const devNull = { write: () => true } as NodeJS.WritableStream; @@ -10,6 +12,7 @@ export class AsyncAgent { private readonly channel = new Channel(); private _closed = false; private queue: Promise = Promise.resolve(); + private streamCallback?: (payload: StreamPayload) => void; readonly sessionId: string; constructor(options?: AgentOptions) { @@ -18,12 +21,18 @@ export class AsyncAgent { logger: { stdout: devNull, stderr: devNull }, }); this.sessionId = this.agent.sessionId; + this.setupStreamEvents(); } get closed(): boolean { return this._closed; } + /** Register callback for streaming events */ + onStream(cb: (payload: StreamPayload) => void): void { + this.streamCallback = cb; + } + /** Write message to agent (non-blocking, serialized queue) */ write(content: string): void { if (this._closed) throw new Error("Agent is closed"); @@ -32,11 +41,15 @@ export class AsyncAgent { .then(async () => { if (this._closed) return; const result = await this.agent.run(content); - if (result.text) { - this.channel.send({ id: uuidv7(), content: result.text }); - } - if (result.error) { - this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); + // Only send final message via channel if no stream callback + // (stream callback already sent the final content) + if (!this.streamCallback) { + if (result.text) { + this.channel.send({ id: uuidv7(), content: result.text }); + } + if (result.error) { + this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); + } } }) .catch((err) => { @@ -56,4 +69,50 @@ export class AsyncAgent { this._closed = true; this.channel.close(); } + + private setupStreamEvents(): void { + let currentStreamId: string | null = null; + + this.agent.subscribe((event) => { + if (!this.streamCallback) return; + + switch (event.type) { + case "message_start": { + if (event.message.role === "assistant") { + currentStreamId = uuidv7(); + this.streamCallback({ + streamId: currentStreamId, + agentId: this.sessionId, + state: "delta", + content: extractText(event.message), + }); + } + break; + } + case "message_update": { + if (event.message.role === "assistant" && currentStreamId) { + this.streamCallback({ + streamId: currentStreamId, + agentId: this.sessionId, + state: "delta", + content: extractText(event.message), + }); + } + break; + } + case "message_end": { + if (event.message.role === "assistant" && currentStreamId) { + this.streamCallback({ + streamId: currentStreamId, + agentId: this.sessionId, + state: "final", + content: extractText(event.message), + }); + currentStreamId = null; + } + break; + } + } + }); + } } diff --git a/src/agent/cli/output.ts b/src/agent/cli/output.ts index 3c6c9835..ba3d465e 100644 --- a/src/agent/cli/output.ts +++ b/src/agent/cli/output.ts @@ -1,5 +1,6 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; import { colors, createSpinner } from "./colors.js"; +import { extractText } from "../extract-text.js"; export type AgentOutputState = { lastAssistantText: string; @@ -12,16 +13,6 @@ export type AgentOutput = { handleEvent: (event: AgentEvent) => void; }; -function extractText(message: AgentMessage | undefined): string { - if (!message || typeof message !== "object" || !("content" in message)) return ""; - const content = (message as { content?: Array<{ type: string; text?: string }> }).content; - if (!Array.isArray(content)) return ""; - return content - .filter((c) => c.type === "text") - .map((c) => c.text ?? "") - .join(""); -} - function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "โ€ฆ" : s; } diff --git a/src/agent/extract-text.ts b/src/agent/extract-text.ts new file mode 100644 index 00000000..145a2c97 --- /dev/null +++ b/src/agent/extract-text.ts @@ -0,0 +1,12 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; + +/** Extract plain text content from an AgentMessage */ +export function extractText(message: AgentMessage | undefined): string { + if (!message || typeof message !== "object" || !("content" in message)) return ""; + const content = (message as { content?: Array<{ type: string; text?: string }> }).content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join(""); +} diff --git a/src/agent/runner.ts b/src/agent/runner.ts index bf73268f..4a161aea 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -234,6 +234,11 @@ export class Agent { }); } + /** Subscribe to agent events (returns unsubscribe function) */ + subscribe(fn: (event: AgentEvent) => void): () => void { + return this.agent.subscribe(fn); + } + async run(prompt: string): Promise { this.output.state.lastAssistantText = ""; await this.agent.prompt(prompt); diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 984082a2..3d1eae3b 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -3,6 +3,7 @@ import { type ConnectionState, RequestAction, ResponseAction, + StreamAction, type RequestPayload, type ResponseSuccessPayload, type ResponseErrorPayload, @@ -143,7 +144,15 @@ export class Hub { addAgentRecord({ id: agent.sessionId, createdAt: Date.now() }); } - // Internally consume messages produced by agent + // Forward streaming events to the requesting client + agent.onStream((payload) => { + const targetDeviceId = this.agentSenders.get(agent.sessionId); + if (targetDeviceId) { + this.client.send(targetDeviceId, StreamAction, payload); + } + }); + + // Internally consume messages produced by agent (fallback for non-stream scenarios) void this.consumeAgent(agent); console.log(`Agent created: ${agent.sessionId}`); From 86d00bb1342a334ffd05a56718a33355e81f0b18 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:18:35 +0800 Subject: [PATCH 12/18] feat(ui): render AI messages with streaming markdown Messages store gains streamingIds set and startStream/appendStream/ endStream actions. Gateway store routes stream action payloads to these new actions. Chat component switches to StreamingMarkdown for in-progress messages, providing incremental block-level rendering. Co-Authored-By: Claude Opus 4.5 --- packages/store/src/gateway.ts | 28 +++++++++++++++++++++- packages/store/src/messages.ts | 33 +++++++++++++++++++++++++ packages/ui/src/components/chat.tsx | 37 ++++++++++++++++++----------- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index fb70f6c0..5682b70a 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -1,5 +1,5 @@ import { create } from "zustand" -import { GatewayClient, type ConnectionState, type DeviceInfo, type SendErrorResponse } from "@multica/sdk" +import { GatewayClient, StreamAction, type ConnectionState, type DeviceInfo, type SendErrorResponse, type StreamPayload } from "@multica/sdk" import { useMessagesStore } from "./messages" const DEFAULT_GATEWAY_URL = "http://localhost:3000" @@ -45,6 +45,32 @@ export const useGatewayStore = create()((set, get) => ({ }) .onStateChange((connectionState) => set({ connectionState })) .onMessage((msg) => { + // Handle streaming messages + if (msg.action === StreamAction) { + const payload = msg.payload as StreamPayload + const store = useMessagesStore.getState() + switch (payload.state) { + case "delta": { + const exists = store.messages.some((m) => m.id === payload.streamId) + if (!exists) { + store.startStream(payload.streamId, payload.agentId) + } + if (payload.content) { + store.appendStream(payload.streamId, payload.content) + } + break + } + case "final": + store.endStream(payload.streamId, payload.content ?? "") + break + case "error": + store.endStream(payload.streamId, `[error] ${payload.error}`) + break + } + return + } + + // Fallback: complete message handling const payload = msg.payload as { agentId?: string; content?: string } if (payload?.agentId && payload?.content) { useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) diff --git a/packages/store/src/messages.ts b/packages/store/src/messages.ts index f2df5f2e..a25625d9 100644 --- a/packages/store/src/messages.ts +++ b/packages/store/src/messages.ts @@ -10,6 +10,7 @@ export interface Message { interface MessagesState { messages: Message[] + streamingIds: Set } interface MessagesActions { @@ -18,12 +19,16 @@ interface MessagesActions { updateMessage: (id: string, content: string) => void loadMessages: (agentId: string, msgs: Message[]) => void clearMessages: (agentId?: string) => void + startStream: (streamId: string, agentId: string) => void + appendStream: (streamId: string, content: string) => void + endStream: (streamId: string, content: string) => void } export type MessagesStore = MessagesState & MessagesActions export const useMessagesStore = create()((set, get) => ({ messages: [], + streamingIds: new Set(), addUserMessage: (content, agentId) => { set((s) => ({ @@ -54,4 +59,32 @@ export const useMessagesStore = create()((set, get) => ({ messages: agentId ? s.messages.filter((m) => m.agentId !== agentId) : [], })) }, + + startStream: (streamId, agentId) => { + set((s) => { + const ids = new Set(s.streamingIds) + ids.add(streamId) + return { + messages: [...s.messages, { id: streamId, role: "assistant" as const, content: "", agentId }], + streamingIds: ids, + } + }) + }, + + appendStream: (streamId, content) => { + set((s) => ({ + messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), + })) + }, + + endStream: (streamId, content) => { + set((s) => { + const ids = new Set(s.streamingIds) + ids.delete(streamId) + return { + messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)), + streamingIds: ids, + } + }) + }, })) diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index 53476365..4363c7c0 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -6,6 +6,7 @@ import { Badge } from "@multica/ui/components/ui/badge"; import { Button } from "@multica/ui/components/ui/button"; import { ChatInput } from "@multica/ui/components/chat-input"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; +import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; import { HugeiconsIcon } from "@hugeicons/react"; import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; import { toast } from "@multica/ui/components/ui/sonner"; @@ -27,6 +28,7 @@ export function Chat() { const gwState = useGatewayStore((s) => s.connectionState) const messages = useMessagesStore((s) => s.messages) + const streamingIds = useMessagesStore((s) => s.streamingIds) const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId]) const handleSend = useCallback((text: string) => { @@ -99,25 +101,32 @@ export function Chat() { ) : (
- {filtered.map((msg) => ( -
+ {filtered.map((msg) => { + const isStreaming = streamingIds.has(msg.id) + return (
- - {msg.content} - +
+ {isStreaming ? ( + + ) : ( + + {msg.content} + + )} +
-
- ))} + ) + })}
)} From 6723aa856186b9fc6ff6111e225dea8726e1b433 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:20:02 +0800 Subject: [PATCH 13/18] refactor(providers): extract provider management to dedicated module - Create src/agent/providers/ with registry.ts and resolver.ts - registry.ts: Provider metadata, status checking, login instructions - resolver.ts: API key resolution, model resolution - oauth/providers.ts now re-exports from providers/ (deprecated) - tools.ts: Remove PROVIDER_ALIAS and DEFAULT_MODELS (moved to providers/) - Update imports in runner.ts and chat.ts This separates concerns: - oauth/ only handles OAuth credential reading - providers/ manages all provider metadata and resolution --- src/agent/cli/commands/chat.ts | 2 +- src/agent/oauth/providers.ts | 304 ++------------------------------ src/agent/providers/index.ts | 34 ++++ src/agent/providers/registry.ts | 275 +++++++++++++++++++++++++++++ src/agent/providers/resolver.ts | 166 +++++++++++++++++ src/agent/runner.ts | 49 +---- src/agent/tools.ts | 55 +----- 7 files changed, 503 insertions(+), 382 deletions(-) create mode 100644 src/agent/providers/index.ts create mode 100644 src/agent/providers/registry.ts create mode 100644 src/agent/providers/resolver.ts diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index 244c6e94..16d897eb 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -17,7 +17,7 @@ import { getCurrentProvider, getLoginInstructions, type ProviderInfo, -} from "../../oauth/providers.js"; +} from "../../providers/index.js"; type ChatOptions = { profile?: string; diff --git a/src/agent/oauth/providers.ts b/src/agent/oauth/providers.ts index 53363cb7..ddd5f8dc 100644 --- a/src/agent/oauth/providers.ts +++ b/src/agent/oauth/providers.ts @@ -1,292 +1,20 @@ /** - * Provider Management + * @deprecated This file is deprecated. Import from '../providers/index.js' instead. * - * Manage LLM providers with support for: - * - API Key authentication (traditional) - * - OAuth authentication (Claude Code, Codex) + * This file re-exports from the new providers/ module for backwards compatibility. + * Will be removed in a future version. */ -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 | undefined; - apiKey?: string | undefined; - baseUrl?: string | undefined; - // OAuth specific - accessToken?: string | undefined; - refreshToken?: string | undefined; - expires?: number | undefined; -} - -// ============================================================ -// Provider Registry -// ============================================================ - -const PROVIDER_INFO: Record> = { - "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."; -} - -/** - * Check if a provider uses OAuth authentication - */ -export function isOAuthProvider(providerId: string): boolean { - const info = PROVIDER_INFO[providerId]; - return info?.authMethod === "oauth"; -} - -/** - * Check if provider is available (has valid credentials) - */ -export function isProviderAvailable(providerId: string): boolean { - const info = PROVIDER_INFO[providerId]; - if (!info) return false; - - if (info.authMethod === "oauth") { - return isOAuthAvailable(providerId); - } - return isApiKeyConfigured(providerId); -} +export { + type AuthMethod, + type ProviderInfo, + type ProviderConfig, + isOAuthProvider, + isProviderAvailable, + getCurrentProvider, + getProviderList, + getAvailableProviders, + formatProviderStatus, + getLoginInstructions, + resolveProviderConfig, +} from "../providers/index.js"; diff --git a/src/agent/providers/index.ts b/src/agent/providers/index.ts new file mode 100644 index 00000000..25108916 --- /dev/null +++ b/src/agent/providers/index.ts @@ -0,0 +1,34 @@ +/** + * Provider Management + * + * Unified exports for LLM provider management: + * - Registry: Provider metadata, status checking, listing + * - Resolver: API key resolution, model resolution + */ + +// Registry exports +export { + type AuthMethod, + type ProviderInfo, + type ProviderMeta, + PROVIDER_ALIAS, + isOAuthProvider, + isProviderAvailable, + getCurrentProvider, + getProviderMeta, + getDefaultModel, + getProviderList, + getAvailableProviders, + formatProviderStatus, + getLoginInstructions, +} from "./registry.js"; + +// Resolver exports +export { + type ProviderConfig, + resolveProviderConfig, + resolveApiKey, + resolveBaseUrl, + resolveModelId, + resolveModel, +} from "./resolver.js"; diff --git a/src/agent/providers/registry.ts b/src/agent/providers/registry.ts new file mode 100644 index 00000000..acff7ee9 --- /dev/null +++ b/src/agent/providers/registry.ts @@ -0,0 +1,275 @@ +/** + * Provider Registry + * + * Central registry for all LLM providers with metadata, + * status checking, and display formatting. + */ + +import { credentialManager } from "../credentials.js"; +import { + hasValidClaudeCliCredentials, + hasValidCodexCliCredentials, +} from "../oauth/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; + defaultModel: string; + models: string[]; + loginUrl?: string | undefined; + loginCommand?: string | undefined; +} + +/** Static provider metadata (without runtime status) */ +export interface ProviderMeta { + id: string; + name: string; + authMethod: AuthMethod; + defaultModel: string; + models: string[]; + loginUrl?: string | undefined; + loginCommand?: string | undefined; +} + +// ============================================================ +// Provider Registry +// ============================================================ + +const PROVIDER_REGISTRY: Record = { + "claude-code": { + id: "claude-code", + name: "Claude Code (OAuth)", + authMethod: "oauth", + defaultModel: "claude-sonnet-4-20250514", + models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"], + loginCommand: "claude login", + }, + "openai-codex": { + id: "openai-codex", + name: "Codex (OAuth)", + authMethod: "oauth", + defaultModel: "gpt-5.1", + models: ["gpt-5.1", "gpt-5.1-codex-max"], + loginCommand: "codex login", + }, + "anthropic": { + id: "anthropic", + name: "Anthropic (API Key)", + authMethod: "api-key", + defaultModel: "claude-sonnet-4-20250514", + models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"], + loginUrl: "https://console.anthropic.com/", + }, + "openai": { + id: "openai", + name: "OpenAI", + authMethod: "api-key", + defaultModel: "gpt-4o", + models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"], + loginUrl: "https://platform.openai.com/api-keys", + }, + "kimi-coding": { + id: "kimi-coding", + name: "Kimi Code", + authMethod: "api-key", + defaultModel: "kimi-k2-thinking", + models: ["kimi-k2-thinking", "k2p5"], + loginUrl: "https://kimi.moonshot.cn/", + }, + "google": { + id: "google", + name: "Google AI", + authMethod: "api-key", + defaultModel: "gemini-2.0-flash", + models: ["gemini-2.0-flash", "gemini-1.5-pro"], + loginUrl: "https://aistudio.google.com/apikey", + }, + "groq": { + id: "groq", + name: "Groq", + authMethod: "api-key", + defaultModel: "llama-3.3-70b-versatile", + models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"], + loginUrl: "https://console.groq.com/keys", + }, + "mistral": { + id: "mistral", + name: "Mistral", + authMethod: "api-key", + defaultModel: "mistral-large-latest", + models: ["mistral-large-latest", "codestral-latest"], + loginUrl: "https://console.mistral.ai/api-keys", + }, + "xai": { + id: "xai", + name: "xAI (Grok)", + authMethod: "api-key", + defaultModel: "grok-beta", + models: ["grok-beta", "grok-vision-beta"], + loginUrl: "https://console.x.ai/", + }, + "openrouter": { + id: "openrouter", + name: "OpenRouter", + authMethod: "api-key", + defaultModel: "anthropic/claude-3.5-sonnet", + models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"], + loginUrl: "https://openrouter.ai/keys", + }, +}; + +/** + * Provider alias mapping for OAuth providers. + * Maps friendly names to actual pi-ai provider names. + */ +export const PROVIDER_ALIAS: Record = { + "claude-code": "anthropic", // Claude Code OAuth uses anthropic API +}; + +// ============================================================ +// Status Checking +// ============================================================ + +/** + * 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; +} + +/** + * Check if a provider uses OAuth authentication + */ +export function isOAuthProvider(providerId: string): boolean { + const info = PROVIDER_REGISTRY[providerId]; + return info?.authMethod === "oauth"; +} + +/** + * Check if provider is available (has valid credentials) + */ +export function isProviderAvailable(providerId: string): boolean { + const info = PROVIDER_REGISTRY[providerId]; + if (!info) return false; + + if (info.authMethod === "oauth") { + return isOAuthAvailable(providerId); + } + return isApiKeyConfigured(providerId); +} + +/** + * Get current provider from credentials + */ +export function getCurrentProvider(): string { + return credentialManager.getLlmProvider() ?? "kimi-coding"; +} + +// ============================================================ +// Provider Listing +// ============================================================ + +/** + * Get static provider metadata + */ +export function getProviderMeta(providerId: string): ProviderMeta | undefined { + return PROVIDER_REGISTRY[providerId]; +} + +/** + * Get default model for a provider + */ +export function getDefaultModel(providerId: string): string | undefined { + return PROVIDER_REGISTRY[providerId]?.defaultModel; +} + +/** + * Get list of all providers with their runtime status + */ +export function getProviderList(): ProviderInfo[] { + const currentProvider = getCurrentProvider(); + + return Object.values(PROVIDER_REGISTRY).map((meta) => { + const isOAuth = meta.authMethod === "oauth"; + const available = isOAuth ? isOAuthAvailable(meta.id) : isApiKeyConfigured(meta.id); + + // Check if this is the current provider + // For claude-code, check if current is "anthropic" and OAuth is available + let isCurrent = currentProvider === meta.id; + if (meta.id === "claude-code" && currentProvider === "anthropic") { + isCurrent = hasValidClaudeCliCredentials(); + } + + return { + ...meta, + available, + configured: available, + current: isCurrent, + }; + }); +} + +/** + * Get available providers only + */ +export function getAvailableProviders(): ProviderInfo[] { + return getProviderList().filter((p) => p.available); +} + +// ============================================================ +// Display Helpers +// ============================================================ + +/** + * 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_REGISTRY[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."; +} diff --git a/src/agent/providers/resolver.ts b/src/agent/providers/resolver.ts new file mode 100644 index 00000000..a29748e8 --- /dev/null +++ b/src/agent/providers/resolver.ts @@ -0,0 +1,166 @@ +/** + * Provider Resolver + * + * Resolves provider configuration for making API calls, + * including API keys, OAuth tokens, and model selection. + */ + +import { getModel } from "@mariozechner/pi-ai"; +import { credentialManager } from "../credentials.js"; +import { + readClaudeCliCredentials, + readCodexCliCredentials, +} from "../oauth/cli-credentials.js"; +import { + PROVIDER_ALIAS, + getProviderMeta, + getDefaultModel, + isOAuthProvider, +} from "./registry.js"; +import type { AgentOptions } from "../types.js"; + +// ============================================================ +// Types +// ============================================================ + +export interface ProviderConfig { + provider: string; + model?: string | undefined; + apiKey?: string | undefined; + baseUrl?: string | undefined; + // OAuth specific + accessToken?: string | undefined; + refreshToken?: string | undefined; + expires?: number | undefined; +} + +// ============================================================ +// Provider Config Resolution +// ============================================================ + +/** + * Get provider config for making API calls. + * Handles both OAuth and API Key authentication. + */ +export function resolveProviderConfig(providerId: string): ProviderConfig | null { + const meta = getProviderMeta(providerId); + if (!meta) return null; + + if (meta.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, + }; +} + +// ============================================================ +// API Key Resolution +// ============================================================ + +/** + * Get API Key based on provider. + * Priority: explicit key > OAuth credentials > credentials.json5 config. + */ +export function resolveApiKey(provider: string, explicitKey?: string): string | undefined { + if (explicitKey) return explicitKey; + + // Try OAuth providers first (claude-code, openai-codex) + const providerConfig = resolveProviderConfig(provider); + if (providerConfig?.apiKey) { + return providerConfig.apiKey; + } + if (providerConfig?.accessToken) { + return providerConfig.accessToken; + } + + // Fall back to credentials.json5 + return credentialManager.getLlmProviderConfig(provider)?.apiKey; +} + +/** + * Get Base URL based on provider. + * Priority: explicit URL > credentials.json5 config. + */ +export function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { + if (explicitUrl) return explicitUrl; + return credentialManager.getLlmProviderConfig(provider)?.baseUrl; +} + +/** + * Get Model ID based on provider. + * Priority: explicit model > credentials.json5 config > default. + */ +export function resolveModelId(provider: string, explicitModel?: string): string | undefined { + if (explicitModel) return explicitModel; + return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider); +} + +// ============================================================ +// Model Resolution +// ============================================================ + +/** + * Resolve model for pi-ai based on provider and options. + */ +export function resolveModel(options: AgentOptions) { + if (options.provider && options.model) { + // Map provider alias (e.g., claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + + // Type assertion needed because provider/model come from dynamic user config + return (getModel as (p: string, m: string) => ReturnType)( + actualProvider, + options.model, + ); + } + + // If only provider specified, use default model for that provider + if (options.provider) { + const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; + const defaultModel = getDefaultModel(options.provider) ?? getDefaultModel(actualProvider); + if (defaultModel) { + return (getModel as (p: string, m: string) => ReturnType)( + actualProvider, + defaultModel, + ); + } + } + + return getModel("kimi-coding", "kimi-k2-thinking"); +} + +// Re-export for convenience +export { isOAuthProvider }; diff --git a/src/agent/runner.ts b/src/agent/runner.ts index e6478849..2234dca3 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -7,7 +7,13 @@ import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; import { SkillManager } from "./skills/index.js"; import { credentialManager, getCredentialsPath } from "./credentials.js"; -import { resolveProviderConfig, isOAuthProvider, getLoginInstructions } from "./oauth/providers.js"; +import { + resolveApiKey, + resolveBaseUrl, + resolveModelId, + isOAuthProvider, + getLoginInstructions, +} from "./providers/index.js"; import { checkContextWindow, DEFAULT_CONTEXT_TOKENS, @@ -15,47 +21,6 @@ import { } from "./context-window/index.js"; import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; -/** - * Get API Key based on provider. - * Priority: explicit key > OAuth credentials > credentials.json5 config. - * - * Supports OAuth providers like "claude-code" and "openai-codex" by - * reading credentials from their respective CLI tools. - */ -function resolveApiKey(provider: string, explicitKey?: string): string | undefined { - if (explicitKey) return explicitKey; - - // Try OAuth providers first (claude-code, openai-codex) - const providerConfig = resolveProviderConfig(provider); - if (providerConfig?.apiKey) { - return providerConfig.apiKey; - } - if (providerConfig?.accessToken) { - return providerConfig.accessToken; - } - - // Fall back to credentials.json5 - return credentialManager.getLlmProviderConfig(provider)?.apiKey; -} - -/** - * Get Base URL based on provider. - * Priority: explicit URL > provider-specific env var > generic env var format. - */ -function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined { - if (explicitUrl) return explicitUrl; - return credentialManager.getLlmProviderConfig(provider)?.baseUrl; -} - -/** - * Get Model ID based on provider. - * Priority: explicit model > provider-specific env var > generic env var format. - */ -function resolveModelId(provider: string, explicitModel?: string): string | undefined { - if (explicitModel) return explicitModel; - return credentialManager.getLlmProviderConfig(provider)?.model; -} - export class Agent { private readonly agent: PiAgentCore; private readonly output; diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 8cec06b3..a7c150bd 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,5 +1,4 @@ import type { AgentOptions } from "./types.js"; -import { getModel } from "@mariozechner/pi-ai"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; @@ -9,62 +8,16 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; import { createMemoryTools } from "./tools/memory/index.js"; import { filterTools } from "./tools/policy.js"; -/** - * Provider alias mapping for OAuth providers. - * Maps friendly names to actual pi-ai provider names. - */ -const PROVIDER_ALIAS: Record = { - "claude-code": "anthropic", // Claude Code OAuth uses anthropic API -}; - -/** - * Default models for each provider. - */ -const DEFAULT_MODELS: Record = { - "anthropic": "claude-sonnet-4-20250514", - "claude-code": "claude-sonnet-4-20250514", - "openai": "gpt-4o", - "openai-codex": "gpt-5.1", - "kimi-coding": "kimi-k2-thinking", - "google": "gemini-2.0-flash", - "groq": "llama-3.3-70b-versatile", - "mistral": "mistral-large-latest", -}; - -export function resolveModel(options: AgentOptions) { - if (options.provider && options.model) { - // Map provider alias (e.g., claude-code -> anthropic) - const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; - - // Type assertion needed because provider/model come from dynamic user config - return (getModel as (p: string, m: string) => ReturnType)( - actualProvider, - options.model, - ); - } - - // If only provider specified, use default model for that provider - if (options.provider) { - const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider; - const defaultModel = DEFAULT_MODELS[options.provider] ?? DEFAULT_MODELS[actualProvider]; - if (defaultModel) { - return (getModel as (p: string, m: string) => ReturnType)( - actualProvider, - defaultModel, - ); - } - } - - return getModel("kimi-coding", "kimi-k2-thinking"); -} +// Re-export resolveModel from providers for backwards compatibility +export { resolveModel } from "./providers/index.js"; /** Options for creating tools */ export interface CreateToolsOptions { cwd: string; /** Profile ID for memory tools (optional) */ - profileId?: string; + profileId?: string | undefined; /** Base directory for profiles (optional) */ - profileBaseDir?: string; + profileBaseDir?: string | undefined; } /** From 1592e0c2114c7f7fa388a16a1548b73aa94a5f9e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:20:10 +0800 Subject: [PATCH 14/18] fix(types): add undefined to optional properties for exactOptionalPropertyTypes - MemoryStorageOptions.baseDir: add | undefined - FilterToolsOptions: add | undefined to all optional properties - CreateToolsOptions: add | undefined to optional properties --- src/agent/tools/memory/types.ts | 2 +- src/agent/tools/policy.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent/tools/memory/types.ts b/src/agent/tools/memory/types.ts index 81912eed..58bc15bf 100644 --- a/src/agent/tools/memory/types.ts +++ b/src/agent/tools/memory/types.ts @@ -37,7 +37,7 @@ export interface MemoryStorageOptions { /** Profile ID (required for storage path) */ profileId: string; /** Base directory for profiles */ - baseDir?: string; + baseDir?: string | undefined; } /** Result from memory_list */ diff --git a/src/agent/tools/policy.ts b/src/agent/tools/policy.ts index 7e1a9407..5b8c2fc0 100644 --- a/src/agent/tools/policy.ts +++ b/src/agent/tools/policy.ts @@ -183,11 +183,11 @@ export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy { export interface FilterToolsOptions { /** Tool configuration */ - config?: ToolsConfig; + config?: ToolsConfig | undefined; /** Current LLM provider (for provider-specific rules) */ - provider?: string; + provider?: string | undefined; /** Whether this is a subagent (applies subagent restrictions) */ - isSubagent?: boolean; + isSubagent?: boolean | undefined; } /** From 932ecb5dbb1bdbe0337b7a9a24ed4c31f9e68f8a Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:35:35 +0800 Subject: [PATCH 15/18] refactor(providers): move oauth/ to providers/oauth/ and simplify model names - Move src/agent/oauth/ to src/agent/providers/oauth/ (parent-child structure) - Delete deprecated oauth/providers.ts re-export file - Simplify Claude model names: claude-opus-4-5 (without date suffix) - Update Codex models to current lineup (gpt-5.2, gpt-5.1-codex, etc.) - Set claude-opus-4-5 as default model for claude-code provider Co-Authored-By: Claude Opus 4.5 --- src/agent/oauth/index.ts | 2 -- src/agent/oauth/providers.ts | 20 ------------------- .../{ => providers}/oauth/cli-credentials.ts | 0 src/agent/providers/oauth/index.ts | 7 +++++++ src/agent/providers/registry.ts | 14 ++++++------- src/agent/providers/resolver.ts | 2 +- 6 files changed, 15 insertions(+), 30 deletions(-) delete mode 100644 src/agent/oauth/index.ts delete mode 100644 src/agent/oauth/providers.ts rename src/agent/{ => providers}/oauth/cli-credentials.ts (100%) create mode 100644 src/agent/providers/oauth/index.ts diff --git a/src/agent/oauth/index.ts b/src/agent/oauth/index.ts deleted file mode 100644 index 9d740a39..00000000 --- a/src/agent/oauth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./cli-credentials.js"; -export * from "./providers.js"; diff --git a/src/agent/oauth/providers.ts b/src/agent/oauth/providers.ts deleted file mode 100644 index ddd5f8dc..00000000 --- a/src/agent/oauth/providers.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @deprecated This file is deprecated. Import from '../providers/index.js' instead. - * - * This file re-exports from the new providers/ module for backwards compatibility. - * Will be removed in a future version. - */ - -export { - type AuthMethod, - type ProviderInfo, - type ProviderConfig, - isOAuthProvider, - isProviderAvailable, - getCurrentProvider, - getProviderList, - getAvailableProviders, - formatProviderStatus, - getLoginInstructions, - resolveProviderConfig, -} from "../providers/index.js"; diff --git a/src/agent/oauth/cli-credentials.ts b/src/agent/providers/oauth/cli-credentials.ts similarity index 100% rename from src/agent/oauth/cli-credentials.ts rename to src/agent/providers/oauth/cli-credentials.ts diff --git a/src/agent/providers/oauth/index.ts b/src/agent/providers/oauth/index.ts new file mode 100644 index 00000000..e666b667 --- /dev/null +++ b/src/agent/providers/oauth/index.ts @@ -0,0 +1,7 @@ +/** + * OAuth Credential Reading + * + * Read OAuth credentials from external CLI tools (Claude Code, Codex). + */ + +export * from "./cli-credentials.js"; diff --git a/src/agent/providers/registry.ts b/src/agent/providers/registry.ts index acff7ee9..50809def 100644 --- a/src/agent/providers/registry.ts +++ b/src/agent/providers/registry.ts @@ -9,7 +9,7 @@ import { credentialManager } from "../credentials.js"; import { hasValidClaudeCliCredentials, hasValidCodexCliCredentials, -} from "../oauth/cli-credentials.js"; +} from "./oauth/cli-credentials.js"; // ============================================================ // Types @@ -50,24 +50,24 @@ const PROVIDER_REGISTRY: Record = { id: "claude-code", name: "Claude Code (OAuth)", authMethod: "oauth", - defaultModel: "claude-sonnet-4-20250514", - models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"], + defaultModel: "claude-opus-4-5", + models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"], loginCommand: "claude login", }, "openai-codex": { id: "openai-codex", name: "Codex (OAuth)", authMethod: "oauth", - defaultModel: "gpt-5.1", - models: ["gpt-5.1", "gpt-5.1-codex-max"], + defaultModel: "gpt-5.2", + models: ["gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max"], loginCommand: "codex login", }, "anthropic": { id: "anthropic", name: "Anthropic (API Key)", authMethod: "api-key", - defaultModel: "claude-sonnet-4-20250514", - models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"], + defaultModel: "claude-sonnet-4-5", + models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"], loginUrl: "https://console.anthropic.com/", }, "openai": { diff --git a/src/agent/providers/resolver.ts b/src/agent/providers/resolver.ts index a29748e8..7ec8dd14 100644 --- a/src/agent/providers/resolver.ts +++ b/src/agent/providers/resolver.ts @@ -10,7 +10,7 @@ import { credentialManager } from "../credentials.js"; import { readClaudeCliCredentials, readCodexCliCredentials, -} from "../oauth/cli-credentials.js"; +} from "./oauth/cli-credentials.js"; import { PROVIDER_ALIAS, getProviderMeta, From c55bb5a8643eb957b1a8afb27875f3d3760cd9b6 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 17:35:40 +0800 Subject: [PATCH 16/18] feat(cli): add /model command for switching models within provider Add /model command to interactively switch models: - /model: Show current model and list available models for provider - /model : Switch to specified model (preserves session) Example usage: /model # Shows claude-opus-4-5, claude-sonnet-4-5, etc. /model claude-sonnet-4-5 # Switch to sonnet model Co-Authored-By: Claude Opus 4.5 --- src/agent/cli/commands/chat.ts | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/agent/cli/commands/chat.ts b/src/agent/cli/commands/chat.ts index 16d897eb..e083175c 100644 --- a/src/agent/cli/commands/chat.ts +++ b/src/agent/cli/commands/chat.ts @@ -16,6 +16,7 @@ import { getProviderList, getCurrentProvider, getLoginInstructions, + getProviderMeta, type ProviderInfo, } from "../../providers/index.js"; @@ -38,6 +39,7 @@ const COMMANDS = { new: "Start a new session", multiline: "Toggle multi-line input mode (end with a line containing only '.')", provider: "Show current provider and available options", + model: "Show or switch model (usage: /model [model-name])", }; function printHelp() { @@ -466,6 +468,10 @@ class InteractiveCLI { this.showProviderStatus(); return true; + case "model": + this.handleModelCommand(input); + return true; + default: const invocation = this.skillManager.resolveCommand(input); if (invocation) { @@ -479,6 +485,66 @@ class InteractiveCLI { } } + private handleModelCommand(input: string) { + const parts = input.trim().split(/\s+/); + const modelArg = parts.slice(1).join(" ").trim(); + const currentProvider = this.opts.provider ?? getCurrentProvider(); + const providerMeta = getProviderMeta(currentProvider); + + if (!providerMeta) { + console.log(`${red("Error:")} Unknown provider: ${currentProvider}\n`); + return; + } + + // No argument - show current model and available models + if (!modelArg) { + console.log(`\n${cyan("๐ŸŽฏ Model Status")}\n`); + console.log(`${dim("Provider:")} ${green(currentProvider)}`); + console.log(`${dim("Current model:")} ${yellow(this.opts.model ?? providerMeta.defaultModel)}`); + console.log(`${dim("Default model:")} ${gray(providerMeta.defaultModel)}`); + + console.log(`\n${dim("Available models for")} ${green(currentProvider)}${dim(":")}`); + for (const model of providerMeta.models) { + const isCurrent = model === (this.opts.model ?? providerMeta.defaultModel); + const marker = isCurrent ? yellow(" (current)") : ""; + const modelDisplay = isCurrent ? yellow(model) : model; + console.log(` โ€ข ${modelDisplay}${marker}`); + } + + console.log(`\n${dim("Switch model:")} ${yellow(`/model `)}`); + console.log(`${dim("Example:")} ${yellow(`/model ${providerMeta.models[0]}`)}`); + console.log(""); + return; + } + + // Check if model is valid for current provider + const normalizedModel = modelArg.toLowerCase(); + const matchedModel = providerMeta.models.find( + (m) => m.toLowerCase() === normalizedModel + ); + + if (!matchedModel) { + console.log(`${red("Error:")} Model "${modelArg}" is not available for provider "${currentProvider}".`); + console.log(`\n${dim("Available models:")}`); + for (const model of providerMeta.models) { + console.log(` โ€ข ${model}`); + } + console.log(""); + return; + } + + // Switch model + const oldModel = this.opts.model ?? providerMeta.defaultModel; + this.opts.model = matchedModel; + + // Recreate agent with new model + this.agent = this.createAgent(this.agent.sessionId); + this.updateStatusBar(); + + console.log(`${green("โœ“")} Model switched: ${gray(oldModel)} โ†’ ${yellow(matchedModel)}`); + console.log(`${dim("Session preserved:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`); + } + private showProviderStatus() { const providers = getProviderList(); const currentProvider = this.opts.provider ?? getCurrentProvider(); From 0385b0e0251f681729d0276264bd6e1bd4a66b9e Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Tue, 3 Feb 2026 02:30:47 +0800 Subject: [PATCH 17/18] test(agent): convert tool policy test --- src/agent/tools/policy.test.ts | 336 +++++++++++++++------------------ 1 file changed, 152 insertions(+), 184 deletions(-) diff --git a/src/agent/tools/policy.test.ts b/src/agent/tools/policy.test.ts index 1862fd6f..e0902708 100644 --- a/src/agent/tools/policy.test.ts +++ b/src/agent/tools/policy.test.ts @@ -1,32 +1,6 @@ -/** - * Tests for tool policy system. - * Run with: npx tsx src/agent/tools/policy.test.ts - */ - -import { filterTools, type ToolsConfig } from "./policy.js"; -import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./groups.js"; - -// Simple test helper -function test(name: string, fn: () => void) { - try { - fn(); - console.log(`โœ“ ${name}`); - } catch (e) { - console.error(`โœ— ${name}`); - console.error(e); - process.exit(1); - } -} - -function assertEqual(actual: T, expected: T, msg?: string) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error( - `${msg || "Assertion failed"}\n Expected: ${expectedStr}\n Actual: ${actualStr}`, - ); - } -} +import { describe, it, expect } from "vitest"; +import { filterTools } from "./policy.js"; +import { TOOL_PROFILES, expandToolGroups } from "./groups.js"; // Mock tools for testing const mockTools = [ @@ -40,177 +14,171 @@ const mockTools = [ { name: "web_search" }, ] as any[]; -console.log("=== Tool Groups Tests ===\n"); - -test("expandToolGroups: group:fs", () => { - const expanded = expandToolGroups(["group:fs"]); - assertEqual(expanded.sort(), ["edit", "glob", "read", "write"]); -}); - -test("expandToolGroups: group:runtime", () => { - const expanded = expandToolGroups(["group:runtime"]); - assertEqual(expanded.sort(), ["exec", "process"]); -}); - -test("expandToolGroups: group:web", () => { - const expanded = expandToolGroups(["group:web"]); - assertEqual(expanded.sort(), ["web_fetch", "web_search"]); -}); - -test("expandToolGroups: mixed groups and tools", () => { - const expanded = expandToolGroups(["group:runtime", "web_fetch"]); - assertEqual(expanded.sort(), ["exec", "process", "web_fetch"]); -}); - -console.log("\n=== Tool Profiles Tests ===\n"); - -test("TOOL_PROFILES: minimal has empty allow", () => { - assertEqual(TOOL_PROFILES.minimal.allow, []); -}); - -test("TOOL_PROFILES: coding has fs and runtime", () => { - assertEqual(TOOL_PROFILES.coding.allow, ["group:fs", "group:runtime"]); -}); - -test("TOOL_PROFILES: full has no restrictions", () => { - assertEqual(TOOL_PROFILES.full.allow, undefined); - assertEqual(TOOL_PROFILES.full.deny, undefined); -}); - -console.log("\n=== Filter Tests ===\n"); - -test("filterTools: no config returns all tools", () => { - const filtered = filterTools(mockTools, {}); - assertEqual(filtered.length, mockTools.length); -}); - -test("filterTools: minimal profile returns no tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); - assertEqual(filtered.length, 0); -}); - -test("filterTools: coding profile returns fs and runtime", () => { - const filtered = filterTools(mockTools, { config: { profile: "coding" } }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["edit", "exec", "glob", "process", "read", "write"]); -}); - -test("filterTools: web profile returns all", () => { - const filtered = filterTools(mockTools, { config: { profile: "web" } }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, [ - "edit", - "exec", - "glob", - "process", - "read", - "web_fetch", - "web_search", - "write", - ]); -}); - -test("filterTools: full profile returns all tools", () => { - const filtered = filterTools(mockTools, { config: { profile: "full" } }); - assertEqual(filtered.length, mockTools.length); -}); - -test("filterTools: deny specific tool", () => { - const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); - const names = filtered.map((t) => t.name); - assertEqual(names.includes("exec"), false); - assertEqual(names.length, mockTools.length - 1); -}); - -test("filterTools: allow specific tools", () => { - const filtered = filterTools(mockTools, { - config: { allow: ["read", "write"] }, +describe("tool groups", () => { + it("expandToolGroups: group:fs", () => { + const expanded = expandToolGroups(["group:fs"]); + expect(expanded.sort()).toEqual(["edit", "glob", "read", "write"]); }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["read", "write"]); -}); -test("filterTools: deny takes precedence over allow", () => { - const filtered = filterTools(mockTools, { - config: { allow: ["read", "write", "exec"], deny: ["exec"] }, + it("expandToolGroups: group:runtime", () => { + const expanded = expandToolGroups(["group:runtime"]); + expect(expanded.sort()).toEqual(["exec", "process"]); + }); + + it("expandToolGroups: group:web", () => { + const expanded = expandToolGroups(["group:web"]); + expect(expanded.sort()).toEqual(["web_fetch", "web_search"]); + }); + + it("expandToolGroups: mixed groups and tools", () => { + const expanded = expandToolGroups(["group:runtime", "web_fetch"]); + expect(expanded.sort()).toEqual(["exec", "process", "web_fetch"]); }); - const names = filtered.map((t) => t.name).sort(); - assertEqual(names, ["read", "write"]); }); -console.log("\n=== Provider-specific Tests ===\n"); +describe("tool profiles", () => { + it("minimal has empty allow", () => { + expect(TOOL_PROFILES.minimal.allow).toEqual([]); + }); -test("filterTools: provider-specific deny", () => { - const filtered = filterTools(mockTools, { - config: { - byProvider: { - google: { deny: ["exec", "process"] }, + it("coding has fs and runtime", () => { + expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]); + }); + + it("full has no restrictions", () => { + expect(TOOL_PROFILES.full.allow).toBeUndefined(); + expect(TOOL_PROFILES.full.deny).toBeUndefined(); + }); +}); + +describe("filterTools", () => { + it("no config returns all tools", () => { + const filtered = filterTools(mockTools, {}); + expect(filtered.length).toBe(mockTools.length); + }); + + it("minimal profile returns no tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); + expect(filtered.length).toBe(0); + }); + + it("coding profile returns fs and runtime", () => { + const filtered = filterTools(mockTools, { config: { profile: "coding" } }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]); + }); + + it("web profile returns all", () => { + const filtered = filterTools(mockTools, { config: { profile: "web" } }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual([ + "edit", + "exec", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); + }); + + it("full profile returns all tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "full" } }); + expect(filtered.length).toBe(mockTools.length); + }); + + it("deny specific tool", () => { + const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); + const names = filtered.map((t) => t.name); + expect(names.includes("exec")).toBe(false); + expect(names.length).toBe(mockTools.length - 1); + }); + + it("allow specific tools", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["read", "write"]); + }); + + it("deny takes precedence over allow", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write", "exec"], deny: ["exec"] }, + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["read", "write"]); + }); +}); + +describe("provider-specific filtering", () => { + it("provider-specific deny", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, }, - }, - provider: "google", + provider: "google", + }); + const names = filtered.map((t) => t.name); + expect(names.includes("exec")).toBe(false); + expect(names.includes("process")).toBe(false); + expect(names.length).toBe(mockTools.length - 2); }); - const names = filtered.map((t) => t.name); - assertEqual(names.includes("exec"), false); - assertEqual(names.includes("process"), false); - assertEqual(names.length, mockTools.length - 2); -}); -test("filterTools: provider not matching does not apply", () => { - const filtered = filterTools(mockTools, { - config: { - byProvider: { - google: { deny: ["exec", "process"] }, + it("provider not matching does not apply", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, }, - }, - provider: "openai", + provider: "openai", + }); + expect(filtered.length).toBe(mockTools.length); }); - assertEqual(filtered.length, mockTools.length); }); -console.log("\n=== Subagent Tests ===\n"); - -test("filterTools: subagent restrictions apply", () => { - // Currently DEFAULT_SUBAGENT_TOOL_DENY is empty, so no tools are denied - const filtered = filterTools(mockTools, { isSubagent: true }); - // With empty deny list, all tools are allowed - assertEqual(filtered.length, mockTools.length); -}); - -console.log("\n=== Combined Tests ===\n"); - -test("filterTools: profile + deny", () => { - const filtered = filterTools(mockTools, { - config: { - profile: "coding", - deny: ["exec"], - }, +describe("subagent restrictions", () => { + it("subagent restrictions apply", () => { + const filtered = filterTools(mockTools, { isSubagent: true }); + expect(filtered.length).toBe(mockTools.length); }); - const names = filtered.map((t) => t.name).sort(); - // coding = fs + runtime, minus exec - assertEqual(names, ["edit", "glob", "process", "read", "write"]); }); -test("filterTools: profile + provider deny", () => { - const filtered = filterTools(mockTools, { - config: { - profile: "web", - byProvider: { - google: { deny: ["exec"] }, +describe("combined filtering", () => { + it("profile + deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "coding", + deny: ["exec"], }, - }, - provider: "google", + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual(["edit", "glob", "process", "read", "write"]); }); - const names = filtered.map((t) => t.name).sort(); - // web profile - exec - assertEqual(names, [ - "edit", - "glob", - "process", - "read", - "web_fetch", - "web_search", - "write", - ]); -}); -console.log("\n=== All tests passed! ===\n"); + it("profile + provider deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "web", + byProvider: { + google: { deny: ["exec"] }, + }, + }, + provider: "google", + }); + const names = filtered.map((t) => t.name).sort(); + expect(names).toEqual([ + "edit", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); + }); +}); From a8143735e93477334ed021c19abdb8a9d61d93a1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:17:31 +0800 Subject: [PATCH 18/18] feat(desktop): initialize electron app with routing and cleanup Remove template boilerplate (sample SVGs, test IPC message, comments), add react-router-dom v7 with createHashRouter, scaffold home and chat pages using @multica/ui components, and add READMEs for desktop, ui, and store packages documenting import conventions. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/.gitignore | 2 + apps/desktop/README.md | 42 +++++++---------- apps/desktop/electron/main.ts | 21 --------- apps/desktop/index.html | 3 +- apps/desktop/package.json | 3 +- apps/desktop/public/electron-vite.animate.svg | 34 -------------- apps/desktop/public/electron-vite.svg | 26 ----------- apps/desktop/public/vite.svg | 1 - apps/desktop/src/App.tsx | 15 ++++--- apps/desktop/src/assets/react.svg | 1 - apps/desktop/src/main.tsx | 5 --- apps/desktop/src/pages/chat.tsx | 15 +++++++ apps/desktop/src/pages/home.tsx | 12 +++++ packages/store/README.md | 17 +++++++ packages/ui/README.md | 32 +++++++++++++ pnpm-lock.yaml | 45 +++++++++++++++++-- 16 files changed, 150 insertions(+), 124 deletions(-) delete mode 100644 apps/desktop/public/electron-vite.animate.svg delete mode 100644 apps/desktop/public/electron-vite.svg delete mode 100644 apps/desktop/public/vite.svg delete mode 100644 apps/desktop/src/assets/react.svg create mode 100644 apps/desktop/src/pages/chat.tsx create mode 100644 apps/desktop/src/pages/home.tsx create mode 100644 packages/store/README.md create mode 100644 packages/ui/README.md diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 4108b33e..4cec9104 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -9,7 +9,9 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr +release *.local # Editor directories and files diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f02aedf8..d5bcf73d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,30 +1,22 @@ -# React + TypeScript + Vite +# @multica/desktop -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Electron desktop app. Vite + React + `createHashRouter`. -Currently, two official plugins are available: +## Development -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} +```bash +multica dev desktop ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +## Build + +```bash +pnpm --filter @multica/desktop build +``` + +## Conventions + +- **Routing**: `react-router-dom` v7 with `createHashRouter` (Electron loads via `file://`, BrowserRouter won't work). Pages go in `src/pages/`. +- **UI**: All components from `@multica/ui`. No local UI components. +- **State**: Store hooks from `@multica/store`. +- **Styles**: Tailwind CSS v4 via `@multica/ui/globals.css`, imported in `src/main.tsx`. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 302852f4..94948866 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4,18 +4,8 @@ import path from 'node:path' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// The built directory structure -// -// โ”œโ”€โ”ฌโ”€โ”ฌ dist -// โ”‚ โ”‚ โ””โ”€โ”€ index.html -// โ”‚ โ”‚ -// โ”‚ โ”œโ”€โ”ฌ dist-electron -// โ”‚ โ”‚ โ”œโ”€โ”€ main.js -// โ”‚ โ”‚ โ””โ”€โ”€ preload.mjs -// โ”‚ process.env.APP_ROOT = path.join(__dirname, '..') -// ๐Ÿšง Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') @@ -26,17 +16,11 @@ let win: BrowserWindow | null function createWindow() { win = new BrowserWindow({ - icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'), webPreferences: { preload: path.join(__dirname, 'preload.mjs'), }, }) - // Test active push message to Renderer-process. - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) - if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL) } else { @@ -45,9 +29,6 @@ function createWindow() { } } -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() @@ -56,8 +37,6 @@ app.on('window-all-closed', () => { }) app.on('activate', () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createWindow() } diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 1136ddeb..d17771da 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -2,9 +2,8 @@ - - Vite + React + TS + Multica
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8eb3132b..f98f7e98 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,7 +12,8 @@ "dependencies": { "@multica/ui": "workspace:*", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/apps/desktop/public/electron-vite.animate.svg b/apps/desktop/public/electron-vite.animate.svg deleted file mode 100644 index ea3e7770..00000000 --- a/apps/desktop/public/electron-vite.animate.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/public/electron-vite.svg b/apps/desktop/public/electron-vite.svg deleted file mode 100644 index 8a6aefe6..00000000 --- a/apps/desktop/public/electron-vite.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/desktop/public/vite.svg b/apps/desktop/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/apps/desktop/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index fc7cead5..bd26458b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,7 +1,12 @@ -import { ComponentExample } from '@multica/ui/components/component-example' +import { createHashRouter, RouterProvider } from 'react-router-dom' +import HomePage from './pages/home' +import ChatPage from './pages/chat' -function App() { - return +const router = createHashRouter([ + { path: '/', element: }, + { path: '/chat', element: }, +]) + +export default function App() { + return } - -export default App diff --git a/apps/desktop/src/assets/react.svg b/apps/desktop/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/apps/desktop/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index ad3387ad..a4e610e1 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -8,8 +8,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render( , ) - -// Use contextBridge -window.ipcRenderer.on('main-process-message', (_event, message) => { - console.log(message) -}) diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx new file mode 100644 index 00000000..f3495285 --- /dev/null +++ b/apps/desktop/src/pages/chat.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' + +export default function ChatPage() { + const navigate = useNavigate() + + return ( +
+

Chat

+ +
+ ) +} diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx new file mode 100644 index 00000000..c8042511 --- /dev/null +++ b/apps/desktop/src/pages/home.tsx @@ -0,0 +1,12 @@ +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' + +export default function HomePage() { + const navigate = useNavigate() + + return ( +
+ +
+ ) +} diff --git a/packages/store/README.md b/packages/store/README.md new file mode 100644 index 00000000..38e02ee4 --- /dev/null +++ b/packages/store/README.md @@ -0,0 +1,17 @@ +# @multica/store + +Zustand state management for Multica apps. + +## Usage + +```tsx +// From barrel +import { useHubStore, useMessagesStore, useGatewayStore } from '@multica/store' + +// Per-file subpath import +import { useGatewayStore } from '@multica/store/gateway' +import { useHubStore } from '@multica/store/hub' +import { useMessagesStore } from '@multica/store/messages' +import { useHubInit } from '@multica/store/hub-init' +import { useDeviceId } from '@multica/store/device-id' +``` diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..e61096c6 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,32 @@ +# @multica/ui + +Shared UI component library. Shadcn + Tailwind CSS v4. + +## Usage + +```tsx +// UI components โ€” subpath imports, no barrel +import { Button } from '@multica/ui/components/ui/button' +import { Card, CardContent } from '@multica/ui/components/ui/card' + +// Feature components +import { ThemeProvider } from '@multica/ui/components/theme-provider' +import { Chat } from '@multica/ui/components/chat' +import { Markdown } from '@multica/ui/components/markdown' + +// Hooks +import { useIsMobile } from '@multica/ui/hooks/use-mobile' +import { useAutoScroll } from '@multica/ui/hooks/use-auto-scroll' + +// Utilities +import { cn } from '@multica/ui/lib/utils' + +// Styles (app entry point) +import '@multica/ui/globals.css' +``` + +## Adding Components + +```bash +pnpm --filter @multica/ui dlx shadcn@latest add +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc53daf..a71a43c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.3(react@19.2.3) + react-router-dom: + specifier: ^7.13.0 + version: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 @@ -5282,6 +5285,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.13.0: + resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.0: + resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -5485,6 +5505,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -10008,7 +10031,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10041,7 +10064,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10056,7 +10079,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -12368,6 +12391,20 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-router@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} read-config-file@6.3.2: @@ -12663,6 +12700,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4