mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
|
||||
const meta = { runtimeId: 'runtime-test' }
|
||||
|
||||
describe('formatRemoteCli', () => {
|
||||
it('falls back to JSON for malformed Linear issue results', () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
_meta: meta,
|
||||
result: {
|
||||
issue: {
|
||||
identifier: 'ENG-123',
|
||||
title: 'Fix thing',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
labels: []
|
||||
},
|
||||
meta: {
|
||||
includeErrors: null,
|
||||
sections: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(formatRemoteCli(response)).toEqual({
|
||||
stdout: `${JSON.stringify(response.result)}\n`,
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to JSON for malformed Linear search results', () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
_meta: meta,
|
||||
result: {
|
||||
issues: [],
|
||||
meta: {
|
||||
query: 'auth',
|
||||
returned: '0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(formatRemoteCli(response)).toEqual({
|
||||
stdout: `${JSON.stringify(response.result)}\n`,
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { LinearIssueContextResult, LinearSearchResult } from '../../shared/linear-agent-access'
|
||||
import type { CliStatusResult } from '../../shared/runtime-types'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
|
||||
export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } {
|
||||
if (!response.ok) {
|
||||
return { stdout: '', stderr: `${response.error.message}\n` }
|
||||
}
|
||||
const result = response.result
|
||||
if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) {
|
||||
const record = result as Record<string, unknown>
|
||||
return formatStatusResult(record as CliStatusResult)
|
||||
}
|
||||
if (isLinearIssueContextResult(result)) {
|
||||
return {
|
||||
stdout: `${formatLinearIssue(result)}\n`,
|
||||
stderr: linearIssueWarnings(result)
|
||||
}
|
||||
}
|
||||
if (isLinearSearchResult(result)) {
|
||||
return {
|
||||
stdout: `${formatLinearSearch(result)}\n`,
|
||||
stderr: linearSearchWarnings(result)
|
||||
}
|
||||
}
|
||||
return { stdout: `${JSON.stringify(result)}\n`, stderr: '' }
|
||||
}
|
||||
|
||||
function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } {
|
||||
return {
|
||||
stdout: `${[
|
||||
`appRunning: ${status.app.running}`,
|
||||
`pid: ${status.app.pid ?? 'none'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
`graphState: ${status.graph.state}`
|
||||
].join('\n')}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
|
||||
function isLinearIssueContextResult(result: unknown): result is LinearIssueContextResult {
|
||||
if (!isRecord(result)) {
|
||||
return false
|
||||
}
|
||||
const issue = result.issue
|
||||
const meta = result.meta
|
||||
if (!isRecord(issue) || !isRecord(meta)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
typeof issue.identifier === 'string' &&
|
||||
typeof issue.title === 'string' &&
|
||||
typeof issue.url === 'string' &&
|
||||
Array.isArray(issue.labels) &&
|
||||
Array.isArray(meta.includeErrors) &&
|
||||
isRecord(meta.sections)
|
||||
)
|
||||
}
|
||||
|
||||
function isLinearSearchResult(result: unknown): result is LinearSearchResult {
|
||||
if (!isRecord(result) || !isRecord(result.meta)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
Array.isArray(result.issues) &&
|
||||
typeof result.meta.query === 'string' &&
|
||||
typeof result.meta.returned === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
||||
function formatLinearIssue(result: LinearIssueContextResult): string {
|
||||
const issue = result.issue
|
||||
const lines: string[] = []
|
||||
lines.push(`${issue.identifier} ${issue.title}`)
|
||||
lines.push(`URL: ${issue.url}`)
|
||||
lines.push(`State: ${issue.state?.name ?? 'unknown'}`)
|
||||
lines.push(`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`)
|
||||
lines.push(`Project: ${issue.project?.name ?? 'none'}`)
|
||||
if (issue.labels.length > 0) {
|
||||
lines.push(
|
||||
`Labels: ${issue.labels
|
||||
.map((label) => label.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
for (const section of ['comments', 'children', 'attachments', 'relations'] as const) {
|
||||
const meta = result.meta.sections[section]
|
||||
if (meta) {
|
||||
lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatLinearSearch(result: LinearSearchResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues
|
||||
.map((issue) => {
|
||||
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}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function linearIssueWarnings(result: LinearIssueContextResult): string {
|
||||
const warnings: string[] = []
|
||||
for (const error of result.meta.includeErrors) {
|
||||
warnings.push(`warning: ${error.include} unavailable: ${error.message}`)
|
||||
}
|
||||
for (const [name, meta] of Object.entries(result.meta.sections)) {
|
||||
if (meta?.capReached) {
|
||||
warnings.push(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
|
||||
}
|
||||
}
|
||||
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
|
||||
}
|
||||
|
||||
function linearSearchWarnings(result: LinearSearchResult): string {
|
||||
const warnings: string[] = []
|
||||
if (result.meta.limitReached) {
|
||||
warnings.push(`warning: showing first ${result.meta.returned} Linear issues`)
|
||||
}
|
||||
for (const error of result.meta.workspaceErrors ?? []) {
|
||||
warnings.push(
|
||||
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
|
||||
)
|
||||
}
|
||||
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
|
||||
function createRuntime() {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-test',
|
||||
getStatus: () => ({
|
||||
runtimeId: 'runtime-test',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 1,
|
||||
liveLeafCount: 1
|
||||
}),
|
||||
linearIssueContext: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Fix thing',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
labels: []
|
||||
},
|
||||
meta: {
|
||||
requested: {
|
||||
current: true,
|
||||
include: { comments: true, children: true, attachments: true, relations: true },
|
||||
depth: 2
|
||||
},
|
||||
resolved: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme'
|
||||
},
|
||||
partial: false,
|
||||
includeErrors: [],
|
||||
sections: {}
|
||||
}
|
||||
})),
|
||||
linearSearchForAgents: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issues: [],
|
||||
meta: {
|
||||
query: 'auth bug',
|
||||
limit: 5,
|
||||
returned: 0,
|
||||
limitReached: false,
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
}))
|
||||
} as unknown as OrcaRuntimeService
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('runRemoteOrcaCli Linear commands', () => {
|
||||
it('dispatches Linear issue reads through the remote runtime with SSH context hints', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', '--current', '--full', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: {
|
||||
ORCA_TERMINAL_HANDLE: 'term_ssh',
|
||||
ORCA_WORKTREE_ID: 'repo::remote'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
result: { request: { current: boolean; context: Record<string, unknown> } }
|
||||
}
|
||||
expect(payload.ok).toBe(true)
|
||||
expect(payload.result.request).toMatchObject({
|
||||
current: true,
|
||||
include: { comments: true, children: true, attachments: true, relations: true },
|
||||
context: {
|
||||
remote: true,
|
||||
terminalHandle: 'term_ssh',
|
||||
worktreeId: 'repo::remote'
|
||||
}
|
||||
})
|
||||
expect(payload.result.request.context).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('accepts leading boolean flags before SSH Linear commands', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['--json', 'linear', 'issue', 'ENG-123', '--full'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
result: { request: { input: string; include: Record<string, boolean> } }
|
||||
}
|
||||
expect(payload.ok).toBe(true)
|
||||
expect(payload.result.request).toMatchObject({
|
||||
input: 'ENG-123',
|
||||
include: { comments: true, children: true, attachments: true, relations: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatches Linear search positional queries through the remote runtime', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'search', 'auth bug', '--limit', '5', '--workspace', 'all', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
result: { request: { query: string; limit: number; workspaceId: string } }
|
||||
}
|
||||
expect(payload.ok).toBe(true)
|
||||
expect(payload.result.request).toEqual({
|
||||
query: 'auth bug',
|
||||
limit: 5,
|
||||
workspaceId: 'all'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats SSH Linear issue reads in non-json mode', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', '--current'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('ENG-123 Fix thing')
|
||||
expect(result.stdout).toContain('URL: https://linear.app/acme/issue/ENG-123')
|
||||
expect(result.stdout).not.toContain('"issue"')
|
||||
})
|
||||
|
||||
it('prints SSH Linear search partial warnings to stderr in non-json mode', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearSearchForAgents = (
|
||||
runtime as unknown as { linearSearchForAgents: ReturnType<typeof vi.fn> }
|
||||
).linearSearchForAgents
|
||||
linearSearchForAgents.mockResolvedValueOnce({
|
||||
issues: [],
|
||||
meta: {
|
||||
query: 'auth',
|
||||
limit: 20,
|
||||
returned: 0,
|
||||
limitReached: false,
|
||||
partial: true,
|
||||
workspaceErrors: [
|
||||
{
|
||||
workspace: { id: 'workspace-stale', name: 'Stale' },
|
||||
code: 'linear_network_error',
|
||||
message: 'fetch failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'search', 'auth'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toBe('No Linear issues found.\n')
|
||||
expect(result.stderr).toContain('warning: Stale unavailable for Linear search: fetch failed')
|
||||
})
|
||||
|
||||
it('formats older SSH Linear search results without workspaceErrors in non-json mode', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearSearchForAgents = (
|
||||
runtime as unknown as { linearSearchForAgents: ReturnType<typeof vi.fn> }
|
||||
).linearSearchForAgents
|
||||
linearSearchForAgents.mockResolvedValueOnce({
|
||||
issues: [],
|
||||
meta: {
|
||||
query: 'auth',
|
||||
limit: 20,
|
||||
returned: 0,
|
||||
limitReached: false,
|
||||
partial: false
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'search', 'auth'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toBe('No Linear issues found.\n')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('prints SSH Linear non-json failures to stderr instead of stdout', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueContext = (
|
||||
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
|
||||
).linearIssueContext
|
||||
linearIssueContext.mockRejectedValueOnce(new Error('Linear is not connected.'))
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', '--current'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toContain('Linear is not connected.')
|
||||
})
|
||||
|
||||
it('shows SSH Linear command help without dispatching to the runtime', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueContext = (
|
||||
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
|
||||
).linearIssueContext
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', '--help'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('orca linear issue')
|
||||
expect(result.stdout).toContain('Usage: orca linear issue')
|
||||
expect(linearIssueContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows SSH Linear group help without dispatching to the runtime', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueContext = (
|
||||
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
|
||||
).linearIssueContext
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', '--help'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('orca linear')
|
||||
expect(result.stdout).toContain('Usage: orca linear <command> [options]')
|
||||
expect(result.stdout).toContain('search')
|
||||
expect(linearIssueContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows SSH Linear help through the local help command form', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueContext = (
|
||||
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
|
||||
).linearIssueContext
|
||||
|
||||
const group = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['help', 'linear'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
const issue = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['help', 'linear', 'issue'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(group.exitCode).toBe(0)
|
||||
expect(group.stdout).toContain('Usage: orca linear <command> [options]')
|
||||
expect(issue.exitCode).toBe(0)
|
||||
expect(issue.stdout).toContain('Usage: orca linear issue')
|
||||
expect(linearIssueContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects ambiguous Linear issue positional and flag ids in the remote shim', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', 'ENG-123', '--id', 'ENG-456', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(payload.ok).toBe(false)
|
||||
expect(payload.error).toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'Pass --id either positionally or as a flag, not both.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid Linear numeric flags in the remote shim', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'search', 'auth', '--limit', 'bad', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(payload.ok).toBe(false)
|
||||
expect(payload.error).toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'Invalid numeric value for --limit'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves Linear-specific JSON error codes for pre-dispatch remote shim validation', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'issue', 'ENG-123', '--workspace', 'all', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(payload.ok).toBe(false)
|
||||
expect(payload.error).toMatchObject({
|
||||
code: 'linear_invalid_workspace',
|
||||
message: '--workspace all is not valid for issue'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
LINEAR_CHILDREN_MAX_DEPTH,
|
||||
clampLinearIssueDepth,
|
||||
clampLinearSearchLimit,
|
||||
type LinearIssueInclude
|
||||
} from '../../shared/linear-agent-access'
|
||||
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
|
||||
type ParsedRemoteCli = {
|
||||
commandPath: string[]
|
||||
flags: Map<string, string | boolean>
|
||||
}
|
||||
|
||||
export class RemoteCliArgumentError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'RemoteCliArgumentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null {
|
||||
const helpPath = remoteLinearHelpPath(parsed)
|
||||
if (!helpPath) {
|
||||
return null
|
||||
}
|
||||
if (helpPath.length === 1 && helpPath[0] === 'linear') {
|
||||
return LINEAR_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(helpPath, 'linear', 'issue')) {
|
||||
return LINEAR_ISSUE_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(helpPath, 'linear', 'search')) {
|
||||
return LINEAR_SEARCH_HELP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
|
||||
if (parsed.commandPath[0] === 'help' && parsed.commandPath[1] === 'linear') {
|
||||
return parsed.commandPath.slice(1)
|
||||
}
|
||||
if (parsed.flags.has('help') && parsed.commandPath[0] === 'linear') {
|
||||
return parsed.commandPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const LINEAR_HELP = `orca linear
|
||||
|
||||
Usage: orca linear <command> [options]
|
||||
|
||||
Commands:
|
||||
issue Read Linear issue context for agents
|
||||
search Search connected Linear workspaces
|
||||
|
||||
Run \`orca linear <command> --help\` for command-specific usage.`
|
||||
|
||||
const LINEAR_ISSUE_HELP = `orca linear issue
|
||||
|
||||
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
|
||||
|
||||
Read Linear issue context for agents
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--current Use the current Orca worktree linked Linear issue
|
||||
--comments Include threaded Linear comments
|
||||
--children Include recursive child issues
|
||||
--depth <n> Child issue depth for --children/--full
|
||||
--attachments Include attachment metadata and URLs
|
||||
--relations Include blocking, related, and duplicate links
|
||||
--full Include all supported V1 issue context within caps
|
||||
--workspace <id> Connected Linear workspace id
|
||||
--id <id> Linear issue key, id, or URL
|
||||
|
||||
Examples:
|
||||
$ orca linear issue ENG-123
|
||||
$ orca linear issue --current --comments
|
||||
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
|
||||
|
||||
const LINEAR_SEARCH_HELP = `orca linear search
|
||||
|
||||
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
|
||||
|
||||
Search connected Linear workspaces
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--limit <n> Maximum number of rows to return
|
||||
--workspace <id|all> Connected Linear workspace id, or all
|
||||
--query <text> Text to search across Linear issues
|
||||
|
||||
Examples:
|
||||
$ orca linear search "auth bug"
|
||||
$ orca linear search ENG --workspace all --json`
|
||||
|
||||
const LINEAR_ISSUE_FLAGS = new Set([
|
||||
'help',
|
||||
'json',
|
||||
'pairing-code',
|
||||
'environment',
|
||||
'current',
|
||||
'comments',
|
||||
'children',
|
||||
'depth',
|
||||
'attachments',
|
||||
'relations',
|
||||
'full',
|
||||
'workspace',
|
||||
'id'
|
||||
])
|
||||
const LINEAR_SEARCH_FLAGS = new Set([
|
||||
'help',
|
||||
'json',
|
||||
'pairing-code',
|
||||
'environment',
|
||||
'limit',
|
||||
'workspace',
|
||||
'query'
|
||||
])
|
||||
|
||||
export async function tryDispatchRemoteLinearCli(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>
|
||||
): Promise<RpcResponse | null> {
|
||||
if (isRemoteCommand(parsed, 'linear', 'issue')) {
|
||||
validateLinearRemoteArgs(parsed, {
|
||||
command: ['linear', 'issue'],
|
||||
allowedFlags: LINEAR_ISSUE_FLAGS,
|
||||
positionalFlag: 'id',
|
||||
maxPositionals: 1
|
||||
})
|
||||
return await call(dispatcher, 'linear.issueContext', buildRemoteLinearIssueRequest(parsed, env))
|
||||
}
|
||||
if (isRemoteCommand(parsed, 'linear', 'search')) {
|
||||
validateLinearRemoteArgs(parsed, {
|
||||
command: ['linear', 'search'],
|
||||
allowedFlags: LINEAR_SEARCH_FLAGS,
|
||||
positionalFlag: 'query',
|
||||
maxPositionals: 1
|
||||
})
|
||||
return await call(dispatcher, 'linear.agentSearchIssues', {
|
||||
query: remotePositional(parsed, 2) ?? requiredString(parsed.flags, 'query'),
|
||||
limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')),
|
||||
workspaceId: optionalString(parsed.flags, 'workspace')
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateLinearRemoteArgs(
|
||||
parsed: ParsedRemoteCli,
|
||||
options: {
|
||||
command: string[]
|
||||
allowedFlags: ReadonlySet<string>
|
||||
positionalFlag: string
|
||||
maxPositionals: number
|
||||
}
|
||||
): void {
|
||||
for (const flag of parsed.flags.keys()) {
|
||||
if (!options.allowedFlags.has(flag)) {
|
||||
throw new RemoteCliArgumentError(
|
||||
'invalid_argument',
|
||||
`Unknown flag --${flag} for command: ${options.command.join(' ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const positionals = parsed.commandPath.slice(options.command.length)
|
||||
if (positionals.length > options.maxPositionals) {
|
||||
throw new RemoteCliArgumentError(
|
||||
'invalid_argument',
|
||||
`Unknown command: ${parsed.commandPath.join(' ')}`
|
||||
)
|
||||
}
|
||||
if (positionals.length > 0 && parsed.flags.has(options.positionalFlag)) {
|
||||
throw new RemoteCliArgumentError(
|
||||
'invalid_argument',
|
||||
`Pass --${options.positionalFlag} either positionally or as a flag, not both.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean {
|
||||
return command.every((part, index) => parsed.commandPath[index] === part)
|
||||
}
|
||||
|
||||
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
|
||||
return (
|
||||
commandPath.length === command.length &&
|
||||
command.every((part, index) => commandPath[index] === part)
|
||||
)
|
||||
}
|
||||
|
||||
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
|
||||
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function buildRemoteLinearIssueRequest(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
const full = parsed.flags.get('full') === true
|
||||
const include: Record<LinearIssueInclude, boolean> = {
|
||||
comments: full || parsed.flags.get('comments') === true,
|
||||
children: full || parsed.flags.get('children') === true,
|
||||
attachments: full || parsed.flags.get('attachments') === true,
|
||||
relations: full || parsed.flags.get('relations') === true
|
||||
}
|
||||
if (parsed.flags.has('depth') && !include.children) {
|
||||
throw new RemoteCliArgumentError('invalid_argument', '--depth requires --children or --full')
|
||||
}
|
||||
const requestedDepth = optionalNonNegativeInteger(parsed.flags, 'depth')
|
||||
if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) {
|
||||
throw new RemoteCliArgumentError(
|
||||
'invalid_argument',
|
||||
`--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}`
|
||||
)
|
||||
}
|
||||
const workspaceId = optionalString(parsed.flags, 'workspace')
|
||||
if (workspaceId === 'all') {
|
||||
throw new RemoteCliArgumentError(
|
||||
'linear_invalid_workspace',
|
||||
'--workspace all is not valid for issue'
|
||||
)
|
||||
}
|
||||
const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, 2)
|
||||
return {
|
||||
input,
|
||||
current: input ? false : parsed.flags.get('current') === true,
|
||||
workspaceId,
|
||||
include,
|
||||
depth: clampLinearIssueDepth(requestedDepth),
|
||||
context: {
|
||||
remote: true,
|
||||
...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}),
|
||||
...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function call(
|
||||
dispatcher: RpcDispatcher,
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<RpcResponse> {
|
||||
return await dispatcher.dispatch({
|
||||
id: `remote-cli-${Date.now()}`,
|
||||
authToken: 'remote-cli',
|
||||
method,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
function requiredString(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = optionalString(flags, name)
|
||||
if (!value) {
|
||||
throw new RemoteCliArgumentError('invalid_argument', `Missing --${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
|
||||
const value = flags.get(name)
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function optionalNumber(flags: Map<string, string | boolean>, name: string): number | undefined {
|
||||
const value = optionalString(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function optionalPositiveInteger(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = optionalNumber(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalNonNegativeInteger(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = optionalNumber(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new RemoteCliArgumentError(
|
||||
'invalid_argument',
|
||||
`Invalid non-negative integer for --${name}`
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -54,7 +54,38 @@ describe('runRemoteOrcaCli', () => {
|
||||
}),
|
||||
getOrchestrationDb: () => db,
|
||||
deliverPendingMessagesForHandle: vi.fn(),
|
||||
notifyMessageArrived: vi.fn()
|
||||
notifyMessageArrived: vi.fn(),
|
||||
linearIssueContext: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Fix thing',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
labels: []
|
||||
},
|
||||
meta: {
|
||||
requested: {
|
||||
current: true,
|
||||
include: { comments: true, children: true, attachments: true, relations: true },
|
||||
depth: 2
|
||||
},
|
||||
resolved: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme'
|
||||
},
|
||||
partial: false,
|
||||
includeErrors: [],
|
||||
sections: {}
|
||||
}
|
||||
})),
|
||||
linearSearchForAgents: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issues: [],
|
||||
meta: { query: 'auth bug', limit: 5, returned: 0, limitReached: false }
|
||||
}))
|
||||
} as unknown as OrcaRuntimeService
|
||||
return { runtime, db }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import {
|
||||
RemoteCliArgumentError,
|
||||
getRemoteLinearHelp,
|
||||
tryDispatchRemoteLinearCli
|
||||
} from './ssh-remote-linear-cli'
|
||||
|
||||
export type RemoteOrcaCliRequest = {
|
||||
argv: string[]
|
||||
@@ -20,6 +26,21 @@ type ParsedRemoteCli = {
|
||||
flags: Map<string, string | boolean>
|
||||
}
|
||||
|
||||
const REMOTE_BOOLEAN_FLAGS = new Set([
|
||||
'all',
|
||||
'attachments',
|
||||
'children',
|
||||
'comments',
|
||||
'current',
|
||||
'full',
|
||||
'help',
|
||||
'inject',
|
||||
'json',
|
||||
'relations',
|
||||
'unread',
|
||||
'wait'
|
||||
])
|
||||
|
||||
export async function runRemoteOrcaCli(
|
||||
runtime: OrcaRuntimeService,
|
||||
request: RemoteOrcaCliRequest
|
||||
@@ -27,19 +48,34 @@ export async function runRemoteOrcaCli(
|
||||
const dispatcher = new RpcDispatcher({ runtime })
|
||||
const parsed = parseRemoteCliArgs(request.argv)
|
||||
const json = parsed.flags.has('json')
|
||||
const help = getRemoteLinearHelp(parsed)
|
||||
if (help) {
|
||||
return { stdout: `${help}\n`, stderr: '', exitCode: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dispatchRemoteCli(dispatcher, parsed, request.env)
|
||||
const formatted = json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
return {
|
||||
stdout: json ? `${JSON.stringify(response, null, 2)}\n` : `${formatRemoteCli(response)}\n`,
|
||||
stderr: '',
|
||||
stdout: formatted.stdout,
|
||||
stderr: formatted.stderr,
|
||||
exitCode: response.ok ? 0 : 1
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const code =
|
||||
err instanceof RemoteCliArgumentError
|
||||
? err.code
|
||||
: err instanceof Error &&
|
||||
'code' in err &&
|
||||
typeof (err as { code: unknown }).code === 'string'
|
||||
? (err as { code: string }).code
|
||||
: 'runtime_error'
|
||||
if (json) {
|
||||
return {
|
||||
stdout: `${JSON.stringify(buildLocalError(message), null, 2)}\n`,
|
||||
stdout: `${JSON.stringify(buildLocalError(message, code), null, 2)}\n`,
|
||||
stderr: '',
|
||||
exitCode: 1
|
||||
}
|
||||
@@ -54,6 +90,10 @@ async function dispatchRemoteCli(
|
||||
env: Record<string, string>
|
||||
): Promise<RpcResponse> {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env)
|
||||
if (linearResponse) {
|
||||
return linearResponse
|
||||
}
|
||||
switch (command) {
|
||||
case 'status': {
|
||||
const response = await call(dispatcher, 'status.get')
|
||||
@@ -147,7 +187,7 @@ function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli {
|
||||
|
||||
const flag = assignment
|
||||
const next = argv[i + 1]
|
||||
if (next && !next.startsWith('--')) {
|
||||
if (!REMOTE_BOOLEAN_FLAGS.has(flag) && next && !next.startsWith('--')) {
|
||||
flags.set(flag, next)
|
||||
i += 1
|
||||
} else {
|
||||
@@ -184,33 +224,17 @@ function optionalNumber(flags: Map<string, string | boolean>, name: string): num
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function formatRemoteCli(response: RpcResponse): string {
|
||||
if (!response.ok) {
|
||||
return response.error.message
|
||||
}
|
||||
const result = response.result as Record<string, unknown>
|
||||
if ('app' in result && 'runtime' in result && 'graph' in result) {
|
||||
const status = result as CliStatusResult
|
||||
return [
|
||||
`appRunning: ${status.app.running}`,
|
||||
`pid: ${status.app.pid ?? 'none'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
`graphState: ${status.graph.state}`
|
||||
].join('\n')
|
||||
}
|
||||
return JSON.stringify(response.result)
|
||||
}
|
||||
|
||||
function buildLocalError(message: string): RpcResponse {
|
||||
function buildLocalError(message: string, code = 'runtime_error'): RpcResponse {
|
||||
return {
|
||||
id: 'remote-cli-local',
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message },
|
||||
error: { code, message },
|
||||
_meta: { runtimeId: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user