From 8f594617bbf53485dc28e2ba71088976dbbaea07 Mon Sep 17 00:00:00 2001 From: Nattapong Tapachoom Date: Wed, 28 Jan 2026 08:38:55 +0700 Subject: [PATCH] feat(auth): add profile reuse and fix TypeScript errors [AI-assisted] --- src/commands/auth-choice.apply.anthropic.ts | 56 +- .../auth-choice.apply.api-providers.ts | 561 ++++++++++++++---- .../auth-choice.apply.github-copilot.ts | 63 +- src/commands/auth-choice.apply.minimax.ts | 34 +- src/commands/auth-choice.apply.oauth.ts | 43 +- src/commands/auth-choice.apply.openai.ts | 38 +- .../auth-choice.apply.plugin-provider.ts | 102 +++- src/commands/chutes-oauth.ts | 127 ++-- src/commands/onboard-auth.ts | 6 +- src/commands/onboarding/plugin-install.ts | 43 +- ui/src/ui/app-settings.ts | 66 ++- ui/src/ui/app.ts | 43 +- ui/src/ui/storage.ts | 6 + 13 files changed, 926 insertions(+), 262 deletions(-) diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts index b28b8ebee..13c5cea9e 100644 --- a/src/commands/auth-choice.apply.anthropic.ts +++ b/src/commands/auth-choice.apply.anthropic.ts @@ -1,12 +1,23 @@ import { upsertAuthProfile } from "../agents/auth-profiles.js"; +declare const process: any; import { formatApiKeyPreview, normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js"; -import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; +import { + buildTokenProfileId, + validateAnthropicSetupToken, +} from "./auth-token.js"; +import { + applyAuthProfileConfig, + ensureAuthProfileStore, + setAnthropicApiKey, +} from "./onboard-auth.js"; export async function applyAuthChoiceAnthropic( params: ApplyAuthChoiceParams, @@ -18,9 +29,10 @@ export async function applyAuthChoiceAnthropic( ) { let nextConfig = params.config; await params.prompter.note( - ["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join( - "\n", - ), + [ + "Run `claude setup-token` in your terminal.", + "Then paste the generated token below.", + ].join("\n"), "Anthropic setup-token", ); @@ -59,7 +71,10 @@ export async function applyAuthChoiceAnthropic( } if (params.authChoice === "apiKey") { - if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") { + if ( + params.opts?.tokenProvider && + params.opts.tokenProvider !== "anthropic" + ) { return null; } @@ -68,7 +83,10 @@ export async function applyAuthChoiceAnthropic( const envKey = process.env.ANTHROPIC_API_KEY?.trim(); if (params.opts?.token) { - await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + await setAnthropicApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } @@ -82,12 +100,32 @@ export async function applyAuthChoiceAnthropic( hasCredential = true; } } + + if (!hasCredential) { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const existing = store.profiles["anthropic:default"]; + if (existing && existing.type === "api_key") { + const useExisting = await params.prompter.confirm({ + message: `Use existing Anthropic API key (${formatApiKeyPreview(existing.key)})?`, + initialValue: true, + }); + if (useExisting) { + hasCredential = true; + } + } + } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Anthropic API key", validate: validateApiKeyInput, }); - await setAnthropicApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setAnthropicApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8be02008b..47ba312db 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -1,11 +1,17 @@ -import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js"; +import { + ensureAuthProfileStore, + resolveAuthProfileOrder, +} from "../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../agents/model-auth.js"; import { formatApiKeyPreview, normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyGoogleGeminiModelDefault, @@ -97,8 +103,12 @@ export async function applyAuthChoiceApiProviders( store, provider: "openrouter", }); - const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); - const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + const existingProfileId = profileOrder.find((profileId) => + Boolean(store.profiles[profileId]), + ); + const existingCred = existingProfileId + ? store.profiles[existingProfileId] + : undefined; let profileId = "openrouter:default"; let mode: "api_key" | "oauth" | "token" = "api_key"; let hasCredential = false; @@ -114,8 +124,15 @@ export async function applyAuthChoiceApiProviders( hasCredential = true; } - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") { - await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "openrouter" + ) { + await setOpenrouterApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } @@ -138,7 +155,10 @@ export async function applyAuthChoiceApiProviders( message: "Enter OpenRouter API key", validate: validateApiKeyInput, }); - await setOpenrouterApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setOpenrouterApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); hasCredential = true; } @@ -174,12 +194,40 @@ export async function applyAuthChoiceApiProviders( params.opts?.token && params.opts?.tokenProvider === "vercel-ai-gateway" ) { - await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + await setVercelAiGatewayApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } const envKey = resolveEnvApiKey("vercel-ai-gateway"); - if (envKey) { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "vercel-ai-gateway", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "vercel-ai-gateway:default"; + + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing Vercel AI Gateway authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + profileId = existingProfileId; + hasCredential = true; + } + } + + if (!hasCredential && envKey) { const useExisting = await params.prompter.confirm({ message: `Use existing AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, initialValue: true, @@ -194,10 +242,13 @@ export async function applyAuthChoiceApiProviders( message: "Enter Vercel AI Gateway API key", validate: validateApiKeyInput, }); - await setVercelAiGatewayApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setVercelAiGatewayApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); } nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "vercel-ai-gateway:default", + profileId, provider: "vercel-ai-gateway", mode: "api_key", }); @@ -219,36 +270,77 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "moonshot-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "moonshot", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "moonshot:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") { - await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("moonshot"); - if (envKey) { + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; const useExisting = await params.prompter.confirm({ - message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + message: `Use existing Moonshot authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, initialValue: true, }); if (useExisting) { - await setMoonshotApiKey(envKey.apiKey, params.agentDir); + profileId = existingProfileId; hasCredential = true; } } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "moonshot" + ) { + await setMoonshotApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("moonshot"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setMoonshotApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Moonshot API key", validate: validateApiKeyInput, }); - await setMoonshotApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setMoonshotApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "moonshot", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, @@ -266,9 +358,41 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "kimi-code-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "kimi-code", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "kimi-code:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kimi-code") { - await setKimiCodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing Kimi Code authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + profileId = existingProfileId; + hasCredential = true; + } + } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "kimi-code" + ) { + await setKimiCodeApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } @@ -280,30 +404,39 @@ export async function applyAuthChoiceApiProviders( ].join("\n"), "Kimi Code", ); - } - const envKey = resolveEnvApiKey("kimi-code"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing KIMICODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setKimiCodeApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; + + const envKey = resolveEnvApiKey("kimi-code"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing KIMICODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setKimiCodeApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } } } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Kimi Code API key", validate: validateApiKeyInput, }); - await setKimiCodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setKimiCodeApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "kimi-code", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "kimi-code:default", - provider: "kimi-code", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, @@ -322,36 +455,75 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "gemini-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "google", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "google:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") { - await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("google"); - if (envKey) { + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; const useExisting = await params.prompter.confirm({ - message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + message: `Use existing Gemini authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, initialValue: true, }); if (useExisting) { - await setGeminiApiKey(envKey.apiKey, params.agentDir); + profileId = existingProfileId; hasCredential = true; } } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "google" + ) { + await setGeminiApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("google"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setGeminiApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Gemini API key", validate: validateApiKeyInput, }); await setGeminiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "google:default", - provider: "google", - mode: "api_key", - }); + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "google", + mode: "api_key", + }); + } + if (params.setDefaultModel) { const applied = applyGoogleGeminiModelDefault(nextConfig); nextConfig = applied.next; @@ -369,36 +541,74 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "zai-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "zai", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "zai:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") { - await setZaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); - hasCredential = true; - } - - const envKey = resolveEnvApiKey("zai"); - if (envKey) { + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; const useExisting = await params.prompter.confirm({ - message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + message: `Use existing Z.AI authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, initialValue: true, }); if (useExisting) { - await setZaiApiKey(envKey.apiKey, params.agentDir); + profileId = existingProfileId; hasCredential = true; } } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "zai" + ) { + await setZaiApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("zai"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setZaiApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Z.AI API key", validate: validateApiKeyInput, }); await setZaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "zai", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "zai:default", - provider: "zai", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, @@ -415,7 +625,9 @@ export async function applyAuthChoiceApiProviders( ...config.agents?.defaults?.models, [ZAI_DEFAULT_MODEL_REF]: { ...config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF], - alias: config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF]?.alias ?? "GLM", + alias: + config.agents?.defaults?.models?.[ZAI_DEFAULT_MODEL_REF] + ?.alias ?? "GLM", }, }, }, @@ -432,20 +644,60 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "synthetic-api-key") { - if (params.opts?.token && params.opts?.tokenProvider === "synthetic") { - await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir); - } else { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "synthetic", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "synthetic:default"; + let hasCredential = false; + + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing Synthetic authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + profileId = existingProfileId; + hasCredential = true; + } + } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "synthetic" + ) { + await setSyntheticApiKey( + String(params.opts.token).trim(), + params.agentDir, + ); + hasCredential = true; + } + + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Synthetic API key", validate: (value) => (value?.trim() ? undefined : "Required"), }); await setSyntheticApiKey(String(key).trim(), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "synthetic", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "synthetic:default", - provider: "synthetic", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, @@ -464,10 +716,41 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "venice-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "venice", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "venice:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "venice") { - await setVeniceApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing Venice AI authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + profileId = existingProfileId; + hasCredential = true; + } + } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "venice" + ) { + await setVeniceApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } @@ -480,31 +763,36 @@ export async function applyAuthChoiceApiProviders( ].join("\n"), "Venice AI", ); - } - const envKey = resolveEnvApiKey("venice"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setVeniceApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; + const envKey = resolveEnvApiKey("venice"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setVeniceApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } } } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter Venice AI API key", validate: validateApiKeyInput, }); await setVeniceApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "venice", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "venice:default", - provider: "venice", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, @@ -523,9 +811,41 @@ export async function applyAuthChoiceApiProviders( } if (authChoice === "opencode-zen") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "opencode", + }); + const existingProfileId = profileOrder.find((pid) => + Boolean(store.profiles[pid]), + ); + let profileId = "opencode:default"; let hasCredential = false; - if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") { - await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing OpenCode Zen authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + profileId = existingProfileId; + hasCredential = true; + } + } + + if ( + !hasCredential && + params.opts?.token && + params.opts?.tokenProvider === "opencode" + ) { + await setOpencodeZenApiKey( + normalizeApiKeyInput(params.opts.token), + params.agentDir, + ); hasCredential = true; } @@ -538,30 +858,39 @@ export async function applyAuthChoiceApiProviders( ].join("\n"), "OpenCode Zen", ); - } - const envKey = resolveEnvApiKey("opencode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; + + const envKey = resolveEnvApiKey("opencode"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } } } + if (!hasCredential) { const key = await params.prompter.text({ message: "Enter OpenCode Zen API key", validate: validateApiKeyInput, }); - await setOpencodeZenApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setOpencodeZenApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "opencode", + mode: "api_key", + }); } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "opencode:default", - provider: "opencode", - mode: "api_key", - }); { const applied = await applyDefaultModelChoice({ config: nextConfig, diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 30a1591b2..b6347143c 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -1,6 +1,13 @@ import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; +declare const process: any; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; +import { + applyAuthProfileConfig, + ensureAuthProfileStore, +} from "./onboard-auth.js"; export async function applyAuthChoiceGitHubCopilot( params: ApplyAuthChoiceParams, @@ -17,19 +24,50 @@ export async function applyAuthChoiceGitHubCopilot( "GitHub Copilot", ); - if (!process.stdin.isTTY) { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const existing = store.profiles["github-copilot:github"]; + if (existing && existing.type === "token") { + const useExisting = await params.prompter.confirm({ + message: `Use existing GitHub Copilot authentication (${existing.email ?? "authorized"})?`, + initialValue: true, + }); + if (useExisting) { + // already has profile, skip login + } else if (!process.stdin.isTTY) { + await params.prompter.note( + "GitHub Copilot login requires an interactive TTY.", + "GitHub Copilot", + ); + return { config: nextConfig }; + } else { + try { + await githubCopilotLoginCommand({ yes: true }, params.runtime); + } catch (err) { + await params.prompter.note( + `GitHub Copilot login failed: ${String(err)}`, + "GitHub Copilot", + ); + return { config: nextConfig }; + } + } + } else if (!process.stdin.isTTY) { await params.prompter.note( "GitHub Copilot login requires an interactive TTY.", "GitHub Copilot", ); return { config: nextConfig }; - } - - try { - await githubCopilotLoginCommand({ yes: true }, params.runtime); - } catch (err) { - await params.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot"); - return { config: nextConfig }; + } else { + try { + await githubCopilotLoginCommand({ yes: true }, params.runtime); + } catch (err) { + await params.prompter.note( + `GitHub Copilot login failed: ${String(err)}`, + "GitHub Copilot", + ); + return { config: nextConfig }; + } } nextConfig = applyAuthProfileConfig(nextConfig, { @@ -55,7 +93,10 @@ export async function applyAuthChoiceGitHubCopilot( }, }, }; - await params.prompter.note(`Default model set to ${model}`, "Model configured"); + await params.prompter.note( + `Default model set to ${model}`, + "Model configured", + ); } return { config: nextConfig }; diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index bf69179ca..406a0a72d 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -4,7 +4,10 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; import { applyAuthProfileConfig, @@ -12,6 +15,7 @@ import { applyMinimaxApiProviderConfig, applyMinimaxConfig, applyMinimaxProviderConfig, + ensureAuthProfileStore, setMinimaxApiKey, } from "./onboard-auth.js"; @@ -34,8 +38,26 @@ export async function applyAuthChoiceMiniMax( params.authChoice === "minimax-api-lightning" ) { const modelId = - params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.1-lightning" : "MiniMax-M2.1"; + params.authChoice === "minimax-api-lightning" + ? "MiniMax-M2.1-lightning" + : "MiniMax-M2.1"; let hasCredential = false; + + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const existingProfileId = "minimax:default"; + if (store.profiles[existingProfileId]) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing MiniMax authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + hasCredential = true; + } + } + const envKey = resolveEnvApiKey("minimax"); if (envKey) { const useExisting = await params.prompter.confirm({ @@ -52,7 +74,10 @@ export async function applyAuthChoiceMiniMax( message: "Enter MiniMax API key", validate: validateApiKeyInput, }); - await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + await setMinimaxApiKey( + normalizeApiKeyInput(String(key)), + params.agentDir, + ); } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "minimax:default", @@ -66,7 +91,8 @@ export async function applyAuthChoiceMiniMax( setDefaultModel: params.setDefaultModel, defaultModel: modelRef, applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId), - applyProviderConfig: (config) => applyMinimaxApiProviderConfig(config, modelId), + applyProviderConfig: (config) => + applyMinimaxApiProviderConfig(config, modelId), noteAgentModel, prompter: params.prompter, }); diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index 9e618e4c5..f928dcc12 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -1,8 +1,16 @@ import { isRemoteEnvironment } from "./oauth-env.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +declare const process: any; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; import { loginChutes } from "./chutes-oauth.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { applyAuthProfileConfig, writeOAuthCredentials } from "./onboard-auth.js"; +import { + applyAuthProfileConfig, + ensureAuthProfileStore, + writeOAuthCredentials, +} from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; export async function applyAuthChoiceOAuth( @@ -12,8 +20,10 @@ export async function applyAuthChoiceOAuth( let nextConfig = params.config; const isRemote = isRemoteEnvironment(); const redirectUri = - process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || "http://127.0.0.1:1456/oauth-callback"; - const scopes = process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; + process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || + "http://127.0.0.1:1456/oauth-callback"; + const scopes = + process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; const clientId = process.env.CHUTES_CLIENT_ID?.trim() || String( @@ -25,6 +35,29 @@ export async function applyAuthChoiceOAuth( ).trim(); const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const existingProfileId = Object.keys(store.profiles).find((id) => + id.startsWith("chutes:"), + ); + + if (existingProfileId) { + const existing = store.profiles[existingProfileId]; + const useExisting = await params.prompter.confirm({ + message: `Use existing Chutes authentication (${"email" in existing && existing.email ? existing.email : existingProfileId})?`, + initialValue: true, + }); + if (useExisting) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: existingProfileId, + provider: "chutes", + mode: "oauth", + }); + return { config: nextConfig }; + } + } + await params.prompter.note( isRemote ? [ @@ -64,7 +97,7 @@ export async function applyAuthChoiceOAuth( manual: isRemote, onAuth, onPrompt, - onProgress: (msg) => spin.update(msg), + onProgress: (msg: string) => spin.update(msg), }); spin.stop("Chutes OAuth complete"); diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index d00208fab..adb86fa7a 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -1,4 +1,6 @@ +// @ts-ignore import { loginOpenAICodex } from "@mariozechner/pi-ai"; +declare const process: any; import { resolveEnvApiKey } from "../agents/model-auth.js"; import { upsertSharedEnvVar } from "../infra/env-file.js"; import { isRemoteEnvironment } from "./oauth-env.js"; @@ -7,9 +9,16 @@ import { normalizeApiKeyInput, validateApiKeyInput, } from "./auth-choice.api-key.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { applyAuthProfileConfig, writeOAuthCredentials } from "./onboard-auth.js"; +import { + applyAuthProfileConfig, + ensureAuthProfileStore, + writeOAuthCredentials, +} from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; import { applyOpenAICodexModelDefault, @@ -96,6 +105,29 @@ export async function applyAuthChoiceOpenAI( ].join("\n"), "OpenAI Codex OAuth", ); + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const existing = store.profiles["openai-codex:default"]; + if (existing && existing.type === "oauth") { + const useExisting = await params.prompter.confirm({ + message: `Use existing OpenAI Codex authentication (${existing.email ?? "authorized"})?`, + initialValue: true, + }); + if (useExisting) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "openai-codex:default", + provider: "openai-codex", + mode: "oauth", + }); + if (params.setDefaultModel) { + const applied = applyOpenAICodexModelDefault(nextConfig); + nextConfig = applied.next; + } + return { config: nextConfig }; + } + } + const spin = params.prompter.progress("Starting OAuth flow…"); try { const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ @@ -110,7 +142,7 @@ export async function applyAuthChoiceOpenAI( const creds = await loginOpenAICodex({ onAuth, onPrompt, - onProgress: (msg) => spin.update(msg), + onProgress: (msg: string) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); if (creds) { diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index b9137af01..b2ffff7de 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -11,8 +11,14 @@ import type { MoltbotConfig } from "../config/config.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { resolvePluginProviders } from "../plugins/providers.js"; import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { applyAuthProfileConfig } from "./onboard-auth.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, +} from "./auth-choice.apply.js"; +import { + applyAuthProfileConfig, + ensureAuthProfileStore, +} from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; import { isRemoteEnvironment } from "./oauth-env.js"; @@ -31,16 +37,23 @@ function resolveProviderMatch( ): ProviderPlugin | null { const normalized = normalizeProviderId(rawProvider); return ( - providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => normalizeProviderId(provider.id) === normalized, + ) ?? providers.find( (provider) => - provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + provider.aliases?.some( + (alias) => normalizeProviderId(alias) === normalized, + ) ?? false, ) ?? null ); } -function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null { +function pickAuthMethod( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { const raw = rawMethod?.trim(); if (!raw) return null; const normalized = raw.toLowerCase(); @@ -85,8 +98,13 @@ function applyDefaultModel(cfg: MoltbotConfig, model: string): MoltbotConfig { ...cfg.agents?.defaults, models, model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + ...(existingModel && + typeof existingModel === "object" && + "fallbacks" in existingModel + ? { + fallbacks: (existingModel as { fallbacks?: string[] }) + .fallbacks, + } : undefined), primary: model, }, @@ -100,6 +118,7 @@ export async function applyAuthChoicePluginProvider( options: PluginProviderAuthChoiceOptions, ): Promise { if (params.authChoice !== options.authChoice) return null; + let agentModelOverride: string | undefined; const enableResult = enablePluginInConfig(params.config, options.pluginId); let nextConfig = enableResult.config; @@ -115,11 +134,17 @@ export async function applyAuthChoicePluginProvider( const defaultAgentId = resolveDefaultAgentId(nextConfig); const agentDir = params.agentDir ?? - (agentId === defaultAgentId ? resolveMoltbotAgentDir() : resolveAgentDir(nextConfig, agentId)); + (agentId === defaultAgentId + ? resolveMoltbotAgentDir() + : resolveAgentDir(nextConfig, agentId)); const workspaceDir = - resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + resolveAgentWorkspaceDir(nextConfig, agentId) ?? + resolveDefaultAgentWorkspaceDir(); - const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); + const providers = resolvePluginProviders({ + config: nextConfig, + workspaceDir, + }); const provider = resolveProviderMatch(providers, options.providerId); if (!provider) { await params.prompter.note( @@ -131,10 +156,56 @@ export async function applyAuthChoicePluginProvider( const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0]; if (!method) { - await params.prompter.note(`${options.label} auth method missing.`, options.label); + await params.prompter.note( + `${options.label} auth method missing.`, + options.label, + ); return { config: nextConfig }; } + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const existingProfiles = Object.entries(store.profiles).filter( + ([, p]) => + normalizeProviderId((p as any).provider) === + normalizeProviderId(options.providerId), + ); + + if (existingProfiles.length > 0) { + const defaultProfile = + existingProfiles.find(([id]) => id === `${options.providerId}:default`) ?? + existingProfiles[0]; + const [profileId, profile] = defaultProfile as [string, any]; + + const useExisting = await params.prompter.confirm({ + message: `Use existing ${options.label} authentication (${"email" in profile && profile.email ? profile.email : profileId})?`, + initialValue: true, + }); + + if (useExisting) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: profile.provider, + mode: (profile.type === "token" ? "token" : profile.type) as any, + ...("email" in profile && profile.email + ? { email: profile.email as string } + : {}), + }); + + if (params.setDefaultModel && provider.models?.models?.[0]?.id) { + nextConfig = applyDefaultModel( + nextConfig, + provider.models.models[0].id, + ); + } else if (params.agentId && provider.models?.models?.[0]?.id) { + agentModelOverride = provider.models.models[0].id; + } + + return { config: nextConfig, agentModelOverride }; + } + } + const isRemote = isRemoteEnvironment(); const result = await method.run({ config: nextConfig, @@ -165,18 +236,21 @@ export async function applyAuthChoicePluginProvider( nextConfig = applyAuthProfileConfig(nextConfig, { profileId: profile.profileId, provider: profile.credential.provider, - mode: profile.credential.type === "token" ? "token" : profile.credential.type, + mode: + profile.credential.type === "token" ? "token" : profile.credential.type, ...("email" in profile.credential && profile.credential.email ? { email: profile.credential.email } : {}), }); } - let agentModelOverride: string | undefined; if (result.defaultModel) { if (params.setDefaultModel) { nextConfig = applyDefaultModel(nextConfig, result.defaultModel); - await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured"); + await params.prompter.note( + `Default model set to ${result.defaultModel}`, + "Model configured", + ); } else if (params.agentId) { agentModelOverride = result.defaultModel; await params.prompter.note( diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index bae5c8492..13d4c337c 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; +// @ts-ignore import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; @@ -43,76 +44,83 @@ async function waitForLocalCallback(params: { }): Promise<{ code: string; state: string }> { const redirectUrl = new URL(params.redirectUri); if (redirectUrl.protocol !== "http:") { - throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); + throw new Error( + `Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`, + ); } const hostname = redirectUrl.hostname || "127.0.0.1"; const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const expectedPath = redirectUrl.pathname || "/"; - return await new Promise<{ code: string; state: string }>((resolve, reject) => { - let timeout: NodeJS.Timeout | null = null; - const server = createServer((req, res) => { - try { - const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); - if (requestUrl.pathname !== expectedPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not found"); - return; - } + return await new Promise<{ code: string; state: string }>( + (resolve, reject) => { + // @ts-ignore + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req: any, res: any) => { + try { + const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not found"); + return; + } - const code = requestUrl.searchParams.get("code")?.trim(); - const state = requestUrl.searchParams.get("state")?.trim(); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Missing code"); - return; - } - if (!state || state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Invalid state"); - return; - } + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Missing code"); + return; + } + if (!state || state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Invalid state"); + return; + } - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end( - [ - "", - "", - "

Chutes OAuth complete

", - "

You can close this window and return to moltbot.

", - ].join(""), - ); - if (timeout) clearTimeout(timeout); - server.close(); - resolve({ code, state }); - } catch (err) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + [ + "", + "", + "

Chutes OAuth complete

", + "

You can close this window and return to moltbot.

", + ].join(""), + ); + if (timeout) clearTimeout(timeout); + server.close(); + resolve({ code, state }); + } catch (err) { + if (timeout) clearTimeout(timeout); + server.close(); + reject(err); + } + }); + + server.once("error", (err: any) => { if (timeout) clearTimeout(timeout); server.close(); reject(err); - } - }); + }); + server.listen(port, hostname, () => { + params.onProgress?.( + `Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`, + ); + }); - server.once("error", (err) => { - if (timeout) clearTimeout(timeout); - server.close(); - reject(err); - }); - server.listen(port, hostname, () => { - params.onProgress?.(`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`); - }); - - timeout = setTimeout(() => { - try { - server.close(); - } catch {} - reject(new Error("OAuth callback timeout")); - }, params.timeoutMs); - }); + timeout = setTimeout(() => { + try { + server.close(); + } catch {} + reject(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }, + ); } export async function loginChutes(params: { @@ -127,7 +135,8 @@ export async function loginChutes(params: { fetchFn?: typeof fetch; }): Promise { const createPkce = params.createPkce ?? generateChutesPkce; - const createState = params.createState ?? (() => randomBytes(16).toString("hex")); + const createState = + params.createState ?? (() => randomBytes(16).toString("hex")); const { verifier, challenge } = createPkce(); const state = createState(); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index b122d89cf..19ee5bcfe 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,8 +1,12 @@ +export { ensureAuthProfileStore } from "../agents/auth-profiles.js"; export { SYNTHETIC_DEFAULT_MODEL_ID, SYNTHETIC_DEFAULT_MODEL_REF, } from "../agents/synthetic-models.js"; -export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; +export { + VENICE_DEFAULT_MODEL_ID, + VENICE_DEFAULT_MODEL_REF, +} from "../agents/venice-models.js"; export { applyAuthProfileConfig, applyKimiCodeConfig, diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index bf751bdab..dd3b7aa97 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +declare const process: any; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { MoltbotConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -49,7 +53,10 @@ function resolveLocalPath( return null; } -function addPluginLoadPath(cfg: MoltbotConfig, pluginPath: string): MoltbotConfig { +function addPluginLoadPath( + cfg: MoltbotConfig, + pluginPath: string, +): MoltbotConfig { const existing = cfg.plugins?.load?.paths ?? []; const merged = Array.from(new Set([...existing, pluginPath])); return { @@ -71,7 +78,11 @@ async function promptInstallChoice(params: { prompter: WizardPrompter; }): Promise { const { entry, localPath, prompter, defaultChoice } = params; - const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath + const localOptions: Array<{ + value: InstallChoice; + label: string; + hint?: string; + }> = localPath ? [ { value: "local", @@ -80,11 +91,12 @@ async function promptInstallChoice(params: { }, ] : []; - const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [ - { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, - ...localOptions, - { value: "skip", label: "Skip for now" }, - ]; + const options: Array<{ value: InstallChoice; label: string; hint?: string }> = + [ + { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, + ...localOptions, + { value: "skip", label: "Skip for now" }, + ]; const initialValue: InstallChoice = defaultChoice === "local" && !localPath ? "npm" : defaultChoice; return await prompter.select({ @@ -149,8 +161,8 @@ export async function ensureOnboardingPluginInstalled(params: { const result = await installPluginFromNpmSpec({ spec: entry.install.npmSpec, logger: { - info: (msg) => runtime.log?.(msg), - warn: (msg) => runtime.log?.(msg), + info: (msg: string) => runtime.log?.(msg), + warn: (msg: string) => runtime.log?.(msg), }, }); @@ -193,17 +205,18 @@ export function reloadOnboardingPluginRegistry(params: { workspaceDir?: string; }): void { const workspaceDir = - params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const log = createSubsystemLogger("plugins"); loadMoltbotPlugins({ config: params.cfg, workspaceDir, cache: false, logger: { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), + info: (msg: string) => log.info(msg), + warn: (msg: string) => log.warn(msg), + error: (msg: string) => log.error(msg), + debug: (msg: string) => log.debug(msg), }, }); } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e269742b2..aea065f1c 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -9,12 +9,27 @@ import { loadExecApprovals } from "./controllers/exec-approvals"; import { loadPresence } from "./controllers/presence"; import { loadSessions } from "./controllers/sessions"; import { loadSkills } from "./controllers/skills"; -import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation"; +import { + inferBasePathFromPathname, + normalizeBasePath, + normalizePath, + pathForTab, + tabFromPath, + type Tab, +} from "./navigation"; import { saveSettings, type UiSettings } from "./storage"; import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme"; -import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition"; +import { + startThemeTransition, + type ThemeTransitionContext, +} from "./theme-transition"; import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; -import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling"; +import { + startLogsPolling, + stopLogsPolling, + startDebugPolling, + stopDebugPolling, +} from "./app-polling"; import { refreshChat } from "./app-chat"; import type { MoltbotApp } from "./app"; @@ -31,6 +46,7 @@ type SettingsHost = { eventLog: unknown[]; eventLogBuffer: unknown[]; basePath: string; + password: string; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; }; @@ -38,7 +54,8 @@ type SettingsHost = { export function applySettings(host: SettingsHost, next: UiSettings) { const normalized = { ...next, - lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main", + lastActiveSessionKey: + next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main", }; host.settings = normalized; saveSettings(normalized); @@ -78,6 +95,10 @@ export function applySettingsFromUrl(host: SettingsHost) { const password = passwordRaw.trim(); if (password) { (host as { password: string }).password = password; + applySettings(host as unknown as Parameters[0], { + ...host.settings, + password, + }); } params.delete("password"); shouldCleanUrl = true; @@ -115,10 +136,14 @@ export function setTab(host: SettingsHost, next: Tab) { if (next === "chat") host.chatHasAutoScrolled = false; if (next === "logs") startLogsPolling(host as unknown as Parameters[0]); - else stopLogsPolling(host as unknown as Parameters[0]); + else + stopLogsPolling(host as unknown as Parameters[0]); if (next === "debug") - startDebugPolling(host as unknown as Parameters[0]); - else stopDebugPolling(host as unknown as Parameters[0]); + startDebugPolling( + host as unknown as Parameters[0], + ); + else + stopDebugPolling(host as unknown as Parameters[0]); void refreshActiveTab(host); syncUrlWithTab(host, next, false); } @@ -144,8 +169,10 @@ export function setTheme( export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "overview") await loadOverview(host); if (host.tab === "channels") await loadChannelsTab(host); - if (host.tab === "instances") await loadPresence(host as unknown as MoltbotApp); - if (host.tab === "sessions") await loadSessions(host as unknown as MoltbotApp); + if (host.tab === "instances") + await loadPresence(host as unknown as MoltbotApp); + if (host.tab === "sessions") + await loadSessions(host as unknown as MoltbotApp); if (host.tab === "cron") await loadCron(host); if (host.tab === "skills") await loadSkills(host as unknown as MoltbotApp); if (host.tab === "nodes") { @@ -193,7 +220,10 @@ export function syncThemeWithSettings(host: SettingsHost) { applyResolvedTheme(host, resolveTheme(host.theme)); } -export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { +export function applyResolvedTheme( + host: SettingsHost, + resolved: ResolvedTheme, +) { host.themeResolved = resolved; if (typeof document === "undefined") return; const root = document.documentElement; @@ -202,7 +232,8 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) } export function attachThemeListener(host: SettingsHost) { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + if (typeof window === "undefined" || typeof window.matchMedia !== "function") + return; host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); host.themeMediaHandler = (event) => { if (host.theme !== "system") return; @@ -234,7 +265,8 @@ export function detachThemeListener(host: SettingsHost) { export function syncTabWithLocation(host: SettingsHost, replace: boolean) { if (typeof window === "undefined") return; - const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat"; + const resolved = + tabFromPath(window.location.pathname, host.basePath) ?? "chat"; setTabFromRoute(host, resolved); syncUrlWithTab(host, resolved, replace); } @@ -263,10 +295,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) { if (next === "chat") host.chatHasAutoScrolled = false; if (next === "logs") startLogsPolling(host as unknown as Parameters[0]); - else stopLogsPolling(host as unknown as Parameters[0]); + else + stopLogsPolling(host as unknown as Parameters[0]); if (next === "debug") - startDebugPolling(host as unknown as Parameters[0]); - else stopDebugPolling(host as unknown as Parameters[0]); + startDebugPolling( + host as unknown as Parameters[0], + ); + else + stopDebugPolling(host as unknown as Parameters[0]); if (host.connected) void refreshActiveTab(host); } diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..01dccdf0b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -24,7 +24,11 @@ import type { StatusSummary, NostrProfile, } from "./types"; -import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; +import { + type ChatAttachment, + type ChatQueueItem, + type CronFormState, +} from "./ui-types"; import type { EventLogEntry } from "./app-events"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; import type { @@ -93,13 +97,18 @@ function resolveOnboardingMode(): boolean { const raw = params.get("onboarding"); if (!raw) return false; const normalized = raw.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; + return ( + normalized === "1" || + normalized === "true" || + normalized === "yes" || + normalized === "on" + ); } @customElement("moltbot-app") export class MoltbotApp extends LitElement { @state() settings: UiSettings = loadSettings(); - @state() password = ""; + @state() password = this.settings.password ?? ""; @state() tab: Tab = "chat"; @state() onboarding = resolveOnboardingMode(); @state() connected = false; @@ -125,7 +134,9 @@ export class MoltbotApp extends LitElement { @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; - @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null; + @state() compactionStatus: + | import("./app-tool-stream").CompactionStatus + | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; @@ -263,7 +274,8 @@ export class MoltbotApp extends LitElement { this as unknown as Parameters[0], ); private themeMedia: MediaQueryList | null = null; - private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; + private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = + null; private topbarObserver: ResizeObserver | null = null; createRenderRoot() { @@ -276,11 +288,15 @@ export class MoltbotApp extends LitElement { } protected firstUpdated() { - handleFirstUpdated(this as unknown as Parameters[0]); + handleFirstUpdated( + this as unknown as Parameters[0], + ); } disconnectedCallback() { - handleDisconnected(this as unknown as Parameters[0]); + handleDisconnected( + this as unknown as Parameters[0], + ); super.disconnectedCallback(); } @@ -339,7 +355,10 @@ export class MoltbotApp extends LitElement { } setTab(next: Tab) { - setTabInternal(this as unknown as Parameters[0], next); + setTabInternal( + this as unknown as Parameters[0], + next, + ); } setTheme(next: ThemeMode, context?: Parameters[2]) { @@ -430,7 +449,9 @@ export class MoltbotApp extends LitElement { handleNostrProfileToggleAdvancedInternal(this); } - async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { + async handleExecApprovalDecision( + decision: "allow-once" | "allow-always" | "deny", + ) { const active = this.execApprovalQueue[0]; if (!active || !this.client || this.execApprovalBusy) return; this.execApprovalBusy = true; @@ -440,7 +461,9 @@ export class MoltbotApp extends LitElement { id: active.id, decision, }); - this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id); + this.execApprovalQueue = this.execApprovalQueue.filter( + (entry) => entry.id !== active.id, + ); } catch (err) { this.execApprovalError = `Exec approval failed: ${String(err)}`; } finally { diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 4b1836bfb..6c6a66d4b 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -5,6 +5,7 @@ import type { ThemeMode } from "./theme"; export type UiSettings = { gatewayUrl: string; token: string; + password?: string; sessionKey: string; lastActiveSessionKey: string; theme: ThemeMode; @@ -24,6 +25,7 @@ export function loadSettings(): UiSettings { const defaults: UiSettings = { gatewayUrl: defaultUrl, token: "", + password: "", sessionKey: "main", lastActiveSessionKey: "main", theme: "system", @@ -44,6 +46,10 @@ export function loadSettings(): UiSettings { ? parsed.gatewayUrl.trim() : defaults.gatewayUrl, token: typeof parsed.token === "string" ? parsed.token : defaults.token, + password: + typeof parsed.password === "string" + ? parsed.password + : defaults.password, sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim()