Add read-only orca linear CLI with trusted launch-prompt pointer (V1) (#5126)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-10 20:20:50 -07:00
committed by GitHub
co-authored by Orca
parent 519cc5d1c3
commit cdc0ca5e53
68 changed files with 4379 additions and 283 deletions
+40 -2
View File
@@ -17,6 +17,38 @@ export type CommandSpec = {
}
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
export const BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'current',
'dry-run',
'enter',
'focus',
'force',
'full',
'help',
'inject',
'interrupt',
'json',
'messages',
'mobile',
'mobile-pairing',
'no-pairing',
'ready',
'relations',
'restore-window',
'return-preamble',
'run-hooks',
'show-profile',
'staged',
'tasks',
'text-stdin',
'unread',
'value-stdin',
'wait'
])
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
@@ -40,6 +72,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
}
const flag = assignment
if (BOOLEAN_FLAGS.has(flag)) {
flags.set(flag, true)
continue
}
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
@@ -85,7 +121,8 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
'computer',
'emulator',
'note',
'diagnostics'
'diagnostics',
'linear'
].includes(commandPath[0])
) {
return false
@@ -123,7 +160,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
'emulator',
'agent',
'environment',
'diagnostics'
'diagnostics',
'linear'
].includes(commandPath[0])) ||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
(commandPath.length === 2 &&
+3 -1
View File
@@ -20,6 +20,7 @@ import { ENVIRONMENT_HANDLERS } from './handlers/environment'
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics'
import { EMULATOR_HANDLERS } from './handlers/emulator'
import { LINEAR_HANDLERS } from './handlers/linear'
export type HandlerContext = {
flags: Map<string, string | boolean>
@@ -53,7 +54,8 @@ function buildHandlers(): Map<string, CommandHandler> {
COMPUTER_HANDLERS,
AGENT_HOOK_HANDLERS,
DIAGNOSTICS_HANDLERS,
ENVIRONMENT_HANDLERS
ENVIRONMENT_HANDLERS,
LINEAR_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {
+215
View File
@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../runtime-client', () => {
class RuntimeClient {
readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
constructor(
_userDataPath?: string,
_requestTimeoutMs?: number,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
) {
this.isRemote = Boolean(remotePairingCode || environmentSelector)
}
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { main } from '../index'
import { okFixture, queueFixtures } from '../test-fixtures'
describe('orca linear CLI handlers', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.restoreAllMocks()
callMock.mockReset()
process.env = { ...originalEnv }
// Why: these tests can run inside an Orca-managed terminal, which exports
// real worktree/terminal/pairing env hints; clear them so handler context
// assertions stay deterministic.
delete process.env.ORCA_WORKTREE_ID
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_PAIRING_CODE
delete process.env.ORCA_ENVIRONMENT
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('maps --full issue reads to read-only issueContext RPC', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', 'ENG-123', '--full', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
include: {
comments: true,
children: true,
attachments: true,
relations: true
},
depth: 2,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 120_000 }
)
})
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['--json', 'linear', 'issue', 'ENG-123', '--full'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: 'ENG-123',
include: expect.objectContaining({
comments: true,
children: true,
attachments: true,
relations: true
})
}),
{ timeoutMs: 120_000 }
)
})
it('passes verified current-context hints without resolving cwd for remote runtimes', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_123'
process.env.ORCA_WORKTREE_ID = 'repo::/srv/app'
process.env.ORCA_PAIRING_CODE = 'orca://pair?payload=bad'
queueFixtures(callMock, okFixture('req_linear', issueResult()))
await main(['linear', 'issue', '--current', '--comments', '--json'], '/client/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueContext',
expect.objectContaining({
input: undefined,
current: true,
include: expect.objectContaining({ comments: true }),
context: {
remote: true,
worktreeId: 'repo::/srv/app',
terminalHandle: 'term_123'
}
}),
{ timeoutMs: undefined }
)
})
it('rejects --depth unless children are requested', async () => {
await main(['linear', 'issue', 'ENG-123', '--depth', '3'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--depth requires --children or --full'
)
expect(process.exitCode).toBe(1)
})
it('maps search to agent search RPC with capped limit', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: 'all', limit: 50, returned: 0, limitReached: false }
})
)
await main(['linear', 'search', 'auth', '--workspace', 'all', '--limit', '500'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 50,
workspaceId: 'all'
})
})
it('keeps boolean flags between Linear and search from consuming the subcommand', async () => {
queueFixtures(
callMock,
okFixture('req_search', {
issues: [],
meta: { query: 'auth', workspaceId: undefined, limit: 1, returned: 0, limitReached: false }
})
)
await main(['linear', '--json', 'search', 'auth', '--limit', '1'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
query: 'auth',
limit: 1,
workspaceId: undefined
})
})
})
function issueResult(): unknown {
return {
issue: {
id: 'issue-id',
identifier: 'ENG-123',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-123',
state: { name: 'Todo' },
team: { name: 'Engineering' },
labels: []
},
meta: {
requested: {
current: false,
include: { comments: false, children: false, attachments: false, relations: false },
depth: 2
},
resolved: {
id: 'issue-id',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import type {
LinearIssueContextResult,
LinearIssueInclude,
LinearIssueRequest,
LinearSearchResult
} from '../../shared/linear-agent-access'
import {
LINEAR_CHILDREN_MAX_DEPTH,
clampLinearIssueDepth,
clampLinearSearchLimit
} from '../../shared/linear-agent-access'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import {
getOptionalNonNegativeIntegerFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import {
formatLinearIssue,
formatLinearSearch,
printLinearIssueWarnings,
printLinearSearchWarnings
} from '../linear-format'
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
'linear issue': async ({ flags, client, cwd, json }) => {
const request = buildIssueRequest(flags, cwd, client.isRemote)
const response = await client.call<LinearIssueContextResult>('linear.issueContext', request, {
timeoutMs: flags.get('full') === true ? ISSUE_CONTEXT_TIMEOUT_MS : undefined
})
if (!json) {
printLinearIssueWarnings(response.result)
}
printResult(response, json, formatLinearIssue)
},
'linear search': async ({ flags, client, json }) => {
const limit = clampLinearSearchLimit(getOptionalPositiveIntegerFlag(flags, 'limit'))
const response = await client.call<LinearSearchResult>('linear.agentSearchIssues', {
query: getRequiredStringFlag(flags, 'query'),
limit,
workspaceId: getOptionalStringFlag(flags, 'workspace')
})
if (!json) {
printLinearSearchWarnings(response.result)
}
printResult(response, json, formatLinearSearch)
}
}
function buildIssueRequest(
flags: Map<string, string | boolean>,
cwd: string,
remote: boolean
): LinearIssueRequest {
const full = flags.get('full') === true
const includes: Record<LinearIssueInclude, boolean> = {
comments: full || flags.get('comments') === true,
children: full || flags.get('children') === true,
attachments: full || flags.get('attachments') === true,
relations: full || flags.get('relations') === true
}
if (flags.has('depth') && !includes.children) {
throw new RuntimeClientError('invalid_argument', '--depth requires --children or --full')
}
const requestedDepth = getOptionalNonNegativeIntegerFlag(flags, 'depth')
if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) {
throw new RuntimeClientError(
'invalid_argument',
`--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}`
)
}
const workspaceId = getOptionalStringFlag(flags, 'workspace')
if (workspaceId === 'all') {
throw new RuntimeClientError(
'linear_invalid_workspace',
'--workspace all is not valid for issue'
)
}
const input = getOptionalStringFlag(flags, 'id')
return {
input,
current: input ? false : flags.get('current') === true,
workspaceId,
include: includes,
depth: clampLinearIssueDepth(requestedDepth),
context: {
remote,
...(remote ? {} : { cwd }),
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
...(process.env.ORCA_TERMINAL_HANDLE
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
: {})
}
}
}
+37
View File
@@ -97,6 +97,9 @@ Computer Use:
computer paste-text Paste text through the native clipboard path
computer set-value Set the value of a settable app element
Linear:
linear Read Linear ticket context for agents
Mobile Emulator (iOS Simulator):
emulator list List available/running emulators (Orca-managed + raw serve-sim)
emulator attach <device> Attach/start helper and make active for the worktree
@@ -356,6 +359,18 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
const command = commandPath.join(' ')
if (command === 'linear issue' && flag === 'id') {
return '--id <id> Linear issue key, id, or URL'
}
if (command === 'linear issue' && flag === 'workspace') {
return '--workspace <id> Connected Linear workspace id'
}
if (command === 'linear search' && flag === 'query') {
return '--query <text> Text to search across Linear issues'
}
if (command === 'linear search' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
}
@@ -459,5 +474,27 @@ export function formatFlagHelp(flag: string): string {
format: '--format <png|jpeg> Screenshot image format'
}
if (flag === 'current') {
return '--current Use the current Orca worktree linked Linear issue'
}
if (flag === 'comments') {
return '--comments Include threaded Linear comments'
}
if (flag === 'children') {
return '--children Include recursive child issues'
}
if (flag === 'depth') {
return '--depth <n> Child issue depth for --children/--full'
}
if (flag === 'attachments') {
return '--attachments Include attachment metadata and URLs'
}
if (flag === 'relations') {
return '--relations Include blocking, related, and duplicate links'
}
if (flag === 'full') {
return '--full Include all supported V1 issue context within caps'
}
return helpByFlag[flag] ?? `--${flag}`
}
+41
View File
@@ -143,6 +143,47 @@ describe('orca root help', () => {
)
expect(callMock).not.toHaveBeenCalled()
})
it('progressively discloses Linear commands', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['--help'], '/tmp/repo')
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).toContain('Linear:')
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
expect(rootHelp).not.toContain('linear issue')
expect(rootHelp).not.toContain('linear search')
logSpy.mockClear()
await main(['linear', '--help'], '/tmp/repo')
const groupHelp = String(logSpy.mock.calls[0][0])
expect(groupHelp).toContain('orca linear')
expect(groupHelp).toContain('issue')
expect(groupHelp).toContain('search')
expect(groupHelp).not.toContain('--comments')
expect(groupHelp).not.toContain('--attachments')
logSpy.mockClear()
await main(['linear', 'issue', '--help'], '/tmp/repo')
const issueHelp = String(logSpy.mock.calls[0][0])
expect(issueHelp).toContain('orca linear issue [<id>]')
expect(issueHelp).toContain('--comments Include threaded Linear comments')
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
logSpy.mockClear()
await main(['linear', 'search', '--help'], '/tmp/repo')
const searchHelp = String(logSpy.mock.calls[0][0])
expect(searchHelp).toContain('orca linear search <query>')
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
expect(callMock).not.toHaveBeenCalled()
})
})
describe('orca cli worktree awareness', () => {
+28
View File
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearSearchResult } from '../shared/linear-agent-access'
import { printLinearSearchWarnings } from './linear-format'
describe('linear-format', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('treats older search results without workspaceErrors as non-partial', () => {
const result = {
issues: [],
meta: {
query: 'auth',
workspaceId: 'all',
limit: 20,
returned: 0,
limitReached: false,
partial: false
}
} as unknown as LinearSearchResult
printLinearSearchWarnings(result)
expect(console.error).not.toHaveBeenCalled()
})
})
+73
View File
@@ -0,0 +1,73 @@
import type {
LinearIssueContextResult,
LinearSearchIssueSummary,
LinearSearchResult
} from '../shared/linear-agent-access'
export function formatLinearIssue(result: LinearIssueContextResult): string {
const issue = result.issue
const lines = [
`${issue.identifier} ${issue.title}`,
`URL: ${issue.url}`,
`State: ${issue.state?.name ?? 'unknown'}`,
`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`,
`Project: ${issue.project?.name ?? 'none'}`
]
if (issue.labels.length > 0) {
lines.push(
`Labels: ${issue.labels
.map((label) => label.name)
.filter(Boolean)
.join(', ')}`
)
}
const sections = result.meta.sections
if (sections.comments) {
lines.push(`Comments: ${sections.comments.returned}`)
}
if (sections.children) {
lines.push(`Children: ${sections.children.returned}`)
}
if (sections.attachments) {
lines.push(`Attachments: ${sections.attachments.returned}`)
}
if (sections.relations) {
lines.push(`Relations: ${sections.relations.returned}`)
}
return lines.join('\n')
}
export function formatLinearSearch(result: LinearSearchResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
}
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
for (const error of result.meta.includeErrors) {
console.error(`warning: ${error.include} unavailable: ${error.message}`)
}
for (const [name, meta] of Object.entries(result.meta.sections)) {
if (meta?.capReached) {
console.error(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
}
}
}
export function printLinearSearchWarnings(result: LinearSearchResult): void {
if (result.meta.limitReached) {
console.error(`warning: showing first ${result.meta.returned} Linear issues`)
}
for (const error of result.meta.workspaceErrors ?? []) {
console.error(
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
)
}
}
function formatSearchRow(issue: LinearSearchIssueSummary): string {
const state = issue.state?.name ?? 'unknown'
const assignee = issue.assignee?.displayName ?? 'unassigned'
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
}
+2
View File
@@ -10,6 +10,7 @@ import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
import { EMULATOR_COMMAND_SPECS } from './emulator'
import { LINEAR_COMMAND_SPECS } from './linear'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -22,5 +23,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...AGENT_HOOK_COMMAND_SPECS,
...DIAGNOSTICS_COMMAND_SPECS,
...ENVIRONMENT_COMMAND_SPECS,
...LINEAR_COMMAND_SPECS,
...EMULATOR_COMMAND_SPECS
]
+37
View File
@@ -0,0 +1,37 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
{
path: ['linear', 'issue'],
summary: 'Read Linear issue context for agents',
usage:
'orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'current',
'comments',
'children',
'depth',
'attachments',
'relations',
'full',
'workspace',
'id'
],
positionalArgs: ['id'],
examples: [
'orca linear issue ENG-123',
'orca linear issue --current --comments',
'orca linear issue https://linear.app/acme/issue/ENG-123 --full --json'
]
},
{
path: ['linear', 'search'],
summary: 'Search connected Linear workspaces',
usage: 'orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
positionalArgs: ['query'],
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --json']
}
]