mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* 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.
407 lines
15 KiB
Go
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
|
|
}
|