fix: integrate Claude CLI provider state

This commit is contained in:
Bohdan Triapitsyn
2026-08-15 01:59:26 +03:00
parent 72cd861eb8
commit d353638f39
17 changed files with 229 additions and 21 deletions
@@ -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),
};
}
};
@@ -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']);
});
});
+4 -1
View File
@@ -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 ? `<a class="return" href="openchamber://focus/mcp-auth">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,
@@ -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'))) {
@@ -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: {
@@ -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 } : {}),
});
}