import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { scanAgents } from '../server/agent-accounts.js'; // Synthetic files in a temp HOME and an injected exec: nothing here reads the // real ~/.claude, ~/.codex or ~/.gemini, or runs a real CLI. let home; const put = (rel, text = '{}') => { mkdirSync(join(home, rel, '..'), { recursive: true }); writeFileSync(join(home, rel), text); }; const fakeJwt = (payload) => ['e30', Buffer.from(JSON.stringify(payload)).toString('base64url'), 'sig'].join('.'); beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'a007-accounts-')); }); const scan = (opts = {}) => scanAgents({ env: {}, home, platform: 'linux', timeoutMs: 200, which: (c) => (['claude', 'codex', 'gemini', 'aider'].includes(c) ? `/bin/${c}` : null), run: async (file, args) => (args[0] === '--version' ? { code: 0, stdout: `${file} 1.0\n`, stderr: '' } : { code: 1, stdout: '', stderr: '' }), ...opts, }); describe('scanAgents', () => { it('lists only the CLIs that were found, each with version and path', async () => { const agents = await scan(); expect(agents.map(a => a.cli)).toEqual(['claude', 'codex', 'gemini', 'aider']); expect(agents.find(a => a.cli === 'aider')).toEqual({ cli: 'aider', version: '/bin/aider 1.0', path: '/bin/aider', accounts: [] }); }); it('reads each Claude config folder with CLAUDE_CONFIG_DIR, unset for the default', async () => { put('.claude/settings.json'); put('.claude-work/.claude.json'); put('.claude_junk/notes.txt'); // not a config dir put('.claude-file'); // not a dir const seen = []; const agents = await scan({ run: async (file, args, { env }) => { if (args[0] !== 'auth') return { code: 0, stdout: '2.0 (Claude Code)', stderr: '' }; seen.push(env.CLAUDE_CONFIG_DIR); const work = env.CLAUDE_CONFIG_DIR?.endsWith('.claude-work'); return { code: 0, stdout: JSON.stringify({ loggedIn: !work, email: work ? null : 'a@example.com', subscriptionType: 'max', orgName: 'Org', accessToken: 'SECRET' }), stderr: 'Warning: {not json}' }; }, }); const claude = agents.find(a => a.cli === 'claude'); expect(seen.sort()).toEqual([join(home, '.claude-work'), undefined].sort()); expect(claude.accounts).toEqual([ { folder: join(home, '.claude'), isDefault: true, email: 'a@example.com', plan: 'max', org: 'Org', loggedIn: true }, { folder: join(home, '.claude-work'), isDefault: false, email: null, plan: 'max', org: 'Org', loggedIn: false }, ]); expect(JSON.stringify(agents)).not.toContain('SECRET'); }); it('marks CLAUDE_CONFIG_DIR as the default when it is set', async () => { put('.claude/settings.json'); put('elsewhere/.claude.json'); const agents = await scan({ env: { CLAUDE_CONFIG_DIR: join(home, 'elsewhere') + '/' } }); expect(agents[0].accounts.map(a => [a.folder, a.isDefault])).toEqual([[join(home, 'elsewhere'), true], [join(home, '.claude'), false]]); }); it('takes the Codex email from the id_token payload and nothing else from auth.json', async () => { put('.codex/auth.json', JSON.stringify({ OPENAI_API_KEY: 'sk-SECRET', tokens: { id_token: fakeJwt({ email: 'c@example.com', sub: 'SECRET' }), access_token: 'SECRET', refresh_token: 'SECRET' } })); put('.codex-two/auth.json', '{"tokens":{"id_token":"not-a-jwt"}}'); put('.codex-empty/config.toml'); // no auth.json: not an account const agents = await scan({ env: { CODEX_HOME: undefined }, run: async (file, args, { env }) => (args[0] === 'login' ? (env.CODEX_HOME.endsWith('.codex') ? { code: 0, stdout: '', stderr: 'Logged in using ChatGPT' } : { code: 1, stdout: 'Not logged in', stderr: '' }) : { code: 0, stdout: 'codex-cli 0.1', stderr: '' }), }); const codex = agents.find(a => a.cli === 'codex'); expect(codex.accounts).toEqual([ { folder: join(home, '.codex'), isDefault: true, email: 'c@example.com', plan: 'ChatGPT', org: null, loggedIn: true }, { folder: join(home, '.codex-two'), isDefault: false, email: null, plan: null, org: null, loggedIn: false }, ]); expect(JSON.stringify(agents)).not.toContain('SECRET'); }); it('reads the active Gemini account email only', async () => { put('.gemini/google_accounts.json', JSON.stringify({ active: 'g@example.com', old: ['x@example.com'] })); const gemini = (await scan()).find(a => a.cli === 'gemini'); expect(gemini.accounts).toEqual([{ folder: join(home, '.gemini'), isDefault: true, email: 'g@example.com', plan: null, org: null, loggedIn: true }]); }); it('still lists a CLI whose --version fails or cannot start', async () => { const agents = await scan({ run: async (file, args) => (file.endsWith('claude') ? { code: 2, stdout: 'boom', stderr: '' } : null) }); expect(agents.find(a => a.cli === 'claude').version).toBeNull(); expect(agents.find(a => a.cli === 'codex').version).toBeNull(); }); it('does not let a hanging CLI hold up the others', async () => { put('.claude/settings.json'); const t = Date.now(); const agents = await scan({ run: (file, args) => (file.endsWith('claude') ? new Promise(() => {}) : Promise.resolve({ code: 0, stdout: 'ok 1', stderr: '' })), }); expect(Date.now() - t).toBeLessThan(2000); const claude = agents.find(a => a.cli === 'claude'); expect(claude.version).toBeNull(); expect(claude.accounts[0].loggedIn).toBeNull(); expect(agents.find(a => a.cli === 'codex').version).toBe('ok 1'); }); }); describe('renderClis (Settings → Accounts, one line per CLI)', () => { it('escapes what it shows and shortens paths under the home dir, for a CLI with no login too', async () => { const { renderClis } = await import('../public/modules/settings.js'); const html = renderClis([ { cli: 'claude', version: '1.0', path: '/h/.local/bin/claude', accounts: [{ folder: '/h/.claude', isDefault: true, email: 'a@x', plan: null, org: null, loggedIn: true }] }, { cli: 'aider', version: '0.86', path: '/hx/bin/aider', accounts: [] }, ]); expect(html).toContain('Claude Code 1.0'); expect(html).toContain('~/.local/bin/claude'); expect(html).toContain('aider <b>0.86</b>'); expect(html).toContain('>/hx/bin/aider<'); expect(html).not.toContain('a@x'); }); it('names claude and codex when missing, with where to get them', async () => { const { renderClis } = await import('../public/modules/settings.js'); const none = renderClis([]); expect(none).toContain('No agent CLIs found on the PATH.'); expect(none).toMatch(/Claude Code<\/span> · not installed/); expect(none).toContain('Install Codex: npm install -g @openai/codex, then restart Agent 007'); const some = renderClis([{ cli: 'claude', version: '1.0', path: '/h/bin/claude', accounts: [] }]); expect(some).not.toMatch(/Claude Code<\/span> · not installed/); expect(some).toMatch(/Codex<\/span> · not installed/); }); });