From d353638f39614d6f0531fcdcbdc996d87bba60a4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 15 Aug 2026 01:59:26 +0300 Subject: [PATCH] fix: integrate Claude CLI provider state --- .../sections/openchamber/DefaultsSettings.tsx | 10 +--- .../providers/ProviderOAuthMethods.tsx | 6 +- .../sections/providers/ProvidersPage.test.ts | 6 ++ .../sections/providers/ProvidersPage.tsx | 5 +- .../sections/providers/provider-oauth.test.ts | 8 +++ .../sections/providers/provider-oauth.ts | 3 + .../sections/providers/providerAuth.ts | 3 + packages/ui/src/hooks/useMessageTTS.ts | 4 +- packages/ui/src/lib/gitApi.ts | 9 +-- packages/ui/src/lib/smallModel.ts | 6 +- packages/ui/src/lib/smallModelRequest.ts | 27 +++++++++ .../server/lib/opencode/claude-cli-auth.js | 60 +++++++++++++++++++ .../lib/opencode/claude-cli-auth.test.js | 54 +++++++++++++++++ packages/web/server/lib/opencode/routes.js | 5 +- packages/web/server/lib/small-model/index.js | 8 +++ .../web/server/lib/small-model/index.test.js | 32 +++++++++- packages/web/server/lib/small-model/routes.js | 4 +- 17 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/lib/smallModelRequest.ts create mode 100644 packages/web/server/lib/opencode/claude-cli-auth.js create mode 100644 packages/web/server/lib/opencode/claude-cli-auth.test.js diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index a3001a89..5d0578cc 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -52,7 +52,7 @@ export const DefaultsSettings: React.FC = () => { const [defaultAgent, setDefaultAgent] = React.useState(); const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true); const [smallModelOverride, setSmallModelOverride] = React.useState(); - const [smallModelProviders, setSmallModelProviders] = React.useState(); + const [smallModelProviders, setSmallModelProviders] = React.useState([]); const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState(); const [isLoading, setIsLoading] = React.useState(true); @@ -274,13 +274,9 @@ export const DefaultsSettings: React.FC = () => { () => getDisplayModel(walkthroughModelOverride), [walkthroughModelOverride] ); - React.useEffect(() => { - // Both pickers filter by the same authenticated-provider list, so either - // one being open is reason enough to fetch it. // Both pickers filter by the same authenticated-provider list, and the // walkthrough picker is always visible, so this is always worth fetching. - if (smallModelProviders !== undefined) return; let cancelled = false; (async () => { try { @@ -291,13 +287,13 @@ export const DefaultsSettings: React.FC = () => { setSmallModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string')); } } catch { - // leave undefined — picker falls back to showing all providers + // Fail closed: never offer providers whose credentials were not verified. } })(); return () => { cancelled = true; }; - }, [smallModelProviders]); + }, []); const availableVariants = React.useMemo(() => { if (!parsedModel.providerId || !parsedModel.modelId) return []; diff --git a/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx index a293fdbe..dd967136 100644 --- a/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx +++ b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx @@ -20,6 +20,7 @@ import { firstUnansweredPrompt, parseAuthPrompts, parseAuthorization, + shouldOpenAuthorizationUrl, visiblePrompts, type AuthPrompt, type OAuthAuthorization, @@ -173,7 +174,10 @@ export const ProviderOAuthMethods: React.FC = ({ return; } - if (authorization.url) { + // Claude Code CLI owns its OAuth flow and opens the browser itself. Its + // plugin URL is informational only; opening it creates a misleading docs + // tab alongside the real sign-in page. + if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) { void openExternalUrl(authorization.url); } diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index 448dcc6e..f0a5d093 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -4,6 +4,7 @@ import { getOAuthAuthMethods, normalizeAuthType, parseAuthPayload, + requiresOpenCodeRestartAfterOAuth, shouldShowApiKeyAuth, } from './providerAuth'; @@ -57,4 +58,9 @@ describe('provider auth method helpers', () => { { method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 }, ]); }); + + test('Claude CLI OAuth does not require an OpenCode restart', () => { + expect(requiresOpenCodeRestartAfterOAuth('claude-code')).toBe(false); + expect(requiresOpenCodeRestartAfterOAuth('github-copilot')).toBe(true); + }); }); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 2dfc607d..e680b506 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -27,6 +27,7 @@ import { shouldLoadAvailableProviders } from './providerAvailability'; import { getOAuthAuthMethods, parseAuthPayload, + requiresOpenCodeRestartAfterOAuth, shouldShowApiKeyAuth, type AuthMethod, type OAuthAuthMethodEntry, @@ -471,7 +472,9 @@ export const ProvidersPage: React.FC = () => { const handleOAuthConnected = (providerId: string) => { setShowAuthPanel(false); - recordDeferredOpenCodeRestart('providers', { id: providerId }); + if (requiresOpenCodeRestartAfterOAuth(providerId)) { + recordDeferredOpenCodeRestart('providers', { id: providerId }); + } setSelectedProvider(providerId); }; diff --git a/packages/ui/src/components/sections/providers/provider-oauth.test.ts b/packages/ui/src/components/sections/providers/provider-oauth.test.ts index 93f8e762..e5291b12 100644 --- a/packages/ui/src/components/sections/providers/provider-oauth.test.ts +++ b/packages/ui/src/components/sections/providers/provider-oauth.test.ts @@ -7,11 +7,19 @@ import { isPromptVisible, parseAuthPrompts, parseAuthorization, + shouldOpenAuthorizationUrl, visiblePrompts, type AuthPrompt, type ProviderOAuthTranslator, } from './provider-oauth'; +describe('shouldOpenAuthorizationUrl', () => { + test('lets Claude Code CLI own browser launch', () => { + expect(shouldOpenAuthorizationUrl('claude-code', 'https://docs.example')).toBe(false); + expect(shouldOpenAuthorizationUrl('github-copilot', 'https://github.com/login')).toBe(true); + }); +}); + /** Mirrors the github-copilot auth method shipped by OpenCode. */ const copilotPrompts = [ { diff --git a/packages/ui/src/components/sections/providers/provider-oauth.ts b/packages/ui/src/components/sections/providers/provider-oauth.ts index f85a8ef6..4e7f130f 100644 --- a/packages/ui/src/components/sections/providers/provider-oauth.ts +++ b/packages/ui/src/components/sections/providers/provider-oauth.ts @@ -29,6 +29,9 @@ export interface OAuthAuthorization { userCode?: string; } +export const shouldOpenAuthorizationUrl = (providerId: string, url?: string): boolean => + Boolean(url) && providerId !== 'claude-code'; + export interface AuthPromptOption { label: string; value: string; diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index 18ca4a9c..ed6561b6 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -57,3 +57,6 @@ export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry methods .map((method, methodIndex) => ({ method, methodIndex })) .filter(({ method }) => normalizeAuthType(method) === 'oauth'); + +export const requiresOpenCodeRestartAfterOAuth = (providerId: string): boolean => + providerId !== 'claude-code'; diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index b6b4037d..88e10615 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -12,7 +12,7 @@ import { useSayTTS } from './useSayTTS'; import { useLocalTTS } from './useLocalTTS'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { sanitizeForTTS } from '@/lib/voice/summarize'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { requestSmallModel } from '@/lib/smallModelRequest'; // Below this length the reply is comfortable to listen to as-is; summarizing // would only add latency. @@ -25,7 +25,7 @@ async function summarizeForSpeech( preferred: { providerID?: string; modelID?: string }, ): Promise { try { - const response = await runtimeFetch('/api/small-model/generate', { + const response = await requestSmallModel({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index b663c8bd..0075a959 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -3,6 +3,7 @@ import * as gitHttp from './gitApiHttp'; import { opencodeClient } from './opencode/client'; import { renderMagicPrompt } from './magicPrompts'; import { runtimeFetch } from './runtime-fetch'; +import { requestSmallModel } from './smallModelRequest'; import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -283,7 +284,7 @@ export async function generateCommitMessage( try { const diffs = await collectSelectedFileDiffs(directory, files); const { currentProviderId, currentModelId } = useConfigStore.getState(); - const response = await runtimeFetch('/api/small-model/generate', { + const response = await requestSmallModel({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -293,7 +294,7 @@ export async function generateCommitMessage( ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), ...(currentModelId ? { preferredModelID: currentModelId } : {}), }), - }); + }, { silentStatuses: [404] }); if (response.status === 404) { // No authenticated provider has a small model — fall back to the @@ -411,7 +412,7 @@ export async function generatePullRequestDescription( try { const { currentProviderId, currentModelId } = useConfigStore.getState(); - const response = await runtimeFetch('/api/small-model/generate', { + const response = await requestSmallModel({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -421,7 +422,7 @@ export async function generatePullRequestDescription( ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), ...(currentModelId ? { preferredModelID: currentModelId } : {}), }), - }); + }, { silentStatuses: [404] }); if (response.status === 404) { // No authenticated provider has a small model — fall back to the diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index e4e2a487..5eb38fab 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -1,4 +1,4 @@ -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { requestSmallModel } from '@/lib/smallModelRequest'; import { useConfigStore } from '@/stores/useConfigStore'; import { getSessionLastAssistantModel } from '@/sync/session-actions'; @@ -34,7 +34,7 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin const { currentProviderId, currentModelId } = useConfigStore.getState(); const preferredProviderID = sessionModel?.providerID || currentProviderId || ''; const preferredModelID = sessionModel?.modelID || currentModelId || ''; - const response = await runtimeFetch('/api/small-model/generate', { + const response = await requestSmallModel({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -77,7 +77,7 @@ const GOAL_OBJECTIVE_SYSTEM_PROMPT = [ export async function distillGoalObjective(planContent: string): Promise { try { const { currentProviderId, currentModelId } = useConfigStore.getState(); - const response = await runtimeFetch('/api/small-model/generate', { + const response = await requestSmallModel({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/packages/ui/src/lib/smallModelRequest.ts b/packages/ui/src/lib/smallModelRequest.ts new file mode 100644 index 00000000..04a8af2a --- /dev/null +++ b/packages/ui/src/lib/smallModelRequest.ts @@ -0,0 +1,27 @@ +import { toast } from 'sonner'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +const SMALL_MODEL_TOAST_ID = 'small-model-unavailable'; + +const notifySmallModelUnavailable = (): void => { + toast.error('Small Model unavailable', { + id: SMALL_MODEL_TOAST_ID, + description: 'Choose another model in Settings → Sessions → Small Model and try again.', + }); +}; + +export async function requestSmallModel( + init: RequestInit, + options: { silentStatuses?: number[] } = {}, +): Promise { + try { + const response = await runtimeFetch('/api/small-model/generate', init); + if (!response.ok && !options.silentStatuses?.includes(response.status)) { + notifySmallModelUnavailable(); + } + return response; + } catch (error) { + notifySmallModelUnavailable(); + throw error; + } +} diff --git a/packages/web/server/lib/opencode/claude-cli-auth.js b/packages/web/server/lib/opencode/claude-cli-auth.js new file mode 100644 index 00000000..e3e4ed43 --- /dev/null +++ b/packages/web/server/lib/opencode/claude-cli-auth.js @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; + +const readStatus = (spawnSyncFn, command, env) => spawnSyncFn(command, ['auth', 'status', '--json'], { + encoding: 'utf8', + timeout: 6000, + env, + windowsHide: true, +}); + +const resolveFromLoginShell = (spawnSyncFn, env, platform) => { + if (platform === 'win32') { + const result = spawnSyncFn('where', ['claude'], { + encoding: 'utf8', + timeout: 6000, + env, + windowsHide: true, + }); + return `${result.stdout || ''}`.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null; + } + + const shell = env.SHELL || '/bin/zsh'; + const result = spawnSyncFn(shell, ['-lic', 'command -v claude'], { + encoding: 'utf8', + timeout: 6000, + env, + windowsHide: true, + }); + return `${result.stdout || ''}`.trim().split(/\s+/).pop() || null; +}; + +export const getClaudeCliAuthStatus = ({ + spawnSyncFn = spawnSync, + env = process.env, + platform = process.platform, +} = {}) => { + const childEnv = { ...env }; + delete childEnv.ANTHROPIC_API_KEY; + delete childEnv.ANTHROPIC_AUTH_TOKEN; + delete childEnv.CLAUDE_CODE_OAUTH_TOKEN; + + try { + let result = readStatus(spawnSyncFn, 'claude', childEnv); + if (!`${result.stdout || ''}`.trim() && result.error) { + const resolved = resolveFromLoginShell(spawnSyncFn, childEnv, platform); + if (resolved) result = readStatus(spawnSyncFn, resolved, childEnv); + } + const output = `${result.stdout || ''}`.trim(); + if (!output) return { connected: false, reason: 'empty-status' }; + const payload = JSON.parse(output); + return { + connected: payload?.loggedIn === true, + reason: payload?.loggedIn === true ? 'logged-in' : 'logged-out', + }; + } catch (error) { + return { + connected: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +}; diff --git a/packages/web/server/lib/opencode/claude-cli-auth.test.js b/packages/web/server/lib/opencode/claude-cli-auth.test.js new file mode 100644 index 00000000..0cf7566a --- /dev/null +++ b/packages/web/server/lib/opencode/claude-cli-auth.test.js @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test'; + +import { getClaudeCliAuthStatus } from './claude-cli-auth.js'; + +describe('getClaudeCliAuthStatus', () => { + test('reports the authoritative Claude CLI login state', () => { + let invocation = null; + const status = getClaudeCliAuthStatus({ + env: { + PATH: '/usr/bin', + CLAUDE_CODE_OAUTH_TOKEN: 'must-not-leak', + }, + spawnSyncFn(command, args, options) { + invocation = { command, args, options }; + return { stdout: JSON.stringify({ loggedIn: true, authMethod: 'oauth' }) }; + }, + }); + + expect(status).toEqual({ connected: true, reason: 'logged-in' }); + expect(invocation.command).toBe('claude'); + expect(invocation.args).toEqual(['auth', 'status', '--json']); + expect(invocation.options.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + }); + + test('ignores a stale OpenCode marker when the CLI is logged out', () => { + const status = getClaudeCliAuthStatus({ + spawnSyncFn: () => ({ stdout: JSON.stringify({ loggedIn: false }) }), + }); + + expect(status).toEqual({ connected: false, reason: 'logged-out' }); + }); + + test('finds Claude through a login shell when a desktop PATH cannot', () => { + const invocations = []; + const status = getClaudeCliAuthStatus({ + env: { HOME: '/Users/test', PATH: '/usr/bin:/bin', SHELL: '/bin/zsh' }, + platform: 'darwin', + spawnSyncFn(command, args, options) { + invocations.push({ command, args, options }); + if (command === 'claude') return { stdout: '', error: new Error('spawnSync claude ENOENT') }; + if (command === '/bin/zsh') return { stdout: '/Users/test/.local/bin/claude\n' }; + return { stdout: JSON.stringify({ loggedIn: true, authMethod: 'claude.ai' }) }; + }, + }); + + expect(status).toEqual({ connected: true, reason: 'logged-in' }); + expect(invocations.map(({ command }) => command)).toEqual([ + 'claude', + '/bin/zsh', + '/Users/test/.local/bin/claude', + ]); + expect(invocations[1].args).toEqual(['-lic', 'command -v claude']); + }); +}); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index b72bdd1e..7c48b060 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -6,6 +6,7 @@ import path from 'path'; import { buildDeferredRestartResponse, } from './config-mutation-response.js'; +import { getClaudeCliAuthStatus } from './claude-cli-auth.js'; export const registerOpenCodeRoutes = (app, dependencies) => { const { @@ -568,7 +569,9 @@ ${desktopReturn ? `Return const sources = getProviderSources(providerId, directory); const { getProviderAuth } = await getAuthLibrary(); const auth = getProviderAuth(providerId); - sources.sources.auth.exists = Boolean(auth); + sources.sources.auth.exists = providerId === 'claude-code' + ? getClaudeCliAuthStatus().connected + : Boolean(auth); return res.json({ providerId, diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 955797e6..4118478c 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -120,6 +120,13 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, ); } + if (resolved.providerID === 'claude-code') { + throw Object.assign( + new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'), + { statusCode: 422, code: 'small-model-provider-unsupported' }, + ); + } + // Callers with a session context can forbid silently switching providers: // an explicit user choice (settings override, opencode config, request // model) is always allowed, anything else must stay on the session's @@ -183,6 +190,7 @@ export function listAuthenticatedProviders() { const ids = new Set( Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])), ); + ids.delete('claude-code'); // The catalog id is github-copilot while legacy auth entries may sit // under the copilot alias. if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) { diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js index fc5d741c..65a1e0f4 100644 --- a/packages/web/server/lib/small-model/index.test.js +++ b/packages/web/server/lib/small-model/index.test.js @@ -26,12 +26,42 @@ vi.mock('./call.js', () => ({ }), })); -const { generateSmallModelText, describeSmallModel } = await import('./index.js'); +const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js'); const { readAuthFile } = await import('../opencode/auth.js'); const { readConfigLayers } = await import('../opencode/shared.js'); const { getModelCatalog } = await import('./catalog.js'); const { callSmallModel } = await import('./call.js'); +describe('unsupported small-model providers', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ + 'claude-code': { + type: 'oauth', + access: 'claude-cli-managed', + refresh: 'claude-cli-managed', + }, + }); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + getModelCatalog.mockResolvedValue({}); + callSmallModel.mockReset(); + }); + + it('rejects Claude Code with an actionable error before transport dispatch', async () => { + await expect(generateSmallModelText({ + prompt: 'summarize this', + model: 'claude-code/haiku', + })).rejects.toMatchObject({ + statusCode: 422, + code: 'small-model-provider-unsupported', + }); + expect(callSmallModel).not.toHaveBeenCalled(); + }); + + it('does not offer Claude Code in the Small Model picker', () => { + expect(listAuthenticatedProviders()).not.toContain('claude-code'); + }); +}); + // 8k context leaves 4k input tokens after the output reserve → 16k chars. const CATALOG = { anthropic: { diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index 2e66063f..7bfc2671 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -39,7 +39,9 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { console.error('Small model generation failed:', error); } res.status(statusCode).json({ - error: error.message || 'Small model generation failed', + error: statusCode === 404 + ? (error.message || 'No small model is available') + : 'The selected Small Model could not complete this action. Choose another model in Settings → Sessions → Small Model and try again.', ...(error?.code ? { code: error.code } : {}), }); }