Files
zero/internal/cli/completions.go
Vasanth T 91d921f4c5 feat(tui): offer mid-run model escalation behind --allow-escalation (#1019)
* feat(tui): offer mid-run model escalation behind --allow-escalation

Mid-run escalation existed but only exec offered it. The TUI already handled
every consequence of a switch, re-deriving the compaction threshold "after a
mid-run escalate_model switch" and resolving the summarizer against the active
profile, while nothing on that surface could cause one: escalate_model was
registered by exec alone. This closes that, opt-in, the same conservative way
exec answers the question.

Opt-in rather than default because escalation moves a run onto a different model,
which changes what it costs and which provider sees the conversation. That is the
operator's call, and the exec flag already answers it.

The switchers are built per turn from m.providerProfile rather than once at
launch. A TUI session can change models with /model, so a closure capturing the
startup profile would escalate from whatever the session began with instead of
what is in force now, and would carry that stale profile's base URL and
credential with it. exec has no such problem because its profile cannot change
mid-run, which is why the wiring lives in different places on the two surfaces.

The nil-contract logic is now shared rather than copied. providers.
EscalationSwitchers is the single implementation both surfaces call: the loop
swaps only on a non-nil provider, so a (nil, nil) return has to leave everything
untouched including the caller's usage attribution, and the session switcher is
installed only when the run STARTED optimized so escalation cannot change the
transport underneath a session. A second copy of that would only ever have been
exercised on one surface. exec's behaviour is unchanged: its six existing
escalation tests pass against the shared builder.

The tool and the switchers ride on one flag and a test asserts they cannot be
separated. Registering escalate_model without wiring a switcher ships a tool the
loop silently ignores, so the model reports an escalation that never happened;
that is the specific failure worth a guard rather than a comment.

Note this does not answer all of #554. Assigning models per phase, plan with one
and execute with another, is still not built. This makes the mechanism reachable
from the interactive surface, which is the part that was one flag away.

Refs #554

* fix(tui): attribute usage after an escalation to the model in force

exec reassigns currentModel when the escalation switcher fires; the TUI
captured usageModelID once per run and handed the switcher no callback,
so every usage event after a mid-run escalation was billed to the model
the run started on. The switcher now updates usageModelID, and the final
response carries the model per usage event so the batch fallback does
not swing the other way and bill the events before the switch to the
escalated model. Covered by a run through a scripted escalation that
checks both the live and the batch attribution.

* fix(cli): accept the root flags in any order

Each leading-flag splitter stops at the first token it does not own, so
running them once in a fixed sequence made the order load-bearing:
`zero --allow-escalation --theme auto` stranded the theme as an unknown
command and exited with an argument error instead of launching, and the
same happened after --skip-permissions-unsafe. The three splitters now
run until they make no progress, at the root and after the unsafe flag,
so every ordering reaches the TUI with every flag applied. A --theme
written before --skip-permissions-unsafe was dropped on that path; it is
kept now, with a later one winning as the last occurrence does.

* fix(cli): forward a root --allow-escalation into exec, reject it elsewhere

The root flag was stripped before dispatch, so `zero --allow-escalation
exec ...` and `zero --allow-escalation -p ...` ran without the opt-in the
help text promises, and `zero --allow-escalation version` accepted a flag
it could only discard. It now follows --add-dir: re-synthesised into the
exec argument list for the exec and -p shapes, so exec parses it as its
own flag, and rejected loudly for every other command. The ordering test
now generates every permutation of the three root flags, with
--skip-permissions-unsafe absent or at any position.

* fix(tui): record the model on an escalated run persisted usage events

The live usage record carried the model in force; the persisted session
payload did not. The usage report rebuilds cost from that payload and
falls back to the session-wide model when an event does not name one, so
an escalated interactive run was priced end to end at the model it started
on. Written only under escalation, matching what exec records under the
same flag, so an ordinary run persists the same compact payload as before.

* test(cli): isolate the new launch helpers from real user state

A temporary working directory isolates project files and nothing else.
runWithDeps fills the dependencies a test leaves out with production ones,
and the interactive launch path reads user config, opens stores, refreshes
the models.dev cache and migrates any inline plaintext API key into the
credential store before it reaches an injected runTUI callback. Running
these tests on a developer machine therefore rewrote that developer's
config.json and wrote a credential file beside it, and the results depended
on whatever providers, MCP servers and plugins the machine had.

Both new helpers now point every per-user base directory at a throwaway
root, pin the models.dev cache path, disable its background fetch, and
force the file credential backend so nothing reaches the host keyring. A
new test seeds a config with an inline key outside those fixture roots and
fails if either helper rewrites it or leaves a file beside it; without the
isolation it reports the rewritten config and the credentials.enc files.

* test(cli): check each launch helper isolation on its own

t.Setenv restores at the end of the test, not when the helper returns, so
one test exercising both helpers let the first isolation cover for the
second. Each helper now seeds its own emulated user config in a subtest,
and removing the isolation from either one fails that subtest.

* test(cli): pin the isolation itself, not only what this platform writes

The exec helper writes its models.dev cache from a background goroutine,
so a footprint check could not prove its isolation deterministically. Each
subtest now also asserts the helper moved the per-user paths off the
seeded root and disabled the fetch and the keyring backend, which fails
for either helper when the isolation call is removed.
2026-09-12 08:51:42 +08:00

407 lines
15 KiB
Go

package cli
import (
"fmt"
"io"
"sort"
"strings"
)
type completionNode struct {
names []string
flags []string
children []completionNode
}
type completionContext struct {
path string
candidates []string
transitions map[string]string
}
var completionRoot = completionNode{
flags: []string{"-h", "--help", "-v", "--version", "-p", "--prompt", "--add-dir", "--allow-escalation", "--theme", "--skip-permissions-unsafe"},
children: []completionNode{
{names: []string{"exec"}, flags: []string{
"-h", "--help", "-f", "--file", "--image", "--add-dir", "--mode", "-m", "--model",
"--use-spec", "--spec-model", "--spec-reasoning-effort", "--plan", "--permission-mode",
"--max-turns", "--exec-profile",
"--auto", "--enabled-tools", "--disabled-tools", "--list-tools", "--profile", "-r",
"--reasoning-effort", "-C", "--cwd", "-w", "--worktree", "--worktree-dir", "-i",
"--input-format", "-o", "--output-format", "--prompt", "--resume", "--fork",
"--calling-session-id", "--calling-tool-use-id", "--tag", "--depth", "--session-title",
"--init-session-id", "--skip-permissions-unsafe", "--allow-escalation", "--self-correct",
"--notify", "--no-notify", "--no-completion-gate",
}},
{
names: []string{"completions"},
flags: []string{"-h", "--help"},
children: leafNodes("bash", "zsh", "fish", "powershell", "elvish"),
},
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
{names: []string{"setup"}},
{names: []string{"config"}},
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
{names: []string{"providers"}, children: []completionNode{
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
{names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}},
{names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}},
{names: []string{"detect"}}, {names: []string{"models"}},
}},
{names: []string{"doctor"}},
{names: []string{"context"}},
{names: []string{"repo-map", "repomap"}},
{names: []string{"search", "find"}},
{names: []string{"sessions", "session"}, children: leafNodes("list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan")},
{names: []string{"spec"}, children: leafNodes("show", "approve", "reject")},
{names: []string{"init"}},
{names: []string{"specialists", "specialist"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"show"}}, {names: []string{"create"}},
{names: []string{"delete", "rm"}}, {names: []string{"edit"}}, {names: []string{"path"}},
}},
{names: []string{"plugins", "plugin"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"backends", "backend"}, children: leafNodes("doctor")},
{names: []string{"skills", "skill"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"info"}}, {names: []string{"remove", "rm"}},
}},
{names: []string{"tools", "tool"}, children: leafNodes("make", "list")},
{names: []string{"hooks"}, children: []completionNode{
{names: []string{"list"}}, {names: []string{"add"}}, {names: []string{"remove", "rm"}},
{names: []string{"enable"}}, {names: []string{"disable"}},
}},
{names: []string{"mcp"}, children: []completionNode{
{names: []string{"add"}}, {names: []string{"remove", "rm"}}, {names: []string{"enable"}},
{names: []string{"disable"}}, {names: []string{"check"}}, {names: []string{"list"}},
{names: []string{"permissions"}, children: leafNodes("list", "revoke", "clear")},
{names: []string{"tools"}, children: leafNodes("list")},
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
}},
{names: []string{"auth"}, children: append(leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh"),
completionNode{names: []string{"reset"}, flags: []string{"--confirm", "--json", "--help"}})},
{names: []string{"sandbox"}, children: []completionNode{
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
}},
{names: []string{"update"}},
{names: []string{"upgrade"}},
{names: []string{"worktrees", "worktree"}, children: leafNodes("prepare", "release")},
{names: []string{"verify"}},
{names: []string{"trust"}, children: leafNodes("list", "remove")},
{names: []string{"eval"}, children: leafNodes("validate", "run", "bench")},
{names: []string{"changes", "change"}, children: []completionNode{
{names: []string{"inspect", "status"}}, {names: []string{"commit"}}, {names: []string{"push"}},
{names: []string{"pr", "pull-request"}},
}},
{names: []string{"usage"}, children: leafNodes("report")},
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
{names: []string{"repo-info", "repoinfo"}},
{names: []string{"serve"}},
{names: []string{"acp"}},
{names: []string{"help"}},
{names: []string{"version"}},
},
}
func leafNodes(names ...string) []completionNode {
nodes := make([]completionNode, 0, len(names))
for _, name := range names {
nodes = append(nodes, completionNode{names: []string{name}})
}
return nodes
}
func runCompletions(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) == 1 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
if err := writeCompletionsHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(args) == 0 {
return writeExecUsageError(stderr, "shell required. Use `zero completions <bash|zsh|fish|powershell|elvish>`.")
}
if len(args) != 1 {
return writeExecUsageError(stderr, fmt.Sprintf("unexpected completions argument %q", args[1]))
}
contexts := completionContexts(completionRoot)
var err error
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "bash":
err = writeBashCompletions(stdout, contexts)
case "zsh":
err = writeZshCompletions(stdout, contexts)
case "fish":
err = writeFishCompletions(stdout, contexts)
case "powershell":
err = writePowerShellCompletions(stdout, contexts)
case "elvish":
err = writeElvishCompletions(stdout, contexts)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unsupported shell %q; expected bash, zsh, fish, powershell, or elvish", args[0]))
}
if err != nil {
return exitCrash
}
return exitSuccess
}
func writeCompletionsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Generate shell completion scripts.
Usage:
zero completions <shell>
Arguments:
<shell> Target shell: bash, zsh, fish, powershell, or elvish
Examples:
source <(zero completions bash)
source <(zero completions zsh)
zero completions fish > ~/.config/fish/completions/zero.fish
zero completions powershell >> $PROFILE
eval (zero completions elvish | slurp)
Flags:
-h, --help Show this help
`)
return err
}
func completionContexts(root completionNode) []completionContext {
contexts := []completionContext{}
var visit func(completionNode, []string)
visit = func(node completionNode, paths []string) {
candidates := append([]string{}, node.flags...)
transitions := map[string]string{}
for _, child := range node.children {
candidates = append(candidates, child.names...)
for _, path := range paths {
for _, name := range child.names {
childPath := strings.TrimSpace(path + " " + name)
transitions[path+"|"+name] = childPath
}
}
}
candidates = uniqueStrings(candidates)
for _, path := range paths {
contexts = append(contexts, completionContext{path: path, candidates: candidates, transitions: transitions})
}
for _, child := range node.children {
childPaths := make([]string, 0, len(paths)*len(child.names))
for _, path := range paths {
for _, name := range child.names {
childPaths = append(childPaths, strings.TrimSpace(path+" "+name))
}
}
visit(child, childPaths)
}
}
visit(root, []string{""})
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].path < contexts[j].path })
return contexts
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func allTransitions(contexts []completionContext) map[string]string {
transitions := map[string]string{}
for _, context := range contexts {
for key, value := range context.transitions {
transitions[key] = value
}
}
return transitions
}
func sortedTransitionKeys(transitions map[string]string) []string {
keys := make([]string, 0, len(transitions))
for key := range transitions {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func shellWords(values []string) string {
return strings.Join(values, " ")
}
func writeBashCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# bash completion for zero\n_zero() {\n local cur context token i candidates\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 1; i < COMP_CWORD; i++)); do", " token=\"${COMP_WORDS[i]}\"", " done"); err != nil {
return err
}
if err := writePOSIXCandidateCase(w, contexts, " "); err != nil {
return err
}
_, err := fmt.Fprintln(w, " COMPREPLY=( $(compgen -W \"$candidates\" -- \"$cur\") )\n}\ncomplete -F _zero zero")
return err
}
func writeZshCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "#compdef zero\n_zero() {\n local context token i\n local -a candidates\n context=\"\""); err != nil {
return err
}
if err := writePOSIXContextLoop(w, contexts, " for ((i = 2; i < CURRENT; i++)); do", " token=\"${words[i]}\"", " done"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " %q) candidates=(%s) ;;\n", context.path, quotedWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " esac\n compadd -- \"${candidates[@]}\"\n}\ncompdef _zero zero")
return err
}
func writePOSIXContextLoop(w io.Writer, contexts []completionContext, start string, token string, end string) error {
if _, err := fmt.Fprintln(w, start); err != nil {
return err
}
if _, err := fmt.Fprintln(w, token); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " case \"$context|$token\" in"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " %q) context=%q ;;\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " esac"); err != nil {
return err
}
_, err := fmt.Fprintln(w, end)
return err
}
func writePOSIXCandidateCase(w io.Writer, contexts []completionContext, indent string) error {
if _, err := fmt.Fprintln(w, indent+"case \"$context\" in"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, "%s %q) candidates=%q ;;\n", indent, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, indent+"esac")
return err
}
func quotedWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, fmt.Sprintf("%q", value))
}
return strings.Join(quoted, " ")
}
func writeFishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# fish completion for zero\nfunction __zero_completion_context\n set -l context ''\n set -l tokens (commandline -opc)"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, " for token in $tokens[2..-1]\n switch \"$context|$token\""); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " case %q\n set context %q\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " end\n end\n if test -z \"$context\"\n echo __root__\n else\n echo \"$context\"\n end\nend"); err != nil {
return err
}
for _, context := range contexts {
path := context.path
if path == "" {
path = "__root__"
}
if _, err := fmt.Fprintf(w, "complete -c zero -f -n 'test (__zero_completion_context) = %q' -a %q\n", path, shellWords(context.candidates)); err != nil {
return err
}
}
return nil
}
func writePowerShellCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# PowerShell completion for zero\nRegister-ArgumentCompleter -Native -CommandName zero -ScriptBlock {\n param($wordToComplete, $commandAst, $cursorPosition)\n $context = ''\n $elements = @($commandAst.CommandElements)\n $limit = $elements.Count\n if ($wordToComplete -ne '' -and $limit -gt 1) { $limit-- }\n for ($i = 1; $i -lt $limit; $i++) {\n $token = $elements[$i].Extent.Text\n switch (\"$context|$token\") {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for _, key := range sortedTransitionKeys(transitions) {
if _, err := fmt.Fprintf(w, " '%s' { $context = '%s'; break }\n", key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }\n $candidates = switch ($context) {"); err != nil {
return err
}
for _, context := range contexts {
if _, err := fmt.Fprintf(w, " '%s' { @(%s) }\n", context.path, powershellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n $candidates | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n }\n}")
return err
}
func powershellWords(values []string) string {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, "'"+strings.ReplaceAll(value, "'", "''")+"'")
}
return strings.Join(quoted, ", ")
}
func writeElvishCompletions(w io.Writer, contexts []completionContext) error {
if _, err := fmt.Fprintln(w, "# Elvish completion for zero\nset edit:completion:arg-completer[zero] = {|@args|\n var context = ''\n for token $args[1..-1] {"); err != nil {
return err
}
transitions := allTransitions(contexts)
for index, key := range sortedTransitionKeys(transitions) {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context'|'$token %q) {\n set context = %q\n", keyword, key, transitions[key]); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, " }\n }"); err != nil {
return err
}
for index, context := range contexts {
keyword := "if"
if index > 0 {
keyword = "} elif"
}
if _, err := fmt.Fprintf(w, " %s (eq $context %q) {\n put %s\n", keyword, context.path, shellWords(context.candidates)); err != nil {
return err
}
}
_, err := fmt.Fprintln(w, " }\n}")
return err
}