Skip to content
Open
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
14 changes: 14 additions & 0 deletions config/tsconfig.cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
// Why: `orca supervisor` is a thin front door over the same generator and audit orcad
// exposes. Both modules import only node builtins and src/shared, so listing them
// pulls in nothing else.
"../src/main/orcad/orcad-app-paths.ts",
"../src/main/orcad/orcad-service-command.ts",
"../src/main/orcad/supervisor-service-discovery.ts",
"../src/main/orcad/supervisor-generation-warnings.ts",
"../src/main/orcad/supervisor-host-observations.ts",
// Why these two as well: the doctor answers whether a data root leaves room for the daemon's
// socket, which is arithmetic over the daemon's endpoint names. Both are leaves — node
// builtins, src/shared and each other — so the daemon itself stays out of this graph.
"../src/main/orcad/supervisor-daemon-socket-budget.ts",
"../src/main/daemon/daemon-runtime-paths.ts",
"../src/main/daemon/daemon-protocol-version.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/sqlite/sync-database.ts",
"../src/main/win32-utils.ts"
Expand Down
6 changes: 5 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,11 @@ export const electronViteConfig: UserConfig = {
'src/main/agent-hooks/managed-agent-hook-controls.ts'
),
// Why: account import mutates the user's macOS Keychain from the CLI.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'),
// Why: `orca supervisor print|doctor` is a front door over orcad's own generator
// and audit, so the CLI imports this path. Without an entry the build that runs
// after build:cli cleans it out of out/main and the command dies at require time.
'orcad/orcad-service-command': resolve('src/main/orcad/orcad-service-command.ts')
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
Expand Down
16 changes: 16 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ describe('parseArgs', () => {
expect(parsed.flags.get('value')).toBe('')
})

// Why this is not in BOOLEAN_FLAGS: that set exists for switches a following token could
// be mistaken for a value, and `supervisor doctor` takes no positionals — so every shape
// it can be written in already parses as a switch. Pinned because reading the set alone
// suggests otherwise.
it('reads --no-probe as a switch in every shape supervisor doctor accepts', () => {
expect(parseArgs(['supervisor', 'doctor', '--no-probe']).flags.get('no-probe')).toBe(true)
expect(parseArgs(['supervisor', 'doctor', '--no-probe', '--json']).flags.get('no-probe')).toBe(
true
)

const withPath = parseArgs(['supervisor', 'doctor', '--no-probe', '--service-path', '/etc/x'])

expect(withPath.flags.get('no-probe')).toBe(true)
expect(withPath.flags.get('service-path')).toBe('/etc/x')
})

it('still parses boolean flags and space-separated values', () => {
const parsed = parseArgs(['tab', 'create', '--json', '--url', 'https://example.com'])

Expand Down
1 change: 1 addition & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
'diagnostics',
'linear',
'skills',
'supervisor',
'vm'
].includes(commandPath[0])) ||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
Expand Down
5 changes: 5 additions & 0 deletions src/cli/handler-group-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['claude-teams', 'open', 'serve', 'status'],
load: async () => (await import('./handlers/core.js')).CORE_HANDLERS
},
{
name: 'supervisor',
keys: ['supervisor print', 'supervisor doctor'],
load: async () => (await import('./handlers/supervisor.js')).SUPERVISOR_HANDLERS
},
{
name: 'account',
keys: ['account add', 'account list'],
Expand Down
131 changes: 131 additions & 0 deletions src/cli/handlers/supervisor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { hostname } from 'node:os'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'

const { printServiceMock, runDoctorMock, collectDoctorFindingsMock } = vi.hoisted(() => ({
printServiceMock: vi.fn(),
runDoctorMock: vi.fn(),
collectDoctorFindingsMock: vi.fn()
}))

// Why mock: these handlers are an adapter, and the thing worth pinning is the argv they
// build and the exit code they carry — not the generator, which has its own tests and
// would drag a real host's service files into this one.
vi.mock('../../main/orcad/orcad-service-command', () => ({
printService: printServiceMock,
runDoctor: runDoctorMock,
collectDoctorFindings: collectDoctorFindingsMock
}))

import { SUPERVISOR_HANDLERS } from './supervisor'
import { SupervisorServiceUnsupportedError } from '../../shared/supervisor-service-render'

const originalExitCode = process.exitCode
let stdout: string

beforeEach(() => {
stdout = ''
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdout += String(chunk)
return true
})
})

afterEach(() => {
process.exitCode = originalExitCode
vi.restoreAllMocks()
vi.clearAllMocks()
})

function run(
command: string,
flags: [string, string | boolean][] = [],
json = false
): Promise<void> {
return SUPERVISOR_HANDLERS[command]({ flags: new Map(flags), json } as never)
}

