diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index ab20ec7cad66..e01432f1eeb8 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -363,6 +363,18 @@ orca account add --agent codex `account add` runs `claude login` / `codex login` in **this** terminal on the host, then registers the captured credentials with the local runtime. Codex uses device authorization so the browser can finish on another machine. Run these on the machine that owns the accounts — not through a client-only remote session. +Switch which saved account new agents launch with, for any provider with saved accounts (Claude, Codex, OpenCode, Devin, Antigravity): + +```bash +orca account list # ids, emails/labels, and the active one +orca account select --agent claude --account you@example.com # id, email or label +orca worktree create --name task --agent claude --prompt "..." +orca account select --agent claude --account system # back to your own sign-in +orca account rm --agent codex --account old@example.com # forget a saved account +``` + +A selection applies to every agent of that provider launched on the host after it — the same switch as the status-bar account menu — not to a single workspace. An email shared by several accounts (one sign-in in two organizations) is refused; pass the id instead. Antigravity has no `system` default, and `account rm` never removes system credentials. + ## Artifacts Publish HTML or Markdown through the signed-in Orca account. Viewing a public link does not require sign-in; create/list/update/delete do. **Publishing is off by default** — a human must enable **Settings → Artifacts → Allow publishing public artifact links** on the device. There is no CLI flag that grants the gate. `list`, `unshare`, and `delete` stay available so you can audit or revoke links after turning publishing off. diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts index 59df0c6b3a48..41c50c69d686 100644 --- a/src/cli/handlers/account-list-format.ts +++ b/src/cli/handlers/account-list-format.ts @@ -1,3 +1,4 @@ +import type { AntigravityAccountState } from '../../shared/antigravity-account-types' import type { ManagedDataAccountsState } from '../../shared/managed-account-types' // Why: Claude and Codex managed-account summaries both carry id+email+active id, @@ -26,19 +27,37 @@ export function formatDataAccounts(label: string, state: ManagedDataAccountsStat /** Renders a provider's managed-account list as a human-readable block, marking the active account. */ export function formatAccountsBlock(label: string, block: AccountsBlock): string { + const hostAccountId = block.activeAccountIdsByRuntime?.host ?? block.activeAccountId + const system = ` system System default${hostAccountId === null ? ' (active)' : ''}` if (block.accounts.length === 0) { - return `No managed ${label} accounts.` + return `No managed ${label} accounts.\n${system}` } const activeAccountIds = new Set([ block.activeAccountId, block.activeAccountIdsByRuntime?.host, ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) ]) + // Why the id: `orca account select --account` needs it when one email signs into several orgs. const lines = block.accounts.map( (account) => - ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ + ` ${account.id} ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}${ account.needsSignIn ? ' (sign in again in Orca Settings > AI Provider Accounts)' : '' }` ) - return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` + return `Managed ${label} accounts (${block.accounts.length}):\n${system}\n${lines.join('\n')}` +} + +/** Antigravity has no system default: one saved account always owns the native login. */ +export function formatAntigravityAccounts(state: AntigravityAccountState): string { + if (state.accounts.length === 0) { + return 'No managed Antigravity accounts.' + } + return `Managed Antigravity accounts (${state.accounts.length}):\n${state.accounts + .map( + (account) => + ` ${account.id} ${account.email ?? account.subject ?? account.id}${ + account.id === state.activeAccountId ? ' (active)' : '' + }` + ) + .join('\n')}` } diff --git a/src/cli/handlers/account-selection.test.ts b/src/cli/handlers/account-selection.test.ts new file mode 100644 index 000000000000..25f85bfa7d5c --- /dev/null +++ b/src/cli/handlers/account-selection.test.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' +import { ACCOUNT_HANDLERS } from './account' +import { formatAccountsBlock, formatAntigravityAccounts } from './account-list-format' +import { resolveAccountId } from './account-selection' +import { + ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, + DATA_ACCOUNT_RUNTIME_CAPABILITY +} from '../../shared/account-runtime-capabilities' + +const claudeAccounts = { + accounts: [ + { id: 'claude-work', email: 'me@work.example' }, + { id: 'claude-org-a', email: 'shared@example.com' }, + { id: 'claude-org-b', email: 'shared@example.com' } + ], + activeAccountId: 'claude-work', + activeAccountIdsByRuntime: { host: 'claude-work', wsl: {} } +} +const codexAccounts = { + accounts: [{ id: 'codex-1', email: 'me@codex.example' }], + activeAccountId: null +} +const dataAccounts = { + opencode: { + accounts: [{ id: 'oc-1', label: 'Perso', integrations: [], createdAt: 1 }], + activeAccountId: null + }, + devin: { + accounts: [{ id: 'devin-1', label: 'Work', integrations: [], createdAt: 1 }], + activeAccountId: 'devin-1' + } +} +const antigravityAccounts = { + accounts: [ + { + id: 'agy-1', + email: 'me@gmail.example', + subject: 'sub-1', + authMethod: 'oauth', + createdAt: 1, + updatedAt: 1 + }, + { id: 'agy-2', email: null, subject: 'sub-2', authMethod: 'oauth', createdAt: 1, updatedAt: 1 } + ], + activeAccountId: 'agy-1', + currentAccount: null, + selectedAccountId: 'agy-1' +} + +describe('resolveAccountId', () => { + const accounts = [ + { id: 'a1', names: ['Me@Example.com'] }, + { id: 'a2', names: ['shared@example.com'] }, + { id: 'a3', names: ['shared@example.com', null] } + ] + + it('prefers an exact id, then a case-insensitive email or label', () => { + expect(resolveAccountId('Claude', accounts, 'a2', { allowSystem: true })).toBe('a2') + expect(resolveAccountId('Claude', accounts, 'me@example.COM', { allowSystem: true })).toBe('a1') + }) + + it('maps system to null only where the provider has a system default', () => { + expect(resolveAccountId('Claude', accounts, 'system', { allowSystem: true })).toBeNull() + expect(() => + resolveAccountId('Antigravity', accounts, 'system', { allowSystem: false }) + ).toThrow('Antigravity has no system default') + }) + + it('refuses an ambiguous name and lists the matching ids', () => { + expect(() => + resolveAccountId('Claude', accounts, 'shared@example.com', { allowSystem: true }) + ).toThrow('a2, a3') + }) + + it('refuses an unknown account', () => { + expect(() => resolveAccountId('Claude', accounts, 'nobody', { allowSystem: true })).toThrow( + 'No managed Claude account matches "nobody"' + ) + }) +}) + +describe('account select and rm for every provider', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const originalBridgeDistro = process.env.ORCA_CLI_WSL_DISTRO + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + let capabilities: string[] + + function context( + agent: string | boolean | undefined, + account?: string | boolean + ): HandlerContext { + const flags = new Map() + if (agent !== undefined) { + flags.set('agent', agent) + } + if (account !== undefined) { + flags.set('account', account) + } + return { client, cwd: process.cwd(), flags, json: false, rawArgs: [] } + } + + function resultFor(method: string): unknown { + if (method === 'status.get') { + return { capabilities } + } + if (method === 'accounts.list') { + return { claude: claudeAccounts, codex: codexAccounts, ...dataAccounts } + } + if (method === 'accounts.listData') { + return dataAccounts + } + if (method.startsWith('accounts.antigravity')) { + return antigravityAccounts + } + if (method.endsWith('Data')) { + return dataAccounts.opencode + } + return method.includes('Claude') ? claudeAccounts : codexAccounts + } + + function mutatingCalls(): string[] { + return callMock.mock.calls + .map(([method]) => String(method)) + .filter((method) => /\.(select|remove|antigravitySelect|antigravityRemove)/.test(method)) + } + + beforeEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + delete process.env.ORCA_CLI_WSL_DISTRO + capabilities = [DATA_ACCOUNT_RUNTIME_CAPABILITY, ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY] + callMock.mockReset().mockImplementation((method: string) => + Promise.resolve({ + id: 'test', + ok: true, + result: resultFor(method), + _meta: { runtimeId: 'test-runtime' } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.restoreAllMocks() + if (originalBridgeDistro === undefined) { + delete process.env.ORCA_CLI_WSL_DISTRO + } else { + process.env.ORCA_CLI_WSL_DISTRO = originalBridgeDistro + } + }) + + it.each([ + ['claude', 'ME@work.example', 'accounts.selectClaude', { accountId: 'claude-work' }], + ['claude', 'claude-org-b', 'accounts.selectClaude', { accountId: 'claude-org-b' }], + ['codex', 'system', 'accounts.selectCodex', { accountId: null }], + ['opencode', 'perso', 'accounts.selectData', { provider: 'opencode', accountId: 'oc-1' }], + ['devin', 'system', 'accounts.selectData', { provider: 'devin', accountId: null }], + [ + 'antigravity', + 'me@gmail.example', + 'accounts.antigravitySelect', + { target: { runtime: 'host' }, accountId: 'agy-1' } + ], + [ + 'antigravity', + 'sub-2', + 'accounts.antigravitySelect', + { target: { runtime: 'host' }, accountId: 'agy-2' } + ] + ])('selects %s account %s', async (agent, account, method, params) => { + await ACCOUNT_HANDLERS['account select'](context(agent, account)) + + expect(callMock).toHaveBeenCalledWith(method, params) + expect(mutatingCalls()).toEqual([method]) + }) + + it.each([ + ['claude', 'me@work.example', 'accounts.removeClaude', { accountId: 'claude-work' }], + ['codex', 'codex-1', 'accounts.removeCodex', { accountId: 'codex-1' }], + ['opencode', 'oc-1', 'accounts.removeData', { provider: 'opencode', accountId: 'oc-1' }], + ['devin', 'work', 'accounts.removeData', { provider: 'devin', accountId: 'devin-1' }], + [ + 'antigravity', + 'agy-2', + 'accounts.antigravityRemove', + { target: { runtime: 'host' }, accountId: 'agy-2' } + ] + ])('removes %s account %s', async (agent, account, method, params) => { + await ACCOUNT_HANDLERS['account rm'](context(agent, account)) + + expect(callMock).toHaveBeenCalledWith(method, params) + expect(mutatingCalls()).toEqual([method]) + }) + + it('never removes the system default', async () => { + await expect(ACCOUNT_HANDLERS['account rm'](context('claude', 'system'))).rejects.toThrow( + 'System credentials are never removed' + ) + expect(mutatingCalls()).toEqual([]) + }) + + it('refuses an email shared by several accounts without switching or removing', async () => { + await expect( + ACCOUNT_HANDLERS['account select'](context('claude', 'shared@example.com')) + ).rejects.toThrow('claude-org-a, claude-org-b') + await expect( + ACCOUNT_HANDLERS['account rm'](context('claude', 'shared@example.com')) + ).rejects.toThrow('claude-org-a, claude-org-b') + expect(mutatingCalls()).toEqual([]) + }) + + it('refuses an unknown account without switching', async () => { + await expect( + ACCOUNT_HANDLERS['account select'](context('codex', 'nobody@example.com')) + ).rejects.toThrow('No managed Codex account matches') + expect(mutatingCalls()).toEqual([]) + }) + + it('refuses the Antigravity system default, which its host cannot select', async () => { + await expect( + ACCOUNT_HANDLERS['account select'](context('antigravity', 'system')) + ).rejects.toThrow('Antigravity has no system default') + expect(mutatingCalls()).toEqual([]) + }) + + it.each([ + [undefined, 'claude-work', 'Use --agent claude|codex|opencode|devin|antigravity'], + [true, 'claude-work', 'Use --agent claude|codex|opencode|devin|antigravity'], + ['gemini', 'claude-work', 'Unsupported --agent "gemini"'], + ['claude', undefined, 'Use --account '], + ['claude', true, 'Use --account '] + ])( + 'refuses --agent %s --account %s before calling the runtime', + async (agent, account, error) => { + await expect(ACCOUNT_HANDLERS['account select'](context(agent, account))).rejects.toThrow( + error + ) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it('targets the bridge distro when selecting Codex from WSL', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu' + + await ACCOUNT_HANDLERS['account select'](context('codex', 'codex-1')) + + expect(callMock).toHaveBeenCalledWith('accounts.selectCodexForTarget', { + accountId: 'codex-1', + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + }) + + it('surfaces the host refusal for Antigravity from WSL without selecting', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu' + // Why: the host refuses every client-selected WSL target, reads included + // (getAntigravityAccountService), so the CLI must stop at the list call. + const respond = callMock.getMockImplementation()! + callMock.mockImplementation((method: string, params: unknown) => + method.startsWith('accounts.antigravity') + ? Promise.reject( + new Error( + 'Antigravity account management for a client-selected WSL distro is not supported yet.' + ) + ) + : respond(method, params) + ) + + await expect( + ACCOUNT_HANDLERS['account select'](context('antigravity', 'agy-1')) + ).rejects.toThrow('client-selected WSL distro is not supported yet') + + expect(callMock).toHaveBeenCalledWith('accounts.antigravityList', { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(mutatingCalls()).toEqual([]) + }) + + it('refuses the Claude system default from WSL rather than resetting the Windows host', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.ORCA_CLI_WSL_DISTRO = 'Ubuntu' + + await expect(ACCOUNT_HANDLERS['account select'](context('claude', 'system'))).rejects.toThrow( + 'Select the Claude system default for a WSL distro in Orca Settings' + ) + expect(callMock).not.toHaveBeenCalled() + }) + + it.each([ + ['devin', DATA_ACCOUNT_RUNTIME_CAPABILITY], + ['antigravity', ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY] + ])('refuses %s on a runtime without its account capability', async (agent, capability) => { + capabilities = capabilities.filter((entry) => entry !== capability) + + await expect(ACCOUNT_HANDLERS['account select'](context(agent, 'system'))).rejects.toThrow( + 'Update or restart this Orca host' + ) + expect(mutatingCalls()).toEqual([]) + }) + + it('lists one provider with --agent', async () => { + await ACCOUNT_HANDLERS['account list'](context('antigravity')) + + expect(console.log).toHaveBeenCalledWith( + 'Managed Antigravity accounts (2):\n agy-1 me@gmail.example (active)\n agy-2 sub-2' + ) + }) + + it('adds Antigravity to the full list when the host supports it', async () => { + await ACCOUNT_HANDLERS['account list'](context(undefined)) + + expect(callMock).toHaveBeenCalledWith('accounts.antigravityList', { runtime: 'host' }) + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('\n\nManaged Antigravity accounts (2):') + ) + }) + + it('omits Antigravity from the full list on an older host', async () => { + capabilities = [DATA_ACCOUNT_RUNTIME_CAPABILITY] + + await ACCOUNT_HANDLERS['account list'](context(undefined)) + + expect(callMock).not.toHaveBeenCalledWith('accounts.antigravityList', expect.anything()) + expect(console.log).toHaveBeenCalledWith(expect.not.stringContaining('Antigravity')) + }) + + it('still lists the other providers when this host cannot switch Antigravity accounts', async () => { + callMock.mockImplementation((method: string) => + method === 'accounts.antigravityList' + ? Promise.reject(new Error('Native Antigravity account switching is not supported')) + : Promise.resolve({ + id: 'test', + ok: true, + result: resultFor(method), + _meta: { runtimeId: 'test-runtime' } + }) + ) + + await ACCOUNT_HANDLERS['account list'](context(undefined)) + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Managed Claude accounts')) + expect(console.log).toHaveBeenCalledWith(expect.not.stringContaining('Antigravity')) + }) +}) + +describe('account list formatting', () => { + it('prints ids and the system default for Claude and Codex', () => { + expect(formatAccountsBlock('Codex', codexAccounts)).toBe( + 'Managed Codex accounts (1):\n system System default (active)\n codex-1 me@codex.example' + ) + const claude = formatAccountsBlock('Claude', claudeAccounts) + expect(claude).toContain(' system System default\n') + expect(claude).toContain(' claude-work me@work.example (active)') + }) + + it('still offers the system default when no account is saved', () => { + expect(formatAccountsBlock('Codex', { accounts: [], activeAccountId: null })).toBe( + 'No managed Codex accounts.\n system System default (active)' + ) + }) + + it('names an Antigravity account by email, then by subject', () => { + expect(formatAntigravityAccounts(antigravityAccounts)).toBe( + 'Managed Antigravity accounts (2):\n agy-1 me@gmail.example (active)\n agy-2 sub-2' + ) + expect( + formatAntigravityAccounts({ ...antigravityAccounts, accounts: [], activeAccountId: null }) + ).toBe('No managed Antigravity accounts.') + }) +}) diff --git a/src/cli/handlers/account-selection.ts b/src/cli/handlers/account-selection.ts new file mode 100644 index 000000000000..07fc521fb8dd --- /dev/null +++ b/src/cli/handlers/account-selection.ts @@ -0,0 +1,349 @@ +import type { HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY } from '../../shared/account-runtime-capabilities' +import type { + AntigravityAccountState, + AntigravityAccountTarget +} from '../../shared/antigravity-account-types' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { getWslAccountTarget } from './account-wsl-location' +import { + formatAccountsBlock, + formatAntigravityAccounts, + formatDataAccounts +} from './account-list-format' +import { assertDataAccountsSupported, listDataAccounts } from './data-account-commands' + +const ACCOUNT_AGENTS = ['claude', 'codex', 'opencode', 'devin', 'antigravity'] as const +type AccountAgent = (typeof ACCOUNT_AGENTS)[number] + +const AGENT_FLAG_HINT = 'Use --agent claude|codex|opencode|devin|antigravity.' +const ACCOUNT_FLAG_HINT = 'Use --account from `orca account list`.' + +/** One saved account and the names `--account` may call it by (email, label, subject). */ +type AccountChoice = { id: string; names: readonly (string | null | undefined)[] } + +/** + * Every provider with saved accounts, behind one shape so select and rm resolve + * `--account` the same way. Each operation prints its own result, which keeps the + * per-provider state types out of the shared code. + */ +type ProviderAccounts = { + label: string + /** Whether `system` (the provider's own sign-in, outside Orca's vault) can be selected. */ + hasSystemDefault: boolean + choices: () => Promise + list: () => Promise + select: (accountId: string | null) => Promise + remove: (accountId: string) => Promise +} + +type ClaudeCodexSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState +} + +function emailChoices(state: { accounts: readonly { id: string; email: string }[] }) { + return state.accounts.map((account) => ({ id: account.id, names: [account.email] })) +} + +function claudeAccounts({ client, json }: HandlerContext): ProviderAccounts { + const read = () => client.call('accounts.list', { refreshUsage: false }) + const print = async (method: string, accountId: string | null) => + printResult( + await client.call(method, { accountId }), + json, + (state) => formatAccountsBlock('Claude', state) + ) + return { + label: 'Claude', + hasSystemDefault: true, + choices: async () => emailChoices((await read()).result.claude), + list: async () => { + const response = await read() + printResult({ ...response, result: response.result.claude }, json, (state) => + formatAccountsBlock('Claude', state) + ) + }, + select: (accountId) => print('accounts.selectClaude', accountId), + remove: (accountId) => print('accounts.removeClaude', accountId) + } +} + +function codexAccounts({ client, cwd, json }: HandlerContext): ProviderAccounts { + const read = () => client.call('accounts.list', { refreshUsage: false }) + const format = (state: CodexRateLimitAccountsState) => formatAccountsBlock('Codex', state) + const wslTarget = getWslAccountTarget(cwd) + return { + label: 'Codex', + hasSystemDefault: true, + choices: async () => emailChoices((await read()).result.codex), + list: async () => { + const response = await read() + printResult({ ...response, result: response.result.codex }, json, format) + }, + // Why ForTarget from WSL: plain selectCodex always writes the Windows host slot. + select: async (accountId) => + printResult( + wslTarget + ? await client.call('accounts.selectCodexForTarget', { + accountId, + target: wslTarget + }) + : await client.call('accounts.selectCodex', { accountId }), + json, + format + ), + remove: async (accountId) => + printResult( + await client.call('accounts.removeCodex', { accountId }), + json, + format + ) + } +} + +function dataAccounts(ctx: HandlerContext, provider: ManagedDataAccountProvider): ProviderAccounts { + const { client, json } = ctx + const label = provider === 'opencode' ? 'OpenCode' : 'Devin' + const read = async () => { + await assertDataAccountsSupported(ctx) + const response = + await client.call>>( + 'accounts.listData' + ) + const state = response.result[provider] + if (!state) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Managed accounts are unavailable on this host.' + ) + } + return { ...response, result: state } + } + const print = async (method: string, accountId: string | null) => + printResult( + await client.call(method, { provider, accountId }), + json, + (state) => formatDataAccounts(label, state) + ) + return { + label, + hasSystemDefault: true, + choices: async () => + (await read()).result.accounts.map((account) => ({ id: account.id, names: [account.label] })), + list: () => listDataAccounts(ctx, provider), + select: (accountId) => print('accounts.selectData', accountId), + remove: (accountId) => print('accounts.removeData', accountId) + } +} + +async function supportsAntigravityAccounts({ client }: HandlerContext): Promise { + const status = await client.call('status.get') + return status.result.capabilities?.includes(ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY) ?? false +} + +function antigravityTarget(cwd: string): AntigravityAccountTarget { + return getWslAccountTarget(cwd) ?? { runtime: 'host' } +} + +function antigravityAccounts(ctx: HandlerContext): ProviderAccounts { + const { client, cwd, json } = ctx + const target = antigravityTarget(cwd) + const read = async () => { + if (!(await supportsAntigravityAccounts(ctx))) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Update or restart this Orca host to manage Antigravity accounts.' + ) + } + return client.call('accounts.antigravityList', target) + } + const print = async (method: string, accountId: string) => + printResult( + await client.call(method, { target, accountId }), + json, + formatAntigravityAccounts + ) + return { + label: 'Antigravity', + // Why: the native `agy` login is always one of the saved accounts; there is no + // separate system sign-in to fall back to. + hasSystemDefault: false, + choices: async () => + (await read()).result.accounts.map((account) => ({ + id: account.id, + names: [account.email, account.subject] + })), + list: async () => printResult(await read(), json, formatAntigravityAccounts), + select: async (accountId) => { + if (accountId === null) { + throw new RuntimeClientError('invalid_argument', 'Antigravity has no system default.') + } + await print('accounts.antigravitySelect', accountId) + }, + remove: (accountId) => print('accounts.antigravityRemove', accountId) + } +} + +function providerAccounts(ctx: HandlerContext, agent: AccountAgent): ProviderAccounts { + switch (agent) { + case 'claude': + return claudeAccounts(ctx) + case 'codex': + return codexAccounts(ctx) + case 'opencode': + case 'devin': + return dataAccounts(ctx, agent) + case 'antigravity': + return antigravityAccounts(ctx) + } +} + +function isAccountAgent(value: string): value is AccountAgent { + return ACCOUNT_AGENTS.some((agent) => agent === value) +} + +// Why required: switching or removing an account decides which subscription is billed, +// so the provider is never guessed. +function readAgentFlag(ctx: HandlerContext): AccountAgent { + const agent = ctx.flags.get('agent') + if (typeof agent !== 'string') { + throw new RuntimeClientError('invalid_argument', AGENT_FLAG_HINT) + } + if (!isAccountAgent(agent)) { + throw new RuntimeClientError( + 'invalid_argument', + `Unsupported --agent "${agent}". ${AGENT_FLAG_HINT}` + ) + } + return agent +} + +function readAccountFlag(ctx: HandlerContext): string { + const account = ctx.flags.get('account') + if (typeof account !== 'string' || !account.trim()) { + throw new RuntimeClientError('invalid_argument', ACCOUNT_FLAG_HINT) + } + return account.trim() +} + +/** + * Resolves `--account` to a saved account id: an exact id first, then a + * case-insensitive email, label or subject. `system` is the provider's own sign-in + * (null). A name shared by several accounts — one email in two organizations — is + * refused with their ids rather than guessing which subscription to bill. + */ +export function resolveAccountId( + label: string, + accounts: readonly AccountChoice[], + query: string, + { allowSystem }: { allowSystem: boolean } +): string | null { + if (query === 'system') { + if (!allowSystem) { + throw new RuntimeClientError('invalid_argument', `${label} has no system default.`) + } + return null + } + return resolveSavedAccountId(label, accounts, query) +} + +function resolveSavedAccountId( + label: string, + accounts: readonly AccountChoice[], + query: string +): string { + const byId = accounts.find((account) => account.id === query) + if (byId) { + return byId.id + } + const needle = query.toLowerCase() + const byName = accounts.filter((account) => + account.names.some((name) => name?.toLowerCase() === needle) + ) + if (byName.length === 1) { + return byName[0].id + } + if (byName.length > 1) { + throw new RuntimeClientError( + 'invalid_argument', + `Several ${label} accounts match "${query}"; pass one of their ids: ${byName + .map((account) => account.id) + .join(', ')}.` + ) + } + throw new RuntimeClientError( + 'invalid_argument', + `No managed ${label} account matches "${query}". Run \`orca account list\` to see the saved accounts.` + ) +} + +/** `orca account select`: the account new launches of that agent use on this host. */ +export async function selectAgentAccount(ctx: HandlerContext): Promise { + const agent = readAgentFlag(ctx) + const query = readAccountFlag(ctx) + // Why: accounts.selectClaude carries no target, so `system` from the WSL bridge + // would reset the Windows host's selection instead of the distro's. + if (agent === 'claude' && query === 'system' && getWslAccountTarget(ctx.cwd)) { + throw new RuntimeClientError( + 'invalid_argument', + 'Select the Claude system default for a WSL distro in Orca Settings > AI Provider Accounts.' + ) + } + const provider = providerAccounts(ctx, agent) + const accountId = resolveAccountId(provider.label, await provider.choices(), query, { + allowSystem: provider.hasSystemDefault + }) + await provider.select(accountId) +} + +/** `orca account rm`: forgets a saved account; the provider's own sign-in is never touched. */ +export async function removeAgentAccount(ctx: HandlerContext): Promise { + const agent = readAgentFlag(ctx) + const query = readAccountFlag(ctx) + if (query === 'system') { + throw new RuntimeClientError( + 'invalid_argument', + 'System credentials are never removed; pass a saved account from `orca account list`.' + ) + } + const provider = providerAccounts(ctx, agent) + await provider.remove(resolveSavedAccountId(provider.label, await provider.choices(), query)) +} + +/** `orca account list --agent `: one provider's saved accounts. */ +export async function listAgentAccounts(ctx: HandlerContext, agentFlag: unknown): Promise { + if (typeof agentFlag !== 'string' || !isAccountAgent(agentFlag)) { + throw new RuntimeClientError('invalid_argument', AGENT_FLAG_HINT) + } + await providerAccounts(ctx, agentFlag).list() +} + +/** The Antigravity block for the full `orca account list`, or null on a host that cannot read it. */ +export async function readAntigravityAccountsForList( + ctx: HandlerContext +): Promise { + if (!(await supportsAntigravityAccounts(ctx))) { + return null + } + // Why: a host can advertise the capability yet have no native credential adapter + // (Linux Secret Service today). The overview must still show every other provider; + // `orca account list --agent antigravity` keeps reporting the reason. + try { + const response = await ctx.client.call( + 'accounts.antigravityList', + antigravityTarget(ctx.cwd) + ) + return response.result + } catch { + return null + } +} diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 57f7b258b1c8..d63ff3d50b5b 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -694,7 +694,7 @@ describe('account CLI handlers', () => { expect(logSpy).toHaveBeenCalledWith( expect.stringContaining( - ' old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok@example.com\n' + ' old old@example.com (active) (sign in again in Orca Settings > AI Provider Accounts)\n ok ok@example.com\n' ) ) }) diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 2d64a29aade1..f2b67392c267 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -36,8 +36,19 @@ import { withInteractiveLoginCleanup } from './interactive-login-interruption' import { getWslAccountTarget } from './account-wsl-location' -import { addDataAccount, listDataAccounts, mutateDataAccount } from './data-account-commands' -import { formatAccountsBlock, formatDataAccounts } from './account-list-format' +import { addDataAccount } from './data-account-commands' +import { + formatAccountsBlock, + formatAntigravityAccounts, + formatDataAccounts +} from './account-list-format' +import { + listAgentAccounts, + readAntigravityAccountsForList, + removeAgentAccount, + selectAgentAccount +} from './account-selection' +import type { AntigravityAccountState } from '../../shared/antigravity-account-types' // Why: add returns just that provider's state; list returns the full snapshot. type AccountsListSnapshot = { @@ -45,6 +56,7 @@ type AccountsListSnapshot = { devin?: ManagedDataAccountsState claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState + antigravity?: AntigravityAccountState } function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { @@ -287,30 +299,35 @@ export const ACCOUNT_HANDLERS: Record = { rejectAccountRemoteSelectionFlags(ctx, 'orca account list') const provider = ctx.flags.get('agent') if (provider !== undefined) { - await listDataAccounts(ctx, provider) + await listAgentAccounts(ctx, provider) return } const { client, json } = ctx // Why: this command renders no usage numbers, so skip the forced provider // refresh — it is one serial network round-trip per managed account. - const result = await client.call('accounts.list', { + const response = await client.call('accounts.list', { refreshUsage: false }) - printResult(result, json, (snapshot) => + // Why separate: Antigravity accounts live in their own native vault, not in the + // accounts.list snapshot, and an older host simply has none to show. + const antigravity = await readAntigravityAccountsForList(ctx) + const result = antigravity ? { ...response.result, antigravity } : response.result + printResult({ ...response, result }, json, (snapshot) => [ formatAccountsBlock('Claude', snapshot.claude), formatAccountsBlock('Codex', snapshot.codex), ...(snapshot.opencode ? [formatDataAccounts('OpenCode', snapshot.opencode)] : []), - ...(snapshot.devin ? [formatDataAccounts('Devin', snapshot.devin)] : []) + ...(snapshot.devin ? [formatDataAccounts('Devin', snapshot.devin)] : []), + ...(snapshot.antigravity ? [formatAntigravityAccounts(snapshot.antigravity)] : []) ].join('\n\n') ) }, 'account select': async (ctx) => { rejectAccountRemoteSelectionFlags(ctx, 'orca account select') - await mutateDataAccount(ctx, 'select') + await selectAgentAccount(ctx) }, 'account rm': async (ctx) => { rejectAccountRemoteSelectionFlags(ctx, 'orca account rm') - await mutateDataAccount(ctx, 'remove') + await removeAgentAccount(ctx) } } diff --git a/src/cli/handlers/data-account-commands.ts b/src/cli/handlers/data-account-commands.ts index 92ca3b8aa737..d7d45cbc4b34 100644 --- a/src/cli/handlers/data-account-commands.ts +++ b/src/cli/handlers/data-account-commands.ts @@ -129,23 +129,3 @@ export async function listDataAccounts(ctx: HandlerContext, provider: unknown): return formatDataAccounts(provider, state) }) } - -export async function mutateDataAccount( - ctx: HandlerContext, - action: 'select' | 'remove' -): Promise { - const provider = ctx.flags.get('agent') - const id = ctx.flags.get('account') - if ((provider !== 'opencode' && provider !== 'devin') || typeof id !== 'string' || !id) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use --agent opencode|devin and --account (system for the default selection).' - ) - } - await assertDataAccountsSupported(ctx) - const result = await ctx.client.call(`accounts.${action}Data`, { - provider, - accountId: action === 'select' && id === 'system' ? null : id - }) - printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) -} diff --git a/src/cli/help.ts b/src/cli/help.ts index b28103373cc1..83eee7e54b49 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -188,7 +188,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { return '--agent Account provider: claude, codex, opencode, or devin (default claude)' } if (command.startsWith('account ') && flag === 'agent') { - return '--agent Account provider: opencode or devin' + return '--agent Account provider: claude, codex, opencode, devin, or antigravity' } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 13ef08d13a66..87183fd7df40 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -26,8 +26,8 @@ export const ROOT_HELP_TEXT_PRIMARY = [ 'Accounts:', ' account add Add a managed agent account on this Orca host', ' account list List managed agent accounts on this Orca host', - ' account select Select an OpenCode or Devin account for new launches', - ' account rm Remove an OpenCode or Devin account and its private data', + ' account select Select the account new agent launches use', + ' account rm Remove a managed agent account and its private data', '', 'Skills:', ' skills installed List installed skill selectors', diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 4749dc73a423..6b1518aca2c2 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -44,6 +44,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca search --index-status [--json]', ' orca account add [--agent claude|codex] [--json]', ' orca account list [--json]', + ' orca account select --agent claude --account [--json]', ' orca host list [--json]', ' orca environment add --name --pairing-code [--json]', ' orca environment list [--json]', diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 40d48034c50f..e21f50d2cbde 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -49,7 +49,9 @@ describe('account command specs', () => { const canonical = spec('account rm') expect(findCommandSpec(ACCOUNT_COMMAND_SPECS, ['account', 'remove'])).toBe(canonical) - expect(formatCommandHelp(canonical)).toContain('orca account rm --agent opencode|devin') + expect(formatCommandHelp(canonical)).toContain( + 'orca account rm --agent claude|codex|opencode|devin|antigravity' + ) expect(HANDLER_COMMAND_KEYS.has('account remove')).toBe(false) }) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 3da205e31eb5..dd0f8e141b5d 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -17,6 +17,7 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ 'Codex uses device authorization so the browser can complete sign-in from a different machine.', 'OpenCode 2 uses `opencode auth login --standalone` in private XDG directories. Devin uses `devin auth login --force-manual-token-flow`.', 'Use --integration to skip the OpenCode integration picker; --label names the saved OpenCode or Devin profile.', + 'Antigravity accounts are saved from the current `agy` login in Orca Settings > AI Provider Accounts.', 'OpenCode and Devin profiles apply to new explicit host agent launches. Direct SSH relay and Windows-hosted WSL selection are not supported; run the command on a headless Orca runtime on that host.', 'Sign in with the account you want to add (e.g. use a private/incognito browser window for a second account).', '--agent defaults to claude. Requires the Orca runtime to be running on this machine.' @@ -26,28 +27,41 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ { path: ['account', 'list'], summary: 'List managed agent accounts on this Orca host', - usage: 'orca account list [--agent opencode|devin] [--json]', + usage: 'orca account list [--agent claude|codex|opencode|devin|antigravity] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'agent'], notes: [ - 'Lists the accounts on this machine. `--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' + "Lists the accounts on this machine with the ids `orca account select` and `orca account rm` accept. `system` is the provider's own sign-in outside Orca.", + '`--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' ], - examples: ['orca account list'] + examples: ['orca account list', 'orca account list --agent claude'] }, { path: ['account', 'select'], - summary: 'Select an OpenCode or Devin profile for new agent launches', - usage: 'orca account select --agent opencode|devin --account [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'] + summary: 'Select the account new agent launches use on this Orca host', + usage: + 'orca account select --agent claude|codex|opencode|devin|antigravity --account [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'], + notes: [ + "--account takes an id, email or label from `orca account list`, or `system` for the provider's own sign-in. An email shared by several accounts is refused; pass the id.", + 'The selection applies to agents launched on this host after it, like the status-bar account switcher. Antigravity has no `system` default.' + ], + examples: [ + 'orca account select --agent claude --account me@work.example', + 'orca account select --agent codex --account system', + 'orca account select --agent antigravity --account me@gmail.example' + ] }, { path: ['account', 'rm'], aliases: [['account', 'remove']], destructive: true, - summary: 'Remove a managed OpenCode or Devin profile and its private data', - usage: 'orca account rm --agent opencode|devin --account [--json]', + summary: 'Remove a managed agent account and its private data', + usage: + 'orca account rm --agent claude|codex|opencode|devin|antigravity --account [--json]', allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'], notes: [ - 'Deletes credentials and conversation data in the managed profile. Stop its running agents first. System credentials are never removed.' + 'Deletes the credentials Orca saved for that account (and, for OpenCode and Devin, the conversation data in its profile). Stop its running agents first. System credentials are never removed.', + 'Select another Antigravity account before removing the one in use.' ] } ]