Fix Claude Agent Teams detection without Claude CLI (#7834)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0784a7ea37
commit
5a390cd60e
|
|
@ -492,6 +492,58 @@ describe('preflight', () => {
|
|||
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor'])
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams when only the Orca shim is present', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('reports Claude Agent Teams when both Orca and Claude are present', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return { stdout: '/Users/test/.local/bin/claude\n' }
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams'])
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams on native Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'where') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return { stdout: '/mock/windows/npm/claude.cmd\n' }
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return { stdout: '/mock/windows/programs/orca.cmd\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents()).resolves.toEqual(['claude'])
|
||||
})
|
||||
|
||||
it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => {
|
||||
// Why: cold GUI launches can run detection before shell-PATH hydration
|
||||
// adds user install dirs, so `which` can miss runnable CLIs.
|
||||
|
|
@ -634,6 +686,28 @@ describe('preflight', () => {
|
|||
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report Claude Agent Teams from WSL agent detection', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'wsl.exe') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
const script = String(args[5])
|
||||
expect(script).not.toContain("'orca'")
|
||||
expect(script).not.toContain("'orca-dev'")
|
||||
expect(script).not.toContain("'orca-ide'")
|
||||
if (script.includes("'claude'")) {
|
||||
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude'])
|
||||
})
|
||||
|
||||
it('detects Mistral Vibe from the installed vibe executable', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import type { PathSource, ShellHydrationFailureReason } from '../../shared/types'
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
import { getAzureDevOpsAuthStatus } from '../azure-devops/client'
|
||||
|
|
@ -22,6 +21,11 @@ import {
|
|||
detectRemoteWindowsTerminalCapabilities,
|
||||
type RemoteWindowsTerminalCapabilities
|
||||
} from './preflight-remote-windows-terminal-capabilities'
|
||||
import {
|
||||
getTuiAgentDetectionProbeCommands,
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
resolveDetectedTuiAgentIds
|
||||
} from './tui-agent-detection-commands'
|
||||
|
||||
export type PreflightStatus = {
|
||||
git: { installed: boolean }
|
||||
|
|
@ -60,13 +64,6 @@ export function _resetPreflightCache(): void {
|
|||
cached = null
|
||||
}
|
||||
|
||||
const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
|
||||
getTuiAgentDetectCommands(config).map((cmd) => ({
|
||||
id,
|
||||
cmd
|
||||
}))
|
||||
)
|
||||
|
||||
function uniqueAgentIds(ids: Iterable<string>): string[] {
|
||||
return [...new Set(ids)]
|
||||
}
|
||||
|
|
@ -92,16 +89,17 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
|
|||
if (wslTarget) {
|
||||
const foundCommands = await detectWslCommandsOnPath(
|
||||
wslTarget,
|
||||
KNOWN_AGENT_COMMANDS.map(({ cmd }) => cmd)
|
||||
)
|
||||
return uniqueAgentIds(
|
||||
KNOWN_AGENT_COMMANDS.filter(({ cmd }) => foundCommands.has(cmd)).map(({ id }) => id)
|
||||
getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl')
|
||||
)
|
||||
return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl')
|
||||
}
|
||||
|
||||
const probeCommands = getTuiAgentDetectionProbeCommands(
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
process.platform
|
||||
)
|
||||
const pathChecks = await Promise.all(
|
||||
KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({
|
||||
id,
|
||||
probeCommands.map(async (cmd) => ({
|
||||
cmd,
|
||||
installedOnPath: await isCommandOnPath(cmd)
|
||||
}))
|
||||
|
|
@ -110,11 +108,16 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
|
|||
// Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution
|
||||
// computes user install dirs once instead of blocking once per missed CLI.
|
||||
const installDirCommands = detectCommandsInInstallDirs(missedCommands)
|
||||
const checks = pathChecks.map(({ id, cmd, installedOnPath }) => ({
|
||||
id,
|
||||
installed: installedOnPath || installDirCommands.has(cmd)
|
||||
}))
|
||||
return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id))
|
||||
const foundCommands = new Set(
|
||||
pathChecks
|
||||
.filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd))
|
||||
.map(({ cmd }) => cmd)
|
||||
)
|
||||
return resolveDetectedTuiAgentIds(
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
foundCommands,
|
||||
process.platform
|
||||
)
|
||||
}
|
||||
|
||||
export async function detectInstalledAgentsWithShellPathHydration(
|
||||
|
|
@ -181,7 +184,7 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis
|
|||
return []
|
||||
}
|
||||
const result = (await mux.request('preflight.detectAgents', {
|
||||
commands: KNOWN_AGENT_COMMANDS
|
||||
commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS
|
||||
})) as { agents: string[] }
|
||||
return uniqueAgentIds(result.agents)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getTuiAgentDetectionProbeCommands,
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
resolveDetectedTuiAgentIds
|
||||
} from './tui-agent-detection-commands'
|
||||
|
||||
describe('tui agent detection commands', () => {
|
||||
it('requires Claude before reporting Claude Agent Teams', () => {
|
||||
const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter(
|
||||
(command) => command.id === 'claude-agent-teams'
|
||||
)
|
||||
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
id: 'claude-agent-teams',
|
||||
cmd: 'orca',
|
||||
requiredCommands: ['claude'],
|
||||
unsupportedRuntimes: ['win32', 'wsl']
|
||||
},
|
||||
{
|
||||
id: 'claude-agent-teams',
|
||||
cmd: 'orca-dev',
|
||||
requiredCommands: ['claude'],
|
||||
unsupportedRuntimes: ['win32', 'wsl']
|
||||
},
|
||||
{
|
||||
id: 'claude-agent-teams',
|
||||
cmd: 'orca-ide',
|
||||
requiredCommands: ['claude'],
|
||||
unsupportedRuntimes: ['win32', 'wsl']
|
||||
}
|
||||
])
|
||||
expect(getTuiAgentDetectionProbeCommands(commands, 'linux')).toEqual([
|
||||
'orca',
|
||||
'claude',
|
||||
'orca-dev',
|
||||
'orca-ide'
|
||||
])
|
||||
expect(resolveDetectedTuiAgentIds(commands, new Set(['orca']), 'linux')).toEqual([])
|
||||
expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'linux')).toEqual([
|
||||
'claude-agent-teams'
|
||||
])
|
||||
expect(getTuiAgentDetectionProbeCommands(commands, 'win32')).toEqual([])
|
||||
expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'win32')).toEqual([])
|
||||
expect(getTuiAgentDetectionProbeCommands(commands, 'wsl')).toEqual([])
|
||||
expect(resolveDetectedTuiAgentIds(commands, new Set(['orca-ide', 'claude']), 'wsl')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
getTuiAgentDetectCommands,
|
||||
TUI_AGENT_CONFIG,
|
||||
type TuiAgentConfig,
|
||||
type TuiAgentDetectionRuntime
|
||||
} from '../../shared/tui-agent-config'
|
||||
|
||||
export type TuiAgentDetectionCommand = {
|
||||
id: TuiAgent
|
||||
cmd: string
|
||||
requiredCommands?: readonly string[]
|
||||
unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[]
|
||||
}
|
||||
|
||||
export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands()
|
||||
|
||||
function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] {
|
||||
return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
|
||||
getTuiAgentDetectCommands(config).map((cmd) =>
|
||||
buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function buildTuiAgentDetectionCommand(
|
||||
id: TuiAgent,
|
||||
cmd: string,
|
||||
config: TuiAgentConfig
|
||||
): TuiAgentDetectionCommand {
|
||||
return {
|
||||
id,
|
||||
cmd,
|
||||
...(config.detectRequiredCommands?.length
|
||||
? { requiredCommands: config.detectRequiredCommands }
|
||||
: {}),
|
||||
...(config.detectUnsupportedRuntimes?.length
|
||||
? { unsupportedRuntimes: config.detectUnsupportedRuntimes }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function getTuiAgentDetectionProbeCommands(
|
||||
commands: readonly TuiAgentDetectionCommand[],
|
||||
runtime: TuiAgentDetectionRuntime
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
commands
|
||||
.filter((command) => !isDetectionUnsupportedInRuntime(command, runtime))
|
||||
.flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])])
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
export function resolveDetectedTuiAgentIds(
|
||||
commands: readonly TuiAgentDetectionCommand[],
|
||||
foundCommands: ReadonlySet<string>,
|
||||
runtime: TuiAgentDetectionRuntime
|
||||
): TuiAgent[] {
|
||||
const detected = commands
|
||||
.filter(
|
||||
(command) =>
|
||||
!isDetectionUnsupportedInRuntime(command, runtime) &&
|
||||
foundCommands.has(command.cmd) &&
|
||||
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
|
||||
)
|
||||
.map(({ id }) => id)
|
||||
return [...new Set(detected)]
|
||||
}
|
||||
|
||||
export function isDetectionUnsupportedInRuntime(
|
||||
command: TuiAgentDetectionCommand,
|
||||
runtime: TuiAgentDetectionRuntime
|
||||
): boolean {
|
||||
return command.unsupportedRuntimes?.includes(runtime) === true
|
||||
}
|
||||
|
|
@ -236,6 +236,86 @@ describe('hasAbsoluteCommandPath', () => {
|
|||
})
|
||||
|
||||
describe('PreflightHandler', () => {
|
||||
it('honors required commands when reporting detected agents', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (_file, args) => {
|
||||
const script = String(args[1])
|
||||
if (script.includes("'orca'")) {
|
||||
return { stdout: '__ORCA_AGENT_PATH__/relay/path/orca\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
requestHandlers.set(method, handler)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
new PreflightHandler(dispatcher as never)
|
||||
|
||||
const handler = requestHandlers.get('preflight.detectAgents')
|
||||
expect(handler).toBeDefined()
|
||||
await expect(
|
||||
handler!({
|
||||
commands: [
|
||||
{ id: 'claude-agent-teams', cmd: 'orca', requiredCommands: ['claude'] },
|
||||
{ id: 'claude', cmd: 'claude' }
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({ agents: [] })
|
||||
})
|
||||
|
||||
it('does not report platform-unsupported agents on native Windows SSH hosts', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (_file, args) => {
|
||||
if (String(args[0]) === 'claude') {
|
||||
return { stdout: 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd\r\n' }
|
||||
}
|
||||
if (String(args[0]) === 'orca') {
|
||||
return { stdout: 'C:\\Program Files\\Orca\\orca.cmd\r\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
const dispatcher = {
|
||||
onRequest: vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
requestHandlers.set(method, handler)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
new PreflightHandler(dispatcher as never)
|
||||
const handler = requestHandlers.get('preflight.detectAgents')
|
||||
expect(handler).toBeDefined()
|
||||
await expect(
|
||||
handler!({
|
||||
commands: [
|
||||
{
|
||||
id: 'claude-agent-teams',
|
||||
cmd: 'orca',
|
||||
requiredCommands: ['claude'],
|
||||
unsupportedRuntimes: ['win32']
|
||||
},
|
||||
{ id: 'claude', cmd: 'claude' }
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({ agents: ['claude'] })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports remote Windows shell capabilities through the SSH preflight path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,15 @@ type RelayCommandLookupOptions = {
|
|||
accountLoginShell?: string | null
|
||||
}
|
||||
|
||||
type AgentDetectionRuntime = NodeJS.Platform | 'wsl'
|
||||
|
||||
type AgentDetectionCommand = {
|
||||
id: string
|
||||
cmd: string
|
||||
requiredCommands?: readonly string[]
|
||||
unsupportedRuntimes?: readonly AgentDetectionRuntime[]
|
||||
}
|
||||
|
||||
const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish'])
|
||||
const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin'])
|
||||
const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__'
|
||||
|
|
@ -45,19 +54,42 @@ export class PreflightHandler {
|
|||
// on the relay side. This keeps the relay bundle minimal and makes the protocol
|
||||
// self-describing — the relay doesn't need to know the agent catalog.
|
||||
private async detectAgents(params: Record<string, unknown>): Promise<{ agents: string[] }> {
|
||||
const commands = params.commands as { id: string; cmd: string }[]
|
||||
const commands = params.commands as AgentDetectionCommand[]
|
||||
if (!Array.isArray(commands)) {
|
||||
return { agents: [] }
|
||||
}
|
||||
const probeCommands = [
|
||||
...new Set(
|
||||
commands
|
||||
.filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform))
|
||||
.flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])])
|
||||
)
|
||||
]
|
||||
|
||||
const results = await Promise.all(
|
||||
commands.map(async ({ id, cmd }) => ({
|
||||
id,
|
||||
probeCommands.map(async (cmd) => ({
|
||||
cmd,
|
||||
installed: await this.isCommandOnPath(cmd)
|
||||
}))
|
||||
)
|
||||
const foundCommands = new Set(
|
||||
results.filter((result) => result.installed).map(({ cmd }) => cmd)
|
||||
)
|
||||
|
||||
return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] }
|
||||
return {
|
||||
agents: [
|
||||
...new Set(
|
||||
commands
|
||||
.filter(
|
||||
(command) =>
|
||||
!isDetectionUnsupportedInRuntime(command, process.platform) &&
|
||||
foundCommands.has(command.cmd) &&
|
||||
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
|
||||
)
|
||||
.map(({ id }) => id)
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async detectWindowsTerminalCapabilities(): Promise<{
|
||||
|
|
@ -91,6 +123,13 @@ export class PreflightHandler {
|
|||
}
|
||||
}
|
||||
|
||||
function isDetectionUnsupportedInRuntime(
|
||||
command: AgentDetectionCommand,
|
||||
runtime: AgentDetectionRuntime
|
||||
): boolean {
|
||||
return command.unsupportedRuntimes?.includes(runtime) === true
|
||||
}
|
||||
|
||||
export function buildCommandLookupSpec(
|
||||
command: string,
|
||||
platform: NodeJS.Platform,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,16 @@ export type DraftPasteReadySignal =
|
|||
| 'codex-composer-prompt'
|
||||
| 'render-cursor-after-bracketed-paste'
|
||||
|
||||
export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl'
|
||||
|
||||
export type TuiAgentConfig = {
|
||||
detectCmd: string
|
||||
/** Additional executable names that identify the same agent on PATH. */
|
||||
detectCmdAliases?: readonly string[]
|
||||
/** Other commands that must also be present before this agent counts as installed. */
|
||||
detectRequiredCommands?: readonly string[]
|
||||
/** Detection runtimes where this launch mode is not available as a detected agent. */
|
||||
detectUnsupportedRuntimes?: readonly TuiAgentDetectionRuntime[]
|
||||
launchCmd: string
|
||||
/** Platform-specific launch command when the public binary name differs. */
|
||||
launchCmdByPlatform?: Partial<Record<NodeJS.Platform, string>>
|
||||
|
|
@ -71,10 +77,15 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
|||
},
|
||||
'claude-agent-teams': {
|
||||
// Why: this is an Orca-provided launch mode, not a separate upstream
|
||||
// binary. Detection follows the Orca CLI, while the wrapper validates the
|
||||
// real Claude binary when it starts.
|
||||
// binary. Detection follows the Orca CLI and requires Claude below.
|
||||
detectCmd: 'orca',
|
||||
detectCmdAliases: ['orca-dev', 'orca-ide'],
|
||||
// Why: the Orca shim alone exists on fresh installs. Require Claude too so
|
||||
// onboarding does not report Agent Teams when no agent CLI is installed.
|
||||
detectRequiredCommands: ['claude'],
|
||||
// Why: native Windows and WSL use Claude's in-process Agent Teams fallback,
|
||||
// not the Orca native-pane/tmux-shim wrapper exposed by this agent entry.
|
||||
detectUnsupportedRuntimes: ['win32', 'wsl'],
|
||||
launchCmd: 'orca claude-teams',
|
||||
launchCmdByPlatform: {
|
||||
linux: `${getOrcaCliCommandNameForPlatform('linux')} claude-teams`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue