Cli destructive suggest (#8352)
* fix(cli): don't recover benign typos into destructive commands CLI did-you-mean ranked purely by Levenshtein, so `orca worktree move` sole-suggested `orca worktree remove` (distance 2) — an alias of the destructive `worktree rm`. Suggestions also flow into --json error.data.nextSteps, the agent recovery channel, so a blind retry could delete a clean worktree. Make destructiveness a declared property of the command instead of a verb heuristic: add `destructive?: true` to CommandSpec and mark the irreversible commands (worktree rm, environment rm, automations remove, project setup-delete, tab profile delete, cookie delete, storage local/session clear). The suggestion ranker excludes destructive candidates unless the input token is itself a near-miss (distance <=1) of a destructive verb, so `worktree remov` still recovers `rm`/`remove` while `worktree move` no longer does. The guard tracks the registry, so it also covers destructive verbs outside the delete family (e.g. kill). Fixes #6303 Co-authored-by: Orca <help@stably.ai> * fix(cli): use Array.at(-1) to satisfy oxlint prefer-at Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
21c0e45b1c
commit
9de1fb8d16
|
|
@ -13,6 +13,9 @@ export type CommandSpec = {
|
|||
// or handler registrations.
|
||||
aliases?: string[][]
|
||||
argumentMode?: 'parsed' | 'passthrough'
|
||||
// Why: irreversibly destroys persistent state — typo recovery must not steer a
|
||||
// benign mistake into one of these via the agent nextSteps channel. #6303
|
||||
destructive?: boolean
|
||||
summary: string
|
||||
usage: string
|
||||
allowedFlags: string[]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { levenshtein, suggestCommands, unknownCommandData } from './command-sugg
|
|||
const specs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
aliases: [['worktree', 'remove']],
|
||||
aliases: [
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
destructive: true,
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: []
|
||||
|
|
@ -22,6 +26,15 @@ const specs: CommandSpec[] = [
|
|||
summary: 'Send input',
|
||||
usage: 'orca terminal send',
|
||||
allowedFlags: []
|
||||
},
|
||||
{
|
||||
// A destructive command outside the delete-family, to prove the guard keys
|
||||
// off the spec flag rather than a hardcoded verb list.
|
||||
path: ['emulator', 'kill'],
|
||||
destructive: true,
|
||||
summary: 'Kill the emulator',
|
||||
usage: 'orca emulator kill',
|
||||
allowedFlags: []
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -67,6 +80,37 @@ describe('suggestCommands', () => {
|
|||
const result = suggestCommands(specs, ['terminal', 'sen'])
|
||||
expect(result[0]).toBe('terminal send')
|
||||
})
|
||||
|
||||
it('never suggests a destructive command for a benign non-destructive typo', () => {
|
||||
// `worktree move` sits distance 2 from `worktree remove`; without the
|
||||
// guard it would sole-suggest an irreversible delete on blind retry. #6303
|
||||
const result = suggestCommands(specs, ['worktree', 'move'])
|
||||
expect(result).not.toContain('worktree remove')
|
||||
expect(result).not.toContain('worktree rm')
|
||||
expect(result).not.toContain('worktree delete')
|
||||
})
|
||||
|
||||
it('still suggests remove for a near-miss of a destructive verb', () => {
|
||||
const result = suggestCommands(specs, ['worktree', 'remov'])
|
||||
expect(result).toContain('worktree rm')
|
||||
expect(result).toContain('worktree remove')
|
||||
})
|
||||
|
||||
it('still suggests delete for a near-miss of the delete alias', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'delet'])).toContain('worktree delete')
|
||||
})
|
||||
|
||||
it('guards destructive commands outside the delete-family via the spec flag', () => {
|
||||
// `emulator ball` is a benign token, distance 2 from the flagged `emulator
|
||||
// kill` — close enough to otherwise rank, so the guard must exclude it.
|
||||
expect(suggestCommands(specs, ['emulator', 'ball'])).not.toContain('emulator kill')
|
||||
// A genuine near-miss of the destructive verb still recovers.
|
||||
expect(suggestCommands(specs, ['emulator', 'kil'])).toContain('emulator kill')
|
||||
})
|
||||
|
||||
it('still recovers non-destructive near-misses', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'lst'])).toContain('worktree list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknownCommandData', () => {
|
||||
|
|
@ -82,4 +126,10 @@ describe('unknownCommandData', () => {
|
|||
expect(data.suggestions).toEqual([])
|
||||
expect(data.nextSteps).toEqual([])
|
||||
})
|
||||
|
||||
it('does not route a benign typo into a destructive nextStep', () => {
|
||||
const data = unknownCommandData(specs, ['worktree', 'move'])
|
||||
expect(data.suggestions).not.toContain('worktree remove')
|
||||
expect(data.nextSteps.join(' ')).not.toContain('remove')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,42 @@ import { specPaths } from './args'
|
|||
const SUGGESTION_THRESHOLD = 3
|
||||
const MAX_SUGGESTIONS = 3
|
||||
|
||||
// Why: a close typo of a destructive verb (`remov`→`remove`) still signals that
|
||||
// intent, but `move` (distance 2 from `remove`) does not — keep this at 1 so
|
||||
// genuine recovery works while unrelated verbs stay locked out. #6303
|
||||
const DESTRUCTIVE_INTENT_THRESHOLD = 1
|
||||
|
||||
function finalToken(path: string[]): string {
|
||||
return path.at(-1) ?? ''
|
||||
}
|
||||
|
||||
// Why: destructiveness is declared on the spec (single source of truth); the
|
||||
// intent verbs are the final tokens of every destructive path/alias so the guard
|
||||
// tracks the registry instead of a hand-maintained list.
|
||||
function destructiveVerbs(specs: CommandSpec[]): Set<string> {
|
||||
const verbs = new Set<string>()
|
||||
for (const spec of specs) {
|
||||
if (spec.destructive) {
|
||||
for (const path of specPaths(spec)) {
|
||||
verbs.add(finalToken(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
return verbs
|
||||
}
|
||||
|
||||
// Why: deletion is irreversible and suggestions flow into agents' recovery
|
||||
// channel (--json nextSteps), so only unlock destructive candidates when the
|
||||
// input token is itself a near-miss of a destructive verb. #6303
|
||||
function intendsDestruction(inputToken: string, verbs: Set<string>): boolean {
|
||||
for (const verb of verbs) {
|
||||
if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type CommandErrorData = {
|
||||
suggestions: string[]
|
||||
nextSteps: string[]
|
||||
|
|
@ -47,9 +83,15 @@ function rankByDistance(scored: { label: string; distance: number }[]): string[]
|
|||
// Why: same-depth matching avoids suggesting parent groups or unrelated commands.
|
||||
export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] {
|
||||
const input = commandPath.join(' ')
|
||||
// Why: only surface destructive commands when the user actually reached for one;
|
||||
// otherwise a benign typo could recover into an irreversible action. #6303
|
||||
const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs))
|
||||
const seen = new Set<string>()
|
||||
const scored: { label: string; distance: number }[] = []
|
||||
for (const spec of specs) {
|
||||
if (spec.destructive && !allowDestructive) {
|
||||
continue
|
||||
}
|
||||
const candidates = specPaths(spec).map((path) =>
|
||||
commandPath.length === 1 ? path.slice(0, 1) : path
|
||||
)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['automations', 'remove'],
|
||||
destructive: true,
|
||||
summary: 'Remove an Orca automation and its run history',
|
||||
usage: 'orca automations remove <id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id'],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['cookie', 'delete'],
|
||||
destructive: true,
|
||||
summary: 'Delete a cookie by name',
|
||||
usage:
|
||||
'orca cookie delete --name <n> [--domain <d>] [--url <u>] [--worktree <selector>] [--json]',
|
||||
|
|
@ -240,6 +241,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['storage', 'local', 'clear'],
|
||||
destructive: true,
|
||||
summary: 'Clear all localStorage',
|
||||
usage: 'orca storage local clear [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
|
|
@ -258,6 +260,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['storage', 'session', 'clear'],
|
||||
destructive: true,
|
||||
summary: 'Clear all sessionStorage',
|
||||
usage: 'orca storage session clear [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['tab', 'profile', 'delete'],
|
||||
destructive: true,
|
||||
summary: 'Delete a browser session profile used by browser tabs',
|
||||
usage: 'orca tab profile delete --profile <id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'profile']
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
|||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
destructive: true,
|
||||
summary: 'Remove a worktree from Orca and git',
|
||||
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['environment', 'rm'],
|
||||
destructive: true,
|
||||
summary: 'Remove one saved Orca runtime environment',
|
||||
usage: 'orca environment rm --environment <selector> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
|||
},
|
||||
{
|
||||
path: ['project', 'setup-delete'],
|
||||
destructive: true,
|
||||
summary: 'Remove a project host setup',
|
||||
usage: 'orca project setup-delete --setup <setup-id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue