Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/site/content/docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,16 @@ 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 account new agents launch with, then create the workspace:

```bash
orca account select --agent claude --account you@example.com # id or email from `account list`
orca worktree create --name task --agent claude --prompt "..."
orca account select --agent claude --account system # back to your own sign-in
```

Claude and Codex have one active account per host — the one the status-bar switcher shows — so a selection applies to every agent launched after it, not to a single workspace.

## 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.
Expand Down
10 changes: 7 additions & 3 deletions src/cli/handlers/account-list-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ 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) =>
` ${account.id} ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}`
)
return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}`
return `Managed ${label} accounts (${block.accounts.length}):\n${system}\n${lines.join('\n')}`
}
3 changes: 2 additions & 1 deletion src/cli/handlers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { getWslAccountTarget } from './account-wsl-location'
import { addDataAccount, listDataAccounts, mutateDataAccount } from './data-account-commands'
import { formatAccountsBlock, formatDataAccounts } from './account-list-format'
import { selectAgentAccount } from './provider-account-selection'

// Why: add returns just that provider's state; list returns the full snapshot.
type AccountsListSnapshot = {
Expand Down Expand Up @@ -328,7 +329,7 @@ export const ACCOUNT_HANDLERS: Record<string, CommandHandler> = {
},
'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')
Expand Down
4 changes: 3 additions & 1 deletion src/cli/handlers/data-account-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ export async function mutateDataAccount(
if ((provider !== 'opencode' && provider !== 'devin') || typeof id !== 'string' || !id) {
throw new RuntimeClientError(
'invalid_argument',
'Use --agent opencode|devin and --account <id> (system for the default selection).'
action === 'select'
? 'Use --agent claude|codex|opencode|devin and --account <id> (system for the default selection).'
: 'Use --agent opencode|devin and --account <id> (system for the default selection).'
)
}
await assertDataAccountsSupported(ctx)
Expand Down
163 changes: 163 additions & 0 deletions src/cli/handlers/provider-account-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type { HandlerContext } from '../dispatch'
import { RuntimeClient } from '../runtime-client'
import { ACCOUNT_HANDLERS } from './account'
import { formatAccountsBlock } from './account-list-format'
import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'

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
}

describe('account select for Claude and Codex', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
const originalBridgeDistro = process.env.ORCA_CLI_WSL_DISTRO
const client = new RuntimeClient(join(tmpdir(), 'orca-account-select-test'), 1000, null, null)
let callMock: MockInstance<RuntimeClient['call']>

function context(agent: string, account?: string): HandlerContext {
const flags = new Map<string, string | boolean>([['agent', agent]])
if (account !== undefined) {
flags.set('account', account)
}
return { client, cwd: process.cwd(), flags, json: false, rawArgs: [] }
}

beforeEach(() => {
delete process.env.ORCA_CLI_WSL_DISTRO
callMock = vi.spyOn(client, 'call').mockImplementation((method: string) =>
Promise.resolve({
id: 'test',
ok: true,
result:
method === 'accounts.list'
? { claude: claudeAccounts, codex: codexAccounts }
: method.includes('Claude')
? claudeAccounts
: codexAccounts,
_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('selects a Claude account by email, ignoring case', async () => {
await ACCOUNT_HANDLERS['account select'](context('claude', 'ME@work.example'))

expect(callMock).toHaveBeenCalledWith('accounts.list', { refreshUsage: false })
expect(callMock).toHaveBeenCalledWith('accounts.selectClaude', { accountId: 'claude-work' })
})

it('selects a Claude account by id', async () => {
await ACCOUNT_HANDLERS['account select'](context('claude', 'claude-org-b'))

expect(callMock).toHaveBeenCalledWith('accounts.selectClaude', { accountId: 'claude-org-b' })
})

it('maps system to the system default', async () => {
await ACCOUNT_HANDLERS['account select'](context('codex', 'system'))

expect(callMock).toHaveBeenCalledWith('accounts.selectCodex', { accountId: null })
})

it('refuses an email shared by several accounts and names their ids', async () => {
await expect(
ACCOUNT_HANDLERS['account select'](context('claude', 'shared@example.com'))
).rejects.toThrow('claude-org-a, claude-org-b')
expect(callMock).not.toHaveBeenCalledWith('accounts.selectClaude', expect.anything())
})

it('refuses an unknown account without switching', async () => {
await expect(
ACCOUNT_HANDLERS['account select'](context('claude', 'nobody@example.com'))
).rejects.toThrow('No managed Claude account matches')
expect(callMock).toHaveBeenCalledOnce()
})

it('requires --account', async () => {
await expect(ACCOUNT_HANDLERS['account select'](context('claude'))).rejects.toThrow(
'Use --account'
)
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('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(
'not supported from the CLI'
)
expect(callMock).not.toHaveBeenCalled()
})

it('keeps OpenCode and Devin on the profile selection path', async () => {
callMock.mockImplementation((method: string) =>
Promise.resolve({
id: 'test',
ok: true,
result:
method === 'status.get'
? { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] }
: { accounts: [], activeAccountId: null },
_meta: { runtimeId: 'test-runtime' }
})
)

await ACCOUNT_HANDLERS['account select'](context('devin', 'system'))

expect(callMock).toHaveBeenCalledWith('accounts.selectData', {
provider: 'devin',
accountId: null
})
expect(callMock).not.toHaveBeenCalledWith('accounts.list', expect.anything())
})
})

describe('formatAccountsBlock', () => {
it('prints ids for select and marks the system default when nothing is selected', () => {
expect(formatAccountsBlock('Codex', codexAccounts)).toBe(
'Managed Codex accounts (1):\n system System default (active)\n codex-1 me@codex.example'
)
})

it('marks the selected account, not the system default', () => {
const output = formatAccountsBlock('Claude', claudeAccounts)
expect(output).toContain(' system System default\n')
expect(output).toContain(' claude-work me@work.example (active)')
})
})
97 changes: 97 additions & 0 deletions src/cli/handlers/provider-account-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { HandlerContext } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
} from '../../shared/managed-account-types'
import { getWslAccountTarget } from './account-wsl-location'
import { formatAccountsBlock } from './account-list-format'
import { mutateDataAccount } from './data-account-commands'

export type ProviderAccountAgent = 'claude' | 'codex'

type ProviderAccountsSnapshot = {
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
}

const PROVIDER_LABELS: Record<ProviderAccountAgent, string> = { claude: 'Claude', codex: 'Codex' }

/** Resolves `--account` to a managed account id; `system` is the user's own sign-in (null). */
export function resolveProviderAccountId(
label: string,
accounts: readonly { id: string; email: string }[],
query: string
): string | null {
if (query === 'system') {
return null
}
const byId = accounts.find((account) => account.id === query)
if (byId) {
return byId.id
}
const byEmail = accounts.filter((account) => account.email.toLowerCase() === query.toLowerCase())
if (byEmail.length === 1) {
return byEmail[0].id
}
if (byEmail.length > 1) {
// Why: one email can sign into several organizations; picking one would run the wrong subscription.
throw new RuntimeClientError(
'invalid_argument',
`${query} matches ${byEmail.length} ${label} accounts. Pass one of these ids instead: ${byEmail
.map((account) => account.id)
.join(', ')}.`
)
}
throw new RuntimeClientError(
'invalid_argument',
`No managed ${label} account matches "${query}". Run \`orca account list\` to see ids and emails.`
)
}

/** `orca account select`: Claude and Codex switch the host account; OpenCode and Devin pick a profile. */
export async function selectAgentAccount(ctx: HandlerContext): Promise<void> {
const agent = ctx.flags.get('agent')
await (agent === 'claude' || agent === 'codex'
? selectProviderAccount(ctx, agent)
: mutateDataAccount(ctx, 'select'))
}

/** Switches the host's active Claude or Codex account, as the status-bar switcher does. */
async function selectProviderAccount(
ctx: HandlerContext,
agent: ProviderAccountAgent
): Promise<void> {
const query = ctx.flags.get('account')
if (typeof query !== 'string' || !query.trim()) {
throw new RuntimeClientError(
'invalid_argument',
'Use --account <id|email> (system for your own sign-in outside Orca).'
)
}
const label = PROVIDER_LABELS[agent]
const wslTarget = getWslAccountTarget(ctx.cwd)
if (agent === 'claude' && wslTarget && query.trim() === 'system') {
// Why: accounts.selectClaude has no target, so `system` would reset the Windows host instead.
throw new RuntimeClientError(
'invalid_argument',
'Selecting the Claude system default for a WSL distro is not supported from the CLI. Use the account switcher in Orca.'
)
}
const snapshot = await ctx.client.call<ProviderAccountsSnapshot>('accounts.list', {
refreshUsage: false
})
const accountId = resolveProviderAccountId(label, snapshot.result[agent].accounts, query.trim())
const [method, params] =
agent === 'claude'
? ['accounts.selectClaude', { accountId }]
: wslTarget
? ['accounts.selectCodexForTarget', { accountId, target: wslTarget }]
: ['accounts.selectCodex', { accountId }]
const result = await ctx.client.call<ClaudeRateLimitAccountsState | CodexRateLimitAccountsState>(
method,
params
)
printResult(result, ctx.json, (state) => formatAccountsBlock(label, state))
}
3 changes: 3 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'account add' && flag === 'agent') {
return '--agent <id> Account provider: claude, codex, opencode, or devin (default claude)'
}
if (command === 'account select' && flag === 'agent') {
return '--agent <id> Account provider: claude, codex, opencode, or devin'
}
if (command.startsWith('account ') && flag === 'agent') {
return '--agent <id> Account provider: opencode or devin'
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/root-help-text-primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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 select Select the account new agent launches use',
' account rm Remove an OpenCode or Devin account and its private data',
'',
'Skills:',
Expand Down
1 change: 1 addition & 0 deletions src/cli/root-help-text-secondary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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|codex --account <id|email|system> [--json]',
' orca host list [--json]',
' orca environment add --name <name> --pairing-code <code> [--json]',
' orca environment list [--json]',
Expand Down
16 changes: 13 additions & 3 deletions src/cli/specs/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,19 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['account', 'select'],
summary: 'Select an OpenCode or Devin profile for new agent launches',
usage: 'orca account select --agent opencode|devin --account <id|system> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account']
summary: 'Select the account new agent launches on this Orca host use',
usage:
'orca account select --agent claude|codex|opencode|devin --account <id|email|system> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'],
notes: [
'Claude and Codex accept an account id or email from `orca account list`; OpenCode and Devin take a profile id.',
'`system` returns to your own sign-in outside Orca.',
'Claude and Codex have one active account per host, the same one the status-bar switcher sets. New agents launched afterwards (e.g. `orca worktree create --agent claude`) use it.'
],
examples: [
'orca account select --agent claude --account you@example.com',
'orca account select --agent codex --account system'
]
},
{
path: ['account', 'rm'],
Expand Down