From 3c99bdeeb5d74b3dc966e4c0214d2d53cb73c859 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 20:59:23 +0100 Subject: [PATCH] feat: Add cw account extract CLI command with tests Adds `cw account extract [--email ]` subcommand to the accountCommand group. Reads directly from the local Claude config via extractCurrentClaudeAccount() without requiring a server connection. Supports optional email verification, outputting JSON with email, configJson (stringified), and credentials fields. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/cli/extract.test.ts | 90 +++++++++++++++++++++++++++++++++ apps/server/cli/index.ts | 26 ++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/server/cli/extract.test.ts diff --git a/apps/server/cli/extract.test.ts b/apps/server/cli/extract.test.ts new file mode 100644 index 0000000..64718ba --- /dev/null +++ b/apps/server/cli/extract.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the accounts module so the dynamic import is intercepted +vi.mock('../agent/accounts/index.js', () => ({ + extractCurrentClaudeAccount: vi.fn(), +})); + +import { extractCurrentClaudeAccount } from '../agent/accounts/index.js'; +const mockExtract = vi.mocked(extractCurrentClaudeAccount); + +import { createCli } from './index.js'; + +describe('cw account extract', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('happy path — no email flag prints JSON to stdout', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'user@example.com', + accountUuid: 'uuid-1', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract']); + + expect(console.log).toHaveBeenCalledTimes(1); + const output = JSON.parse((console.log as ReturnType).mock.calls[0][0]); + expect(output.email).toBe('user@example.com'); + expect(output.configJson).toBe('{"hasCompletedOnboarding":true}'); + expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}'); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('happy path — matching email flag succeeds', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'user@example.com', + accountUuid: 'uuid-1', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']); + + expect(console.log).toHaveBeenCalledTimes(1); + const output = JSON.parse((console.log as ReturnType).mock.calls[0][0]); + expect(output.email).toBe('user@example.com'); + expect(output.configJson).toBe('{"hasCompletedOnboarding":true}'); + expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}'); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('email mismatch prints error and exits with code 1', async () => { + mockExtract.mockResolvedValueOnce({ + email: 'other@example.com', + accountUuid: 'uuid-2', + configJson: { hasCompletedOnboarding: true }, + credentials: '{"claudeAiOauth":{"accessToken":"tok"}}', + }); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']); + + expect(console.error).toHaveBeenCalledWith( + "Account 'user@example.com' not found (active account is 'other@example.com')" + ); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('extractCurrentClaudeAccount throws prints error and exits with code 1', async () => { + mockExtract.mockRejectedValueOnce(new Error('No Claude account found')); + + const program = createCli(); + await program.parseAsync(['node', 'cw', 'account', 'extract']); + + expect(console.error).toHaveBeenCalledWith('Failed to extract account:', 'No Claude account found'); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 79213ab..007035c 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1334,6 +1334,32 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // cw account extract + accountCommand + .command('extract') + .description('Extract current Claude credentials for use with the UI (does not require server)') + .option('--email ', 'Verify extracted account matches this email') + .action(async (options: { email?: string }) => { + try { + const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js'); + const extracted = await extractCurrentClaudeAccount(); + if (options.email && extracted.email !== options.email) { + console.error(`Account '${options.email}' not found (active account is '${extracted.email}')`); + process.exit(1); + return; + } + const output = { + email: extracted.email, + configJson: JSON.stringify(extracted.configJson), + credentials: extracted.credentials, + }; + console.log(JSON.stringify(output, null, 2)); + } catch (error) { + console.error('Failed to extract account:', (error as Error).message); + process.exit(1); + } + }); + // Preview command group const previewCommand = program .command('preview')