it('rebuilds the argv the orcad parser expects, in its own flag order', async () => {
printServiceMock.mockResolvedValue(0)

await run('supervisor print', [
['bind', '127.0.0.1'],
['scope', 'system'],
['orcad', '/opt/orcad/orcad.js'],
['user', 'orca']
])

expect(printServiceMock).toHaveBeenCalledWith([
'--orcad',
'/opt/orcad/orcad.js',
'--scope',
'system',
'--user',
'orca',
'--bind',
'127.0.0.1'
])
})

// Why: the orcad parser reads --no-probe as a bare switch and treats the next token as an
// unknown argument, so emitting it with a value would fail the command outright.
it('emits a boolean flag bare and omits the flags nobody passed', async () => {
runDoctorMock.mockResolvedValue(0)

await run('supervisor doctor', [
['service-path', '/etc/systemd/system/orcad.service'],
['no-probe', true]
])

expect(runDoctorMock).toHaveBeenCalledWith([
'--service-path',
'/etc/systemd/system/orcad.service',
'--no-probe'
])
})

it('refuses --json on print instead of generating a unit file wrapped in nothing', async () => {
await expect(
run('supervisor print', [['orcad', '/opt/orcad/orcad.js']], true)
).rejects.toMatchObject({ code: 'invalid_argument' })

expect(printServiceMock).not.toHaveBeenCalled()
})

it('carries the audit exit code out of the JSON path and names the host it read', async () => {
const findings = [{ code: 'kill_mode_unsafe', severity: 'critical', message: 'KillMode=mixed' }]
collectDoctorFindingsMock.mockResolvedValue({ findings, code: 1 })

await run('supervisor doctor', [], true)

expect(JSON.parse(stdout)).toEqual({ host: hostname(), findings })
expect(process.exitCode).toBe(1)
})

// Why the host is on stdout at all: the CLI normally targets a paired remote runtime, so a
// verdict that does not name a machine reads as one about the machine you meant.
it('names the host in the text path and leaves a clean audit exit code alone', async () => {
runDoctorMock.mockResolvedValue(0)

await run('supervisor doctor')

expect(stdout).toContain(`on ${hostname()} (this machine)`)
expect(process.exitCode).toBe(originalExitCode)
})

it('reports an unsupported platform as a bad request rather than a CLI crash', async () => {
printServiceMock.mockRejectedValue(new SupervisorServiceUnsupportedError('win32'))

await expect(run('supervisor print', [['orcad', '/opt/orcad/orcad.js']])).rejects.toMatchObject({
code: 'invalid_argument'
})
})

// Why: the missing-`--orcad` refusal is a plain Error raised inside the generator, and its
// message is the only thing that names the flag — translating it would lose that.
it('leaves every other failure with its own message', async () => {
printServiceMock.mockRejectedValue(
new Error('this process is /opt/orca/cli/index.js, not orcad.js')
)

await expect(run('supervisor print')).rejects.toThrow('not orcad.js')
})
74 changes: 74 additions & 0 deletions src/cli/handlers/supervisor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* `orca supervisor print|doctor` — a front door over the generator and audit orcad already
* exposes as `--print-service` and `--doctor`.
*
* These inspect and describe THIS machine. The CLI normally targets a paired runtime that
* is a different host entirely, so `supervisor` is registered in `shouldIgnoreRemoteSelection`
* and every answer names the host it looked at — otherwise an operator would read a verdict
* about their laptop as a verdict about their server.
*/
import { hostname } from 'node:os'
import process from 'node:process'
import type { CommandHandler } from '../dispatch'
import { RuntimeClientError } from '../runtime/types'
import {
collectDoctorFindings,
printService,
runDoctor
} from '../../main/orcad/orcad-service-command'
import { SupervisorServiceUnsupportedError } from '../../shared/supervisor-service-render'

/** Why translate: an unsupported platform is a bad request, not a CLI crash. */
async function reportingUnsupported<T>(run: () => Promise<T> | T): Promise<T> {
try {
return await run()
} catch (error) {
throw error instanceof SupervisorServiceUnsupportedError
? new RuntimeClientError('invalid_argument', error.message)
: error
}
}

/** Rebuilds the argv the orcad-side parser expects from the CLI's parsed flag map. */
function toServiceArgv(flags: Map<string, string | boolean>, names: string[]): string[] {
const argv: string[] = []
for (const name of names) {
const value = flags.get(name)
if (value === true) {
argv.push(`--${name}`)
} else if (typeof value === 'string') {
argv.push(`--${name}`, value)
}
}
return argv
}

export const SUPERVISOR_HANDLERS: Record<string, CommandHandler> = {
'supervisor print': async ({ flags, json }) => {
// Why refuse rather than ignore: stdout here is a file meant to be redirected, so
// honouring --json would hand a scripted caller a unit file wrapped in nothing.
if (json) {
throw new RuntimeClientError(
'invalid_argument',
'supervisor print writes a service definition to stdout, so it has no JSON form. Drop --json.'
)
}
await reportingUnsupported(() =>
printService(toServiceArgv(flags, ['orcad', 'scope', 'user', 'node', 'port', 'bind']))
)
},
'supervisor doctor': async ({ flags, json }) => {
const argv = toServiceArgv(flags, ['service-path', 'no-probe'])
if (json) {
const { findings, code } = await reportingUnsupported(() => collectDoctorFindings(argv))
process.stdout.write(`${JSON.stringify({ host: hostname(), findings }, null, 2)}\n`)
process.exitCode = code
return
}
process.stdout.write(`Inspecting service definitions on ${hostname()} (this machine).\n\n`)
const code = await reportingUnsupported(() => runDoctor(argv))
if (code !== 0) {
process.exitCode = code
}
}
}
42 changes: 41 additions & 1 deletion src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ vi.mock('child_process', async () => {

import { COMMAND_SPECS, main } from './index'
import { formatFlagHelp } from './help'
import { GLOBAL_FLAGS, specPaths } from './args'
import { GLOBAL_FLAGS, isCommandGroup, specPaths } from './args'
import { okFixture, queueFixtures } from './test-fixtures'

describe('COMMAND_SPECS collision check', () => {
Expand All @@ -58,6 +58,19 @@ describe('COMMAND_SPECS collision check', () => {
}
})

// Why: `orca <group> --help` falls through printHelp to "Unknown command: <group>" plus
// the whole root help, and exits 1, when the prefix is missing from isCommandGroup — while
// each subcommand's own --help keeps working, so nothing about the specs reveals the gap.
// Registration is exhaustive over the live tree; this keeps the next group from shipping
// without it.
it('registers every multi-segment command prefix as a command group', () => {
const ungrouped = [
...new Set(COMMAND_SPECS.filter((spec) => spec.path.length > 1).map((spec) => spec.path[0]))
].filter((prefix) => !isCommandGroup([prefix]))

expect(ungrouped).toEqual([])
})

it('allows every flag documented in command usage strings', () => {
const flagPattern = /--([a-zA-Z0-9-]+)/g
for (const spec of COMMAND_SPECS) {
Expand Down Expand Up @@ -305,6 +318,33 @@ describe('orca root help', () => {
logSpy.mockRestore()
})

it('lists the supervisor commands and answers `orca supervisor --help` as a group', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const priorExitCode = process.exitCode

await main(['--help'], '/tmp/repo')

expect(String(logSpy.mock.calls[0][0])).toContain(
'supervisor print Print a service definition for running orcad on this machine'
)

logSpy.mockClear()
process.exitCode = 0
await main(['supervisor', '--help'], '/tmp/repo')

// Why the exit code is asserted: an unregistered group still prints something — the whole
// root help, behind "Unknown command: supervisor" — and only the 1 says it went wrong.
const groupHelp = String(logSpy.mock.calls[0][0])
expect(groupHelp).toContain('orca supervisor <command>')
expect(groupHelp).toContain('Print a supervisor service definition')
expect(groupHelp).toContain('Check an installed orcad service')
expect(groupHelp).not.toContain('Unknown command')
expect(process.exitCode).toBe(0)

process.exitCode = priorExitCode
logSpy.mockRestore()
})

it('advertises computer-use capabilities discovery', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

Expand Down
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
commandPath[0] === 'artifacts' ||
commandPath[0] === 'environment' ||
commandPath[0] === 'serve' ||
// Why local-only: supervisor inspects THIS machine's service files, which is normally
// not the host a paired CLI targets. Remote selection would silently answer about the
// wrong machine.
commandPath[0] === 'supervisor' ||
commandPath[0] === 'agent' ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context'
Expand Down
4 changes: 4 additions & 0 deletions src/cli/root-help-text-primary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' serve Start a headless Orca runtime server',
' status Show app/runtime/graph readiness',
'',
'Supervisor:',
' supervisor print Print a service definition for running orcad on this machine',
' supervisor doctor Check an installed orcad service for settings that lose terminals',
'',
'Diagnostics:',
' diagnostics memory Collect a memory snapshot for Orca and managed terminals',
'',
Expand Down
2 changes: 2 additions & 0 deletions src/cli/specs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import { LINEAR_COMMAND_SPECS } from './linear'
import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
import { SUPERVISOR_COMMAND_SPECS } from './supervisor'

export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...SUPERVISOR_COMMAND_SPECS,
...ARTIFACT_COMMAND_SPECS,
...ACCOUNT_COMMAND_SPECS,
...PROJECT_COMMAND_SPECS,
Expand Down
Loading