fix: integrate Claude CLI provider state
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user