Files
orca/src/cli/args.ts
T
Neil 15717a4e53 Add CLI commands to manage and find workspace references (#26807)
* Add CLI commands to manage and find workspace references

* Fix reference review findings: source-context twins, legacy link collapse, closed chats, old-host errors

- Keep same-URL links with different source contexts separate; CLI add/remove no longer drops a twin
- Restore merging a bare legacy link into its single matching URL link (no duplicate PR rows, no resurrection on older-client removal)
- Carry absorbed data through collapse chains; resolve repoId conflicts deterministically
- Identity: ignore www., case-insensitive GitHub/GitLab/Gitea paths (stored URL keeps case), no Jira match on git hosts; find accepts self-hosted issue URLs
- reference find: skip closed native chats, require a session id for linked, include an explicitly named archived workspace, warm-cache fallback for --worktree
- Selector parity: collapse same-host duplicate paths, issue: ignores unlinked rows
- CLI: clear update-host error for hosts without reference.*, local flag checks before host probe, help text fixes

* Fix CI: add reference rm alias for verb policy; expect linkedItems in worktree ps summary

* Simplify reference CLI: share identifier/legacy-match helpers, drop redundant dedupe
2026-10-09 22:22:01 -07:00

288 lines
8.7 KiB
TypeScript

import { RuntimeClientError } from './runtime/types'
import { unknownCommandData, unknownFlagData } from './command-suggestion'
import { specPaths, type CommandSpec } from './command-spec'
import {
CLI_BOOLEAN_FLAGS,
CLI_GLOBAL_FLAGS,
CLI_GLOBAL_VALUE_FLAGS,
findCliCommandIndex
} from '../shared/cli-argument-boundary'
export { specPaths }
export type { CommandSpec }
export type ParsedArgs = {
commandPath: string[]
flags: Map<string, string | boolean>
positionalFlagConflicts?: string[]
}
export const GLOBAL_FLAGS = CLI_GLOBAL_FLAGS
const GLOBAL_VALUE_FLAGS = new Set(CLI_GLOBAL_VALUE_FLAGS)
export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
function setFlagValue(
flags: Map<string, string | boolean>,
name: string,
value: string,
repeatable: ReadonlySet<string>
): void {
const existing = flags.get(name)
if (typeof existing === 'string' && repeatable.has(name)) {
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
return
}
flags.set(name, value)
}
/** The most specific spec whose path prefixes `path`, so a group never shadows a leaf. */
function specForPathPrefix(
specs: readonly CommandSpec[],
path: readonly string[]
): CommandSpec | undefined {
let best: { spec: CommandSpec; length: number } | undefined
for (const spec of specs) {
for (const candidate of specPaths(spec)) {
if (
candidate.length <= path.length &&
candidate.every((part, index) => part === path[index]) &&
(!best || candidate.length > best.length)
) {
best = { spec, length: candidate.length }
}
}
}
return best?.spec
}
export function parseArgs(
argv: string[],
commandPaths?: readonly string[][],
specs: readonly CommandSpec[] = []
): ParsedArgs {
const commandPath: string[] = []
const flagEntries: [string, string | boolean][] = []
const commandIndex = findCliCommandIndex(argv, commandPaths ?? [])
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const assignment = token.slice(2)
// Why: `--flag=value` is the only unambiguous way to pass a value that
// itself starts with `--` (e.g. `--text=--help`); the space-separated form
// treats a `--`-leading next token as a new flag, so it can't express one.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
flagEntries.push([assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)])
continue
}
const flag = assignment
if (BOOLEAN_FLAGS.has(flag)) {
flagEntries.push([flag, true])
continue
}
// Why: a pre-command flag must not consume a registry-resolvable command path.
if (commandPath.length === 0 && i + 1 === commandIndex) {
flagEntries.push([flag, true])
continue
}
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
flagEntries.push([flag, true])
continue
}
flagEntries.push([flag, next])
i += 1
}
const declared = specForPathPrefix(specs, commandPath)?.repeatableFlags
const repeatable = declared
? new Set([...REPEATABLE_STRING_FLAGS, ...declared])
: REPEATABLE_STRING_FLAGS
const flags = new Map<string, string | boolean>()
for (const [name, value] of flagEntries) {
if (typeof value === 'string') {
setFlagValue(flags, name, value, repeatable)
} else {
flags.set(name, value)
}
}
return { commandPath, flags }
}
export function resolveHelpPath(parsed: ParsedArgs): string[] | null {
if (parsed.commandPath[0] === 'help') {
return parsed.commandPath.slice(1)
}
if (parsed.flags.has('help')) {
return parsed.commandPath
}
return null
}
export function matches(actual: string[], expected: string[]): boolean {
return (
actual.length === expected.length && actual.every((value, index) => value === expected[index])
)
}
export function supportsBrowserPageFlag(commandPath: string[]): boolean {
const joined = commandPath.join(' ')
if (['open', 'status'].includes(commandPath[0])) {
return false
}
if (
[
'account',
'artifacts',
'automations',
'project',
'repo',
'reference',
'worktree',
'terminal',
'file',
'orchestration',
'computer',
'emulator',
'note',
'diagnostics',
'linear',
'skills',
'search',
'agent-context'
].includes(commandPath[0])
) {
return false
}
return ![
'tab list',
'tab create',
'tab current',
'tab profile list',
'tab profile create',
'tab profile delete'
].includes(joined)
}
// Why: validation and agent discovery must expose the same effective flag set.
export function effectiveAllowedFlags(spec: CommandSpec): string[] {
if (spec.argumentMode === 'passthrough') {
return []
}
return [
...new Set([
...GLOBAL_FLAGS,
...spec.allowedFlags,
...(supportsBrowserPageFlag(spec.path) ? ['page'] : [])
])
]
}
export function isCommandGroup(specs: CommandSpec[], commandPath: string[]): boolean {
if (commandPath.length === 0) {
return false
}
return specs.some(
(spec) =>
spec.hidden !== true &&
specPaths(spec).some(
(candidate) =>
candidate.length > commandPath.length &&
matches(candidate.slice(0, commandPath.length), commandPath)
)
)
}
export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs {
for (const spec of specs) {
const positionalArgs = spec.positionalArgs ?? []
// Why: aliased paths still need canonicalization when there are no positionals.
if (positionalArgs.length === 0 && !spec.aliases) {
continue
}
// Why: canonicalize aliases before validation and dispatch so both use one key.
for (const base of specPaths(spec)) {
// Why: `< 0` (not `<= 0`) so an exact base match with zero positionals
// still canonicalizes an aliased path; upper bound guards over-consumption.
const positionalCount = parsed.commandPath.length - base.length
if (
positionalCount < 0 ||
(!spec.variadicPositional && positionalCount > positionalArgs.length)
) {
continue
}
if (!matches(parsed.commandPath.slice(0, base.length), base)) {
continue
}
const flags = new Map(parsed.flags)
const values = parsed.commandPath.slice(base.length)
// Why: validation runs inside main's error-reporting path, so normalization
// records ambiguity instead of throwing before CLI errors can be formatted.
const providedPositionals = values.map(
(_, index) => positionalArgs[Math.min(index, positionalArgs.length - 1)]
)
const positionalFlagConflicts = providedPositionals.filter((name) => flags.has(name))
values.forEach((value, index) => {
const name = providedPositionals[index]
if (!parsed.flags.has(name)) {
setFlagValue(flags, name, value, new Set(spec.repeatableFlags ?? []))
}
})
return { commandPath: spec.path, flags, positionalFlagConflicts }
}
}
return parsed
}
export function findCommandSpec(
specs: CommandSpec[],
commandPath: string[]
): CommandSpec | undefined {
return specs.find((spec) => specPaths(spec).some((candidate) => matches(candidate, commandPath)))
}
export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs): void {
const spec = findCommandSpec(specs, parsed.commandPath)
if (!spec) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown command: ${parsed.commandPath.join(' ')}`,
unknownCommandData(specs, parsed.commandPath)
)
}
if (parsed.positionalFlagConflicts && parsed.positionalFlagConflicts.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`Pass ${parsed.positionalFlagConflicts
.map((flag) => `--${flag}`)
.join(', ')} either positionally or as a flag, not both.`
)
}
const pageAllowed = supportsBrowserPageFlag(spec.path)
for (const [flag, value] of parsed.flags) {
const isGlobalFlag = GLOBAL_FLAGS.includes(flag)
if (GLOBAL_VALUE_FLAGS.has(flag) && (typeof value !== 'string' || value.length === 0)) {
throw new RuntimeClientError('invalid_argument', `Flag --${flag} requires a value.`)
}
if (!isGlobalFlag && !spec.allowedFlags.includes(flag) && !(flag === 'page' && pageAllowed)) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`,
unknownFlagData(flag, effectiveAllowedFlags(spec))
)
}
}
}