diff --git a/AGENTS.md b/AGENTS.md index 8ac5cd365cc3..5aa9295c71ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Avoid type assertions except `as const`. Unavoidable casts need a line-specific - **Test**: `pnpm test [path/to/file.test.ts]` - **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format` - **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces +- **Real Claude CLI**: when you change Claude structured-session code (`src/main/claude/claude-structured-*`), run `ORCA_REAL_CLAUDE_CLI_TEST=1 pnpm test src/main/claude/claude-structured-real-cli.test.ts src/main/claude/claude-structured-real-cli-fold.test.ts`; it uses your real Claude login # Writing Pull Requests diff --git a/src/main/claude/claude-real-cli-availability-test-support.ts b/src/main/claude/claude-real-cli-availability-test-support.ts index faa322c456b1..ecbce5c354f8 100644 --- a/src/main/claude/claude-real-cli-availability-test-support.ts +++ b/src/main/claude/claude-real-cli-availability-test-support.ts @@ -1,5 +1,5 @@ -// Whether a real, signed-in Claude CLI is present — the gate every real-CLI -// suite skips on. Probed once per test process. +// Whether a real, signed-in Claude CLI may be used — the gate every real-CLI +// suite skips on. Opt-in only; probed once per test process. import { homedir } from 'node:os' import { join } from 'node:path' @@ -7,6 +7,7 @@ import { runProcessSync, type ProcessResult } from '../../shared/child-process/r import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CLAUDE_AUTH_ENV_VARS } from '../claude-accounts/environment' import { resolveClaudeCommand } from '../codex-cli/command' +import { resolveRealClaudeCliGate } from './claude-real-cli-test-gate' export const realClaudeCommand = resolveClaudeCommand() @@ -25,24 +26,18 @@ function probeRealClaude(args: string[]): ProcessResult | null { } } -export const realClaudeAvailable = probeRealClaude(['--version'])?.code === 0 +// Why the gate: an installed, signed-in CLI is not consent to spend turns on that account, so +// nothing is probed unless the run opted in. +export const realClaudeCliGate = resolveRealClaudeCliGate(process.env, (args) => { + const result = probeRealClaude([...args]) + return { status: result?.code ?? null, stdout: result?.stdout ?? '' } +}) + +export const realClaudeAvailable = realClaudeCliGate.skipReason === null /** The CLI's own account report — the only source of truth for where it writes that * is not derived from Orca's own path expressions. */ -export const realClaudeAuthStatus = (() => { - if (!realClaudeAvailable) { - return null - } - const result = probeRealClaude(['auth', 'status', '--json']) - if (!result || result.code !== 0) { - return null - } - try { - return JSON.parse(result.stdout) as { loggedIn?: boolean; projectsDirectory?: string } - } catch { - return null - } -})() +export const realClaudeAuthStatus = realClaudeCliGate.authStatus export const realClaudeAuthenticated = realClaudeAuthStatus?.loggedIn === true diff --git a/src/main/claude/claude-real-cli-test-gate.test.ts b/src/main/claude/claude-real-cli-test-gate.test.ts new file mode 100644 index 000000000000..508fd6af08ed --- /dev/null +++ b/src/main/claude/claude-real-cli-test-gate.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + REAL_CLAUDE_CLI_TEST_ENV, + resolveRealClaudeCliGate, + type ClaudeCliProbeResult +} from './claude-real-cli-test-gate' + +/** A claude that is installed and signed in, as far as the probes can tell. */ +function signedInClaude() { + return vi.fn((args: readonly string[]): ClaudeCliProbeResult => { + if (args[0] === '--version') { + return { status: 0, stdout: '2.1.0 (Claude Code)\n' } + } + return { + status: 0, + stdout: JSON.stringify({ loggedIn: true, projectsDirectory: '/home/dev/.claude/projects' }) + } + }) +} + +describe('resolveRealClaudeCliGate', () => { + it.each([undefined, '', '0', 'true'])( + 'skips without probing a signed-in claude when %s is the opt-in value', + (value) => { + const runClaude = signedInClaude() + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: value }, runClaude) + + expect(gate).toEqual({ + skipReason: 'set ORCA_REAL_CLAUDE_CLI_TEST=1 to run against the real claude CLI', + authStatus: null + }) + expect(runClaude).not.toHaveBeenCalled() + } + ) + + it('runs with the CLI account report once opted in and signed in', () => { + const runClaude = signedInClaude() + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude) + + expect(gate).toEqual({ + skipReason: null, + authStatus: { loggedIn: true, projectsDirectory: '/home/dev/.claude/projects' } + }) + expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]]) + }) + + it('still skips when opted in but no claude binary answers', () => { + const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' })) + + const gate = resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude) + + expect(gate).toEqual({ skipReason: '`claude --version` failed', authStatus: null }) + expect(runClaude).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['a failed auth probe', { status: 1, stdout: '' }], + ['unparseable auth output', { status: 0, stdout: 'not json' }] + ])('runs with no account report after %s', (_label, authResult) => { + const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => + args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult + ) + + // The suite's signed-out case still runs; its signed-in cases skip on a null report. + expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({ + skipReason: null, + authStatus: null + }) + }) + + it('keeps only the account fields it understands', () => { + const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => + args[0] === '--version' + ? { status: 0, stdout: '2.1.0\n' } + : { status: 0, stdout: JSON.stringify({ loggedIn: 'yes', projectsDirectory: 7 }) } + ) + + expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({ + skipReason: null, + authStatus: {} + }) + }) +}) diff --git a/src/main/claude/claude-real-cli-test-gate.ts b/src/main/claude/claude-real-cli-test-gate.ts new file mode 100644 index 000000000000..de25ef87bf47 --- /dev/null +++ b/src/main/claude/claude-real-cli-test-gate.ts @@ -0,0 +1,52 @@ +/** Opt-in for tests that drive the real `claude` CLI on this machine's signed-in account. */ +export const REAL_CLAUDE_CLI_TEST_ENV = 'ORCA_REAL_CLAUDE_CLI_TEST' + +export type ClaudeCliProbeResult = { status: number | null; stdout: string } + +export type RealClaudeAuthStatus = { loggedIn?: boolean; projectsDirectory?: string } + +export type RealClaudeCliGate = { + /** Null when the real-CLI suite may run; otherwise why it is skipped. */ + skipReason: string | null + /** The CLI's own account report; null when skipped or unreadable. */ + authStatus: RealClaudeAuthStatus | null +} + +function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null { + let parsed: unknown + try { + parsed = JSON.parse(stdout) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const status: RealClaudeAuthStatus = {} + if ('loggedIn' in parsed && typeof parsed.loggedIn === 'boolean') { + status.loggedIn = parsed.loggedIn + } + if ('projectsDirectory' in parsed && typeof parsed.projectsDirectory === 'string') { + status.projectsDirectory = parsed.projectsDirectory + } + return status +} + +export function resolveRealClaudeCliGate( + env: NodeJS.ProcessEnv, + runClaude: (args: readonly string[]) => ClaudeCliProbeResult +): RealClaudeCliGate { + // Why before any probe: an installed, signed-in CLI is not consent to spend turns on that + // account and write transcripts into its home from a broad local test run. + if (env[REAL_CLAUDE_CLI_TEST_ENV] !== '1') { + return { + skipReason: `set ${REAL_CLAUDE_CLI_TEST_ENV}=1 to run against the real claude CLI`, + authStatus: null + } + } + if (runClaude(['--version']).status !== 0) { + return { skipReason: '`claude --version` failed', authStatus: null } + } + const auth = runClaude(['auth', 'status', '--json']) + return { skipReason: null, authStatus: auth.status === 0 ? parseAuthStatus(auth.stdout) : null } +} diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 73bec7986c84..677d2de4cb9e 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -10,6 +10,7 @@ import { realClaudeAuthenticated, realClaudeAuthStatus, realClaudeAvailable, + realClaudeCliGate, realClaudeCommand } from './claude-real-cli-availability-test-support' import { @@ -19,6 +20,7 @@ import { import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' const command = realClaudeCommand +const suiteTitle = `Claude structured real CLI handshake${realClaudeCliGate.skipReason ? ` (skipped: ${realClaudeCliGate.skipReason})` : ''}` function realAdapter( providerSessionId: string, @@ -79,7 +81,7 @@ async function waitForResolvedTranscript( } } -describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () => { +describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { it.skipIf(!realClaudeAuthenticated)( 'proves a pre-minted session before the first user message', async () => {