-p as a switch with the prompt positional or on stdin, --print, --output-format text|json|stream-json, -c/--continue, --verbose, --allowedTools/--disallowedTools, --permission-mode, --dangerously-skip-permissions, --append-system-prompt, --system-prompt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 6 changed files with 1381 additions and 5 deletions.
| @@ -0,0 +1,338 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + // The command line a script written for Claude Code's `claude` sends. | |
| 4 | + // | |
| 5 | + // `claude -p` is how a lot of CI jobs and shell scripts run an agent, and the | |
| 6 | + // point of this file is that pointing one of them at Codey instead should be a | |
| 7 | + // matter of changing the binary's name. So the flags those scripts pass are | |
| 8 | + // read here, in Claude Code's spelling and with Claude Code's meaning, and | |
| 9 | + // turned into the settings Codey already had: --dangerously-skip-permissions | |
| 10 | + // is -yolo, --continue is -resume latest, --allowedTools is a list of allow | |
| 11 | + // rules on the approval policy. | |
| 12 | + // | |
| 13 | + // Two of them do not fit Go's flag package as they are. `claude -p` is a | |
| 14 | + // switch and the prompt is a positional argument — `claude -p --output-format | |
| 15 | + // json "fix it"` — where Codey's -p takes the prompt as its value; and | |
| 16 | + // --allowedTools takes several values in a row. prepareArgs reads the line | |
| 17 | + // first and hands the flag package something it can parse, so that both | |
| 18 | + // `codey -p "fix it"` and the Claude Code spelling work. | |
| 19 | + | |
| 20 | + import ( | |
| 21 | + "errors" | |
| 22 | + "flag" | |
| 23 | + "fmt" | |
| 24 | + "io" | |
| 25 | + "os" | |
| 26 | + "strings" | |
| 27 | + | |
| 28 | + "golang.org/x/term" | |
| 29 | + | |
| 30 | + "github.com/johannSo/codey/internal/approval" | |
| 31 | + ) | |
| 32 | + | |
| 33 | + // Output formats `-p` can print in. The empty format is Codey's own: the | |
| 34 | + // streamed transcript and a usage line, for a person reading along. | |
| 35 | + const ( | |
| 36 | + formatText = "text" | |
| 37 | + formatJSON = "json" | |
| 38 | + formatStreamJSON = "stream-json" | |
| 39 | + ) | |
| 40 | + | |
| 41 | + // claudeFlags are the flags `claude` takes that Codey answers to as well. | |
| 42 | + type claudeFlags struct { | |
| 43 | + print bool | |
| 44 | + outputFormat string | |
| 45 | + inputFormat string | |
| 46 | + verbose bool | |
| 47 | + cont bool | |
| 48 | + allowed ruleList | |
| 49 | + disallowed ruleList | |
| 50 | + appendSystem string | |
| 51 | + systemPrompt string | |
| 52 | + permissionMode string | |
| 53 | + skipPermissions bool | |
| 54 | + partial bool | |
| 55 | + } | |
| 56 | + | |
| 57 | + // ruleList collects permission rules from every time the flag is given, each | |
| 58 | + // time a list of its own: --allowedTools "Read,Edit" --allowedTools "Bash(go test:*)". | |
| 59 | + type ruleList []string | |
| 60 | + | |
| 61 | + func (l *ruleList) String() string { return strings.Join(*l, ",") } | |
| 62 | + | |
| 63 | + func (l *ruleList) Set(v string) error { | |
| 64 | + *l = append(*l, approval.SplitRules(v)...) | |
| 65 | + return nil | |
| 66 | + } | |
| 67 | + | |
| 68 | + // ruleFlags are the flags prepareArgs lets take several values in a row. | |
| 69 | + var ruleFlags = map[string]bool{ | |
| 70 | + "allowedTools": true, "allowed-tools": true, | |
| 71 | + "disallowedTools": true, "disallowed-tools": true, | |
| 72 | + } | |
| 73 | + | |
| 74 | + func registerClaudeFlags(fs *flag.FlagSet) *claudeFlags { | |
| 75 | + c := &claudeFlags{} | |
| 76 | + fs.BoolVar(&c.print, "print", false, "the same as -p; the prompt may follow it, or come after the other flags, or on stdin") | |
| 77 | + fs.StringVar(&c.outputFormat, "output-format", "", "with -p: text (the answer only), json (one result object) or stream-json (one line per message), in the shapes claude -p prints") | |
| 78 | + fs.StringVar(&c.inputFormat, "input-format", formatText, "with -p: only text is supported") | |
| 79 | + fs.BoolVar(&c.verbose, "verbose", false, "the same as -v") | |
| 80 | + fs.BoolVar(&c.cont, "c", false, "continue the most recent session in this working root, the same as -resume latest") | |
| 81 | + fs.BoolVar(&c.cont, "continue", false, "the same as -c") | |
| 82 | + ruleHelp := "comma- or space-separated rules in Claude Code's syntax: Bash, Bash(git diff:*), Read(./src/**), mcp__server" | |
| 83 | + fs.Var(&c.allowed, "allowedTools", "tool calls to approve without asking; "+ruleHelp) | |
| 84 | + fs.Var(&c.allowed, "allowed-tools", "the same as -allowedTools") | |
| 85 | + fs.Var(&c.disallowed, "disallowedTools", "tools to take away, or calls to refuse whatever else says yes; "+ruleHelp) | |
| 86 | + fs.Var(&c.disallowed, "disallowed-tools", "the same as -disallowedTools") | |
| 87 | + fs.StringVar(&c.appendSystem, "append-system-prompt", "", "text added to the end of the system prompt") | |
| 88 | + fs.StringVar(&c.systemPrompt, "system-prompt", "", "replace Codey's system prompt with this text") | |
| 89 | + fs.StringVar(&c.permissionMode, "permission-mode", "", "default, acceptEdits (edits without asking), bypassPermissions (the same as -yolo) or dontAsk (refuse what no rule allows)") | |
| 90 | + fs.BoolVar(&c.skipPermissions, "dangerously-skip-permissions", false, "the same as -yolo") | |
| 91 | + fs.BoolVar(&c.partial, "include-partial-messages", false, "accepted for compatibility and ignored: stream-json prints whole messages only") | |
| 92 | + return c | |
| 93 | + } | |
| 94 | + | |
| 95 | + // prepareArgs rewrites the command line into one the flag package reads the | |
| 96 | + // way `claude` would have. | |
| 97 | + // | |
| 98 | + // Go's flag parsing stops at the first argument that is not a flag, and | |
| 99 | + // commander, which `claude` uses, does not: `claude "fix it" --model x` is a | |
| 100 | + // prompt and a model. So the flags are pulled out wherever they are, and the | |
| 101 | + // positional arguments go after a "--" at the end, where flag.Args finds them. | |
| 102 | + // Knowing which flags take a value is what makes that possible, and the flag | |
| 103 | + // set already knows. | |
| 104 | + // | |
| 105 | + // -p and --print take the next argument as the prompt when it is not a flag, | |
| 106 | + // which is both Codey's -p "prompt" and Claude Code's -p "prompt"; followed by | |
| 107 | + // a flag or by nothing, they are a switch, and the prompt is whatever | |
| 108 | + // positional arguments there are, or stdin. The rule flags take every | |
| 109 | + // following argument that reads as a rule, as commander's variadic options do | |
| 110 | + // — except that "reads as a rule" here means it parses as one, so a one-word | |
| 111 | + // prompt after --allowedTools Read is still a prompt. | |
| 112 | + // | |
| 113 | + // Anything it does not recognise is passed through untouched, for the flag | |
| 114 | + // package to report in its own words. | |
| 115 | + func prepareArgs(fs *flag.FlagSet, args []string, c *claudeFlags) []string { | |
| 116 | + var flags, positional []string | |
| 117 | + for i := 0; i < len(args); i++ { | |
| 118 | + a := args[i] | |
| 119 | + if a == "--" { | |
| 120 | + positional = append(positional, args[i+1:]...) | |
| 121 | + break | |
| 122 | + } | |
| 123 | + if !isFlag(a) { | |
| 124 | + positional = append(positional, a) | |
| 125 | + continue | |
| 126 | + } | |
| 127 | + name, _, hasValue := strings.Cut(strings.TrimLeft(a, "-"), "=") | |
| 128 | + | |
| 129 | + if (name == "p" || name == "print") && !hasValue { | |
| 130 | + if i+1 < len(args) && !isFlag(args[i+1]) { | |
| 131 | + flags = append(flags, "-p", args[i+1]) | |
| 132 | + i++ | |
| 133 | + continue | |
| 134 | + } | |
| 135 | + c.print = true | |
| 136 | + continue | |
| 137 | + } | |
| 138 | + | |
| 139 | + f := fs.Lookup(name) | |
| 140 | + if f == nil || hasValue || isBoolFlag(f) || i+1 >= len(args) { | |
| 141 | + flags = append(flags, a) | |
| 142 | + continue | |
| 143 | + } | |
| 144 | + flags = append(flags, a, args[i+1]) | |
| 145 | + i++ | |
| 146 | + if ruleFlags[name] { | |
| 147 | + for i+1 < len(args) && !isFlag(args[i+1]) && readsAsRules(args[i+1]) { | |
| 148 | + flags = append(flags, a, args[i+1]) | |
| 149 | + i++ | |
| 150 | + } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + return append(append(flags, "--"), positional...) | |
| 154 | + } | |
| 155 | + | |
| 156 | + // isFlag is the flag package's own test: a dash and something after it. | |
| 157 | + func isFlag(a string) bool { return len(a) > 1 && a[0] == '-' } | |
| 158 | + | |
| 159 | + func isBoolFlag(f *flag.Flag) bool { | |
| 160 | + b, ok := f.Value.(interface{ IsBoolFlag() bool }) | |
| 161 | + return ok && b.IsBoolFlag() | |
| 162 | + } | |
| 163 | + | |
| 164 | + // readsAsRules reports whether every item in an argument parses as a rule. | |
| 165 | + func readsAsRules(a string) bool { | |
| 166 | + items := approval.SplitRules(a) | |
| 167 | + if len(items) == 0 { | |
| 168 | + return false | |
| 169 | + } | |
| 170 | + for _, s := range items { | |
| 171 | + if _, err := approval.ParseRule(s); err != nil { | |
| 172 | + return false | |
| 173 | + } | |
| 174 | + } | |
| 175 | + return true | |
| 176 | + } | |
| 177 | + | |
| 178 | + // cliRun is what the Claude Code flags came to. | |
| 179 | + type cliRun struct { | |
| 180 | + // Prompt is the -p prompt, put together from the flag, the positional | |
| 181 | + // arguments and stdin; empty for an interactive session. | |
| 182 | + Prompt string | |
| 183 | + // FromStdin is set when the prompt was read from stdin, which leaves | |
| 184 | + // nothing there to read an approval from. | |
| 185 | + FromStdin bool | |
| 186 | + // Format is the -p output format, empty for Codey's own. | |
| 187 | + Format string | |
| 188 | + // Resume is -resume, or "latest" for --continue. | |
| 189 | + Resume string | |
| 190 | + // YOLO is --dangerously-skip-permissions or bypassPermissions. | |
| 191 | + YOLO bool | |
| 192 | + // NoPrompt refuses whatever no rule allows rather than asking. | |
| 193 | + NoPrompt bool | |
| 194 | + // Mode is the permission mode, in Claude Code's words, for the init line. | |
| 195 | + Mode string | |
| 196 | + // Rules go on the approval policy; Drop is the part of them that takes | |
| 197 | + // whole tools away, for the registry; and DropHostedSearch is the same | |
| 198 | + // for the one tool the registry never holds. | |
| 199 | + Rules approval.Ruleset | |
| 200 | + Drop func(name string) bool | |
| 201 | + DropHostedSearch bool | |
| 202 | + // System replaces the system prompt when set, and SystemAppend goes on | |
| 203 | + // the end of it. | |
| 204 | + System, SystemAppend string | |
| 205 | + // Warnings are for the user, once there is somewhere to say them. | |
| 206 | + Warnings []string | |
| 207 | + } | |
| 208 | + | |
| 209 | + // UIOut is where the line front end may write its prompts. In a Claude-shaped | |
| 210 | + // run stdout belongs to the output format, so a question goes to stderr. | |
| 211 | + func (r cliRun) UIOut() io.Writer { | |
| 212 | + if r.Format != "" { | |
| 213 | + return os.Stderr | |
| 214 | + } | |
| 215 | + return os.Stdout | |
| 216 | + } | |
| 217 | + | |
| 218 | + // resolve turns the parsed flags into a cliRun. prompt and resume are Codey's | |
| 219 | + // own -p and -resume, positional is what flag parsing left over, and root is | |
| 220 | + // what relative path rules are read against. | |
| 221 | + func (c *claudeFlags) resolve(prompt string, positional []string, resume, root string, stdin *os.File) (cliRun, error) { | |
| 222 | + r := cliRun{Resume: resume, System: c.systemPrompt, SystemAppend: c.appendSystem, Mode: "default"} | |
| 223 | + | |
| 224 | + printing := c.print || prompt != "" | |
| 225 | + switch c.outputFormat { | |
| 226 | + case "", formatText, formatJSON, formatStreamJSON: | |
| 227 | + default: | |
| 228 | + return r, fmt.Errorf("--output-format %q: want text, json or stream-json", c.outputFormat) | |
| 229 | + } | |
| 230 | + if c.outputFormat != "" && !printing { | |
| 231 | + return r, errors.New("--output-format applies only with -p") | |
| 232 | + } | |
| 233 | + r.Format = c.outputFormat | |
| 234 | + if c.inputFormat != formatText { | |
| 235 | + return r, fmt.Errorf("--input-format %q is not supported: the prompt is read as text", c.inputFormat) | |
| 236 | + } | |
| 237 | + if c.partial { | |
| 238 | + r.Warnings = append(r.Warnings, "--include-partial-messages is ignored: stream-json prints whole messages only") | |
| 239 | + } | |
| 240 | + | |
| 241 | + if c.cont { | |
| 242 | + if resume != "" && resume != "latest" { | |
| 243 | + return r, fmt.Errorf("--continue and -resume %s ask for different sessions; give one", resume) | |
| 244 | + } | |
| 245 | + r.Resume = "latest" | |
| 246 | + } | |
| 247 | + | |
| 248 | + if printing { | |
| 249 | + parts := positional | |
| 250 | + if prompt != "" { | |
| 251 | + parts = append([]string{prompt}, positional...) | |
| 252 | + } | |
| 253 | + r.Prompt = strings.TrimSpace(strings.Join(parts, " ")) | |
| 254 | + if r.Prompt == "" && !isTerminal(stdin) { | |
| 255 | + // What Claude Code does with `echo "fix it" | claude -p`. Stdin | |
| 256 | + // is read only when there is no prompt otherwise: a CI runner can | |
| 257 | + // leave it as a pipe nobody ever closes, and a run that waited on | |
| 258 | + // it would hang there for good. | |
| 259 | + b, err := io.ReadAll(stdin) | |
| 260 | + if err != nil { | |
| 261 | + return r, fmt.Errorf("reading the prompt from stdin: %w", err) | |
| 262 | + } | |
| 263 | + r.Prompt, r.FromStdin = strings.TrimSpace(string(b)), true | |
| 264 | + } | |
| 265 | + if r.Prompt == "" { | |
| 266 | + return r, errors.New("-p needs a prompt: give it after -p, as an argument, or on stdin") | |
| 267 | + } | |
| 268 | + } else if len(positional) > 0 { | |
| 269 | + r.Warnings = append(r.Warnings, fmt.Sprintf("ignoring %q: a prompt on the command line needs -p", strings.Join(positional, " "))) | |
| 270 | + } | |
| 271 | + | |
| 272 | + // With nobody at a terminal there is nobody to ask, and a Claude-shaped | |
| 273 | + // run never asks anyway: what no rule allows is refused, and the model | |
| 274 | + // is told so. Codey's own -p keeps reading answers from stdin, which is | |
| 275 | + // how `yes | codey -p ...` has always worked. | |
| 276 | + r.NoPrompt = r.FromStdin || (r.Format != "" && !isTerminal(stdin)) | |
| 277 | + | |
| 278 | + allow, errs := approval.ParseRules(c.allowed) | |
| 279 | + deny, denyErrs := approval.ParseRules(c.disallowed) | |
| 280 | + for _, err := range append(errs, denyErrs...) { | |
| 281 | + r.Warnings = append(r.Warnings, fmt.Sprintf("%v — ignored", err)) | |
| 282 | + } | |
| 283 | + | |
| 284 | + r.YOLO = c.skipPermissions | |
| 285 | + switch c.permissionMode { | |
| 286 | + case "", "default": | |
| 287 | + case "acceptEdits": | |
| 288 | + // The mode the ACP front end calls by the same name: edits go | |
| 289 | + // through, commands still ask. An Edit rule says exactly that. | |
| 290 | + edit, _ := approval.ParseRule("Edit") | |
| 291 | + allow = append(allow, edit) | |
| 292 | + r.Mode = c.permissionMode | |
| 293 | + case "bypassPermissions": | |
| 294 | + r.YOLO, r.Mode = true, c.permissionMode | |
| 295 | + case "dontAsk": | |
| 296 | + r.NoPrompt, r.Mode = true, c.permissionMode | |
| 297 | + case "plan": | |
| 298 | + return r, errors.New("--permission-mode plan: plan mode is not supported — Codey has no read-only planning mode. Ask for a plan in the prompt, and use --disallowedTools Edit,Write,Bash to hold it to that") | |
| 299 | + default: | |
| 300 | + return r, fmt.Errorf("--permission-mode %q: want default, acceptEdits, bypassPermissions or dontAsk", c.permissionMode) | |
| 301 | + } | |
| 302 | + if r.YOLO { | |
| 303 | + r.Mode = "bypassPermissions" | |
| 304 | + } | |
| 305 | + | |
| 306 | + for i := range allow { | |
| 307 | + allow[i].Root = root | |
| 308 | + } | |
| 309 | + for i := range deny { | |
| 310 | + deny[i].Root = root | |
| 311 | + } | |
| 312 | + r.Rules = approval.Ruleset{Allow: allow, Deny: deny} | |
| 313 | + | |
| 314 | + var whole []approval.Rule | |
| 315 | + for _, d := range deny { | |
| 316 | + if d.WholeTool() { | |
| 317 | + whole = append(whole, d) | |
| 318 | + } | |
| 319 | + } | |
| 320 | + r.Drop = func(name string) bool { | |
| 321 | + for _, d := range whole { | |
| 322 | + if d.Covers(name) { | |
| 323 | + return true | |
| 324 | + } | |
| 325 | + } | |
| 326 | + return false | |
| 327 | + } | |
| 328 | + r.DropHostedSearch = r.Drop("web_search") | |
| 329 | + return r, nil | |
| 330 | + } | |
| 331 | + | |
| 332 | + // isTerminal reports whether f is a terminal rather than a pipe or a file. It | |
| 333 | + // asks the terminal driver rather than checking for a character device, | |
| 334 | + // because /dev/null is one too — and `< /dev/null` is exactly how a CI job | |
| 335 | + // says there is nobody here. | |
| 336 | + func isTerminal(f *os.File) bool { | |
| 337 | + return term.IsTerminal(int(f.Fd())) | |
| 338 | + } | |
| @@ -0,0 +1,256 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "encoding/json" | |
| 5 | + "flag" | |
| 6 | + "io" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "slices" | |
| 10 | + "strings" | |
| 11 | + "testing" | |
| 12 | + | |
| 13 | + "github.com/johannSo/codey/internal/approval" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // testFlags is a flag set shaped like main's: the Codey flags the pre-pass has | |
| 17 | + // to know the arity of, and the Claude Code ones. | |
| 18 | + func testFlags() (*flag.FlagSet, *claudeFlags, *string, *string, *string, *bool) { | |
| 19 | + fs := flag.NewFlagSet("codey", flag.ContinueOnError) | |
| 20 | + fs.SetOutput(io.Discard) | |
| 21 | + prompt := fs.String("p", "", "") | |
| 22 | + model := fs.String("model", "", "") | |
| 23 | + resume := fs.String("resume", "", "") | |
| 24 | + v := fs.Bool("v", false, "") | |
| 25 | + fs.Int("max-turns", 0, "") | |
| 26 | + fs.String("C", ".", "") | |
| 27 | + fs.Bool("yolo", false, "") | |
| 28 | + return fs, registerClaudeFlags(fs), prompt, model, resume, v | |
| 29 | + } | |
| 30 | + | |
| 31 | + func TestPrepareArgsReadsBothSpellingsOfP(t *testing.T) { | |
| 32 | + cases := []struct { | |
| 33 | + name string | |
| 34 | + args []string | |
| 35 | + wantPrompt string // what -p ends up holding | |
| 36 | + wantPrint bool // -p given as a switch | |
| 37 | + wantArgs []string | |
| 38 | + wantModel string | |
| 39 | + }{ | |
| 40 | + {name: "codey style", args: []string{"-p", "fix it"}, wantPrompt: "fix it"}, | |
| 41 | + {name: "claude style, prompt last", args: []string{"-p", "--output-format", "json", "fix it"}, wantPrint: true, wantArgs: []string{"fix it"}}, | |
| 42 | + {name: "--print", args: []string{"--print", "--model", "m", "fix", "it"}, wantPrint: true, wantArgs: []string{"fix", "it"}, wantModel: "m"}, | |
| 43 | + {name: "prompt first, flags after", args: []string{"fix it", "-p", "--model", "m"}, wantPrint: true, wantArgs: []string{"fix it"}, wantModel: "m"}, | |
| 44 | + {name: "-p with its prompt, then flags", args: []string{"-p", "fix it", "--model=m"}, wantPrompt: "fix it", wantModel: "m"}, | |
| 45 | + {name: "-p alone", args: []string{"-p"}, wantPrint: true}, | |
| 46 | + {name: "-p= keeps a leading dash", args: []string{"-p=-x is broken"}, wantPrompt: "-x is broken"}, | |
| 47 | + {name: "double-dash value flags", args: []string{"--model", "m", "--max-turns", "3", "-p", "go"}, wantPrompt: "go", wantModel: "m"}, | |
| 48 | + {name: "bool flags take no value", args: []string{"-p", "-v", "go"}, wantPrint: true, wantArgs: []string{"go"}}, | |
| 49 | + {name: "-- ends the flags", args: []string{"-p", "--", "--model", "x"}, wantPrint: true, wantArgs: []string{"--model", "x"}}, | |
| 50 | + } | |
| 51 | + for _, c := range cases { | |
| 52 | + t.Run(c.name, func(t *testing.T) { | |
| 53 | + fs, cc, prompt, model, _, _ := testFlags() | |
| 54 | + if err := fs.Parse(prepareArgs(fs, c.args, cc)); err != nil { | |
| 55 | + t.Fatalf("parse: %v", err) | |
| 56 | + } | |
| 57 | + if *prompt != c.wantPrompt || cc.print != c.wantPrint || *model != c.wantModel { | |
| 58 | + t.Errorf("-p=%q print=%v model=%q, want %q %v %q", *prompt, cc.print, *model, c.wantPrompt, c.wantPrint, c.wantModel) | |
| 59 | + } | |
| 60 | + if got := fs.Args(); !slices.Equal(got, c.wantArgs) && !(len(got) == 0 && len(c.wantArgs) == 0) { | |
| 61 | + t.Errorf("positional = %q, want %q", got, c.wantArgs) | |
| 62 | + } | |
| 63 | + }) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + func TestPrepareArgsTakesSeveralRulesInARow(t *testing.T) { | |
| 68 | + fs, cc, _, _, _, _ := testFlags() | |
| 69 | + args := []string{"-p", "--allowedTools", "Read", "Bash(git log:*)", "Edit,Write", "summarise the log", "--disallowedTools", "WebFetch"} | |
| 70 | + if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil { | |
| 71 | + t.Fatal(err) | |
| 72 | + } | |
| 73 | + if want := []string{"Read", "Bash(git log:*)", "Edit", "Write"}; !slices.Equal(cc.allowed, want) { | |
| 74 | + t.Errorf("allowed = %q, want %q", cc.allowed, want) | |
| 75 | + } | |
| 76 | + if !slices.Equal(cc.disallowed, []string{"WebFetch"}) { | |
| 77 | + t.Errorf("disallowed = %q", cc.disallowed) | |
| 78 | + } | |
| 79 | + if got := fs.Args(); !slices.Equal(got, []string{"summarise the log"}) { | |
| 80 | + t.Errorf("the prompt should stop the rule list: positional = %q", got) | |
| 81 | + } | |
| 82 | + | |
| 83 | + // A one-word prompt is not a tool name, so it is not eaten as a rule. | |
| 84 | + fs, cc, _, _, _, _ = testFlags() | |
| 85 | + fs.Parse(prepareArgs(fs, []string{"-p", "--allowed-tools", "Read", "hello"}, cc)) | |
| 86 | + if !slices.Equal(cc.allowed, []string{"Read"}) || !slices.Equal(fs.Args(), []string{"hello"}) { | |
| 87 | + t.Errorf("allowed=%q positional=%q", cc.allowed, fs.Args()) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + func TestPrepareArgsLeavesTheUnknownToTheFlagPackage(t *testing.T) { | |
| 92 | + fs, cc, _, _, _, _ := testFlags() | |
| 93 | + if err := fs.Parse(prepareArgs(fs, []string{"--mcp-config", "x.json", "-p", "go"}, cc)); err == nil { | |
| 94 | + t.Error("an unknown flag should still be an error") | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + // The agent-run pre-parse in agentcmd.go still sees the line first, and an | |
| 99 | + // ordinary line still comes through it untouched for prepareArgs. | |
| 100 | + func TestScanArgsStillHandsOrdinaryLinesOn(t *testing.T) { | |
| 101 | + args := []string{"-p", "--permission-mode", "acceptEdits", "--output-format", "json", "go"} | |
| 102 | + inv := scanArgs(args) | |
| 103 | + if inv.Kind != invNormal || !slices.Equal(inv.Rest, args) { | |
| 104 | + t.Fatalf("scanArgs = %+v", inv) | |
| 105 | + } | |
| 106 | + fs, cc, _, _, _, _ := testFlags() | |
| 107 | + if err := fs.Parse(prepareArgs(fs, inv.Rest, cc)); err != nil { | |
| 108 | + t.Fatal(err) | |
| 109 | + } | |
| 110 | + if cc.permissionMode != "acceptEdits" || cc.outputFormat != "json" || !cc.print { | |
| 111 | + t.Errorf("flags = %+v", cc) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + // ttyless is a stdin that is a file, not a terminal: what a CI job has. | |
| 116 | + func ttyless(t *testing.T, content string) *os.File { | |
| 117 | + t.Helper() | |
| 118 | + p := filepath.Join(t.TempDir(), "stdin") | |
| 119 | + os.WriteFile(p, []byte(content), 0o644) | |
| 120 | + f, err := os.Open(p) | |
| 121 | + if err != nil { | |
| 122 | + t.Fatal(err) | |
| 123 | + } | |
| 124 | + t.Cleanup(func() { f.Close() }) | |
| 125 | + return f | |
| 126 | + } | |
| 127 | + | |
| 128 | + func parseResolve(t *testing.T, stdin *os.File, args ...string) (cliRun, error) { | |
| 129 | + t.Helper() | |
| 130 | + fs, cc, prompt, _, resume, _ := testFlags() | |
| 131 | + if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil { | |
| 132 | + t.Fatalf("parse %q: %v", args, err) | |
| 133 | + } | |
| 134 | + return cc.resolve(*prompt, fs.Args(), *resume, "/work", stdin) | |
| 135 | + } | |
| 136 | + | |
| 137 | + func TestResolveJoinsThePrompt(t *testing.T) { | |
| 138 | + r, err := parseResolve(t, ttyless(t, "ignored"), "-p", "fix", "the", "bug") | |
| 139 | + if err != nil || r.Prompt != "fix the bug" || r.FromStdin { | |
| 140 | + t.Errorf("prompt=%q fromStdin=%v err=%v", r.Prompt, r.FromStdin, err) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + func TestResolveReadsThePromptFromStdin(t *testing.T) { | |
| 145 | + r, err := parseResolve(t, ttyless(t, " explain main.go\n"), "-p", "--output-format", "json") | |
| 146 | + if err != nil { | |
| 147 | + t.Fatal(err) | |
| 148 | + } | |
| 149 | + if r.Prompt != "explain main.go" || !r.FromStdin || !r.NoPrompt || r.Format != formatJSON { | |
| 150 | + t.Errorf("got %+v", r) | |
| 151 | + } | |
| 152 | + if _, err := parseResolve(t, ttyless(t, ""), "-p"); err == nil || !strings.Contains(err.Error(), "needs a prompt") { | |
| 153 | + t.Errorf("no prompt anywhere should be an error, got %v", err) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + func TestResolveRefusesWhatItCannotDo(t *testing.T) { | |
| 158 | + stdin := ttyless(t, "") | |
| 159 | + for _, args := range [][]string{ | |
| 160 | + {"-p", "x", "--output-format", "yaml"}, | |
| 161 | + {"--output-format", "json"}, | |
| 162 | + {"-p", "x", "--input-format", "stream-json"}, | |
| 163 | + {"-p", "x", "--permission-mode", "plan"}, | |
| 164 | + {"-p", "x", "--permission-mode", "whatever"}, | |
| 165 | + {"-c", "-resume", "20260101T000000Z", "-p", "x"}, | |
| 166 | + } { | |
| 167 | + if _, err := parseResolve(t, stdin, args...); err == nil { | |
| 168 | + t.Errorf("%q should be refused", args) | |
| 169 | + } | |
| 170 | + } | |
| 171 | + _, err := parseResolve(t, stdin, "-p", "x", "--permission-mode", "plan") | |
| 172 | + if err == nil || !strings.Contains(err.Error(), "plan mode is not supported") { | |
| 173 | + t.Errorf("plan mode should say so plainly, got %v", err) | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + func TestResolvePermissionModes(t *testing.T) { | |
| 178 | + stdin := ttyless(t, "") | |
| 179 | + edit := json.RawMessage(`{"path":"a.go"}`) | |
| 180 | + | |
| 181 | + r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "acceptEdits") | |
| 182 | + if v, _ := r.Rules.Check("write_file", edit); v != approval.Allowed || r.Mode != "acceptEdits" || r.YOLO { | |
| 183 | + t.Errorf("acceptEdits should allow edits and only edits: %+v", r) | |
| 184 | + } | |
| 185 | + if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"ls"}`)); v == approval.Allowed { | |
| 186 | + t.Error("acceptEdits still asks before a command") | |
| 187 | + } | |
| 188 | + | |
| 189 | + for _, args := range [][]string{ | |
| 190 | + {"-p", "x", "--permission-mode", "bypassPermissions"}, | |
| 191 | + {"-p", "x", "--dangerously-skip-permissions"}, | |
| 192 | + } { | |
| 193 | + if r, _ := parseResolve(t, stdin, args...); !r.YOLO || r.Mode != "bypassPermissions" { | |
| 194 | + t.Errorf("%q should be -yolo: %+v", args, r) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + if r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "dontAsk"); !r.NoPrompt { | |
| 198 | + t.Error("dontAsk refuses rather than asking") | |
| 199 | + } | |
| 200 | + // Codey's own -p keeps asking on stdin, as `yes | codey -p` relies on. | |
| 201 | + if r, _ := parseResolve(t, stdin, "-p", "x"); r.NoPrompt { | |
| 202 | + t.Error("plain -p with a prompt argument should still read approvals from stdin") | |
| 203 | + } | |
| 204 | + // A Claude-shaped run with no terminal never asks. | |
| 205 | + if r, _ := parseResolve(t, stdin, "-p", "x", "--output-format", "text"); !r.NoPrompt { | |
| 206 | + t.Error("an output format with no terminal should not ask") | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + func TestResolveContinue(t *testing.T) { | |
| 211 | + stdin := ttyless(t, "") | |
| 212 | + for _, args := range [][]string{{"-c", "-p", "x"}, {"--continue", "-p", "x"}, {"-c", "-resume", "latest", "-p", "x"}} { | |
| 213 | + if r, err := parseResolve(t, stdin, args...); err != nil || r.Resume != "latest" { | |
| 214 | + t.Errorf("%q: resume=%q err=%v", args, r.Resume, err) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + if r, _ := parseResolve(t, stdin, "--resume", "abc", "-p", "x"); r.Resume != "abc" { | |
| 218 | + t.Errorf("--resume <id> is unchanged, got %q", r.Resume) | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 222 | + func TestResolveRules(t *testing.T) { | |
| 223 | + stdin := ttyless(t, "") | |
| 224 | + r, err := parseResolve(t, stdin, "-p", "x", | |
| 225 | + "--allowedTools", "Bash(go test:*),Read(./src/**)", | |
| 226 | + "--disallowedTools", "WebSearch Bash(git push:*) mcp__db NotebookEdit") | |
| 227 | + if err != nil { | |
| 228 | + t.Fatal(err) | |
| 229 | + } | |
| 230 | + if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "NotebookEdit") { | |
| 231 | + t.Errorf("an unknown tool is a warning, not an error: %q", r.Warnings) | |
| 232 | + } | |
| 233 | + for name, want := range map[string]bool{ | |
| 234 | + "web_search": true, "mcp__db__query": true, "bash": false, "read_file": false, | |
| 235 | + } { | |
| 236 | + if got := r.Drop(name); got != want { | |
| 237 | + t.Errorf("Drop(%q) = %v, want %v", name, got, want) | |
| 238 | + } | |
| 239 | + } | |
| 240 | + if !r.DropHostedSearch { | |
| 241 | + t.Error("WebSearch disallowed should take hosted search off the request too") | |
| 242 | + } | |
| 243 | + if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"go test ./... && git push"}`)); v != approval.Denied { | |
| 244 | + t.Errorf("a scoped deny rule is enforced at approval time, got %v", v) | |
| 245 | + } | |
| 246 | + if v, _ := r.Rules.Check("read_file", json.RawMessage(`{"path":"/work/src/a.go"}`)); v != approval.Allowed { | |
| 247 | + t.Errorf("relative path rules are read against the working root, got %v", v) | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + func TestResolveWarnsAboutAPromptWithoutP(t *testing.T) { | |
| 252 | + r, err := parseResolve(t, ttyless(t, ""), "fix it") | |
| 253 | + if err != nil || r.Prompt != "" || len(r.Warnings) != 1 { | |
| 254 | + t.Errorf("prompt=%q warnings=%q err=%v", r.Prompt, r.Warnings, err) | |
| 255 | + } | |
| 256 | + } | |
| @@ -61,6 +61,7 @@ const hostedSearchesPerTurn = 5 | ||
| 61 | 61 | |
| 62 | 62 | func main() { |
| 63 | 63 | if err := run(); err != nil { |
| 64 | + reportFailure(err) | |
| 64 | 65 | fmt.Fprintln(os.Stderr, "codey:", err) |
| 65 | 66 | os.Exit(1) |
| 66 | 67 | } |
| @@ -93,6 +94,8 @@ func run() error { | ||
| 93 | 94 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 94 | 95 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 95 | 96 | noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered") |
| 97 | + // The flags a script written for `claude -p` passes — see claudecli.go. | |
| 98 | + cc := registerClaudeFlags(flag.CommandLine) | |
| 96 | 99 | |
| 97 | 100 | // Read the line before the flag package does. Three invocations belong to |
| 98 | 101 | // a program driving Codey rather than to a person — see agentcmd.go — and |
| @@ -102,7 +105,7 @@ func run() error { | ||
| 102 | 105 | printVersion(os.Stdout) |
| 103 | 106 | return nil |
| 104 | 107 | } |
| 105 | - if err := flag.CommandLine.Parse(inv.Rest); err != nil { | |
| 108 | + if err := flag.CommandLine.Parse(prepareArgs(flag.CommandLine, inv.Rest, cc)); err != nil { | |
| 106 | 109 | return err |
| 107 | 110 | } |
| 108 | 111 | |
| @@ -111,6 +114,19 @@ func run() error { | ||
| 111 | 114 | return err |
| 112 | 115 | } |
| 113 | 116 | |
| 117 | + // What the Claude Code flags come to, folded into Codey's own from here | |
| 118 | + // on, so nothing below needs to know which spelling was used. | |
| 119 | + cli, err := cc.resolve(*prompt, flag.Args(), *resume, root, os.Stdin) | |
| 120 | + if err != nil { | |
| 121 | + return err | |
| 122 | + } | |
| 123 | + *prompt, *resume = cli.Prompt, cli.Resume | |
| 124 | + *verbose = *verbose || cc.verbose | |
| 125 | + *yolo = *yolo || cli.YOLO | |
| 126 | + if cli.Format != "" { | |
| 127 | + claudeOut = newPrinter(os.Stdout, os.Stderr, cli.Format, *verbose) | |
| 128 | + } | |
| 129 | + | |
| 114 | 130 | // A clone last used under the old name keeps its sessions and its event |
| 115 | 131 | // log. This runs before anything opens a file inside the state directory, |
| 116 | 132 | // and is reported rather than fatal: a stale directory that cannot be |
| @@ -222,6 +238,9 @@ func run() error { | ||
| 222 | 238 | fmt.Fprintln(os.Stderr, "codey: "+msg) |
| 223 | 239 | } |
| 224 | 240 | |
| 241 | + for _, w := range cli.Warnings { | |
| 242 | + notice("%s", w) | |
| 243 | + } | |
| 225 | 244 | if cfgErr != nil { |
| 226 | 245 | notice("%v — using defaults; /setup will write a fresh one", cfgErr) |
| 227 | 246 | } |
| @@ -333,6 +352,11 @@ func run() error { | ||
| 333 | 352 | } |
| 334 | 353 | } |
| 335 | 354 | } |
| 355 | + // Hosted search is never in the registry, so --disallowedTools WebSearch | |
| 356 | + // has to take it off the request instead. | |
| 357 | + if cli.DropHostedSearch { | |
| 358 | + serverTools = nil | |
| 359 | + } | |
| 336 | 360 | |
| 337 | 361 | // Whatever this machine can say about a file the agent has just written. |
| 338 | 362 | // A nil Runner is the off switch, and every call site takes one, so the |
| @@ -392,9 +416,13 @@ func run() error { | ||
| 392 | 416 | // /worktree rebuilds the tool set against a different directory: each tool |
| 393 | 417 | // closes over the root it is handed, so moving the work means building the |
| 394 | 418 | // set again, and this is the only place that knows how. |
| 419 | + // | |
| 420 | + // A tool --disallowedTools takes away is kept out here, before anything | |
| 421 | + // is registered, so it is missing from every registry built — subagents' | |
| 422 | + // and the tools main adds afterwards alike. | |
| 395 | 423 | toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry { |
| 396 | 424 | return func(state *agent.State, log *metrics.Logger) *tools.Registry { |
| 397 | - reg := tools.NewRegistry(log).Register( | |
| 425 | + reg := tools.NewRegistry(log).Exclude(cli.Drop).Register( | |
| 398 | 426 | tools.NewRead(root, state, skillDirs...), |
| 399 | 427 | tools.NewGlob(root, skillDirs...), |
| 400 | 428 | tools.NewGrep(root, skillDirs...), |
| @@ -433,14 +461,19 @@ func run() error { | ||
| 433 | 461 | in.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| 434 | 462 | |
| 435 | 463 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 436 | - approver := &ui.Approver{In: in, Out: os.Stdout} | |
| 464 | + approver := &ui.Approver{In: in, Out: cli.UIOut()} | |
| 437 | 465 | policy := approval.New(approver.Prompt) |
| 466 | + policy.Rules = cli.Rules | |
| 467 | + if cli.NoPrompt { | |
| 468 | + // Nobody to ask: a nil prompt refuses, and tells the model why. | |
| 469 | + policy.Prompt = nil | |
| 470 | + } | |
| 438 | 471 | |
| 439 | 472 | // Questions go to the line front end until a TUI takes them over in |
| 440 | 473 | // tui.Run. The tool belongs to the session's own agent and is registered |
| 441 | 474 | // here rather than in newTools, so a subagent still cannot stop and ask: |
| 442 | 475 | // its report goes to the parent, and the parent is not a person. |
| 443 | - asker := ask.NewAsker((&ui.Asker{In: in, Out: os.Stdout}).Prompt) | |
| 476 | + asker := ask.NewAsker((&ui.Asker{In: in, Out: cli.UIOut()}).Prompt) | |
| 444 | 477 | reg.Register(ask.Tool(asker.Ask)) |
| 445 | 478 | // The plan belongs to the session agent for the same reason the question |
| 446 | 479 | // does: a subagent keeping its own list would show the user a plan for |
| @@ -532,6 +565,11 @@ func run() error { | ||
| 532 | 565 | base.Models = models |
| 533 | 566 | } |
| 534 | 567 | |
| 568 | + // The system prompt flags are for the session's own agents, the tabs | |
| 569 | + // below included, and not for subagents: the supervisors were handed | |
| 570 | + // their options above, and a child's prompt is its preset's. | |
| 571 | + base.System, base.SystemAppend = cli.System, cli.SystemAppend | |
| 572 | + | |
| 535 | 573 | a := agent.New(client, reg, state, log, base) |
| 536 | 574 | a.Observe = render.Handle |
| 537 | 575 | a.Approve = policy |
| @@ -668,6 +706,7 @@ func run() error { | ||
| 668 | 706 | // to come up in the tab whose agent is asking. |
| 669 | 707 | pol := approval.New(nil) |
| 670 | 708 | pol.YOLO = acceptAll |
| 709 | + pol.Rules = cli.Rules | |
| 671 | 710 | |
| 672 | 711 | var tabSup *subagent.Supervisor |
| 673 | 712 | if !*noAgents { |
| @@ -805,6 +844,12 @@ func run() error { | ||
| 805 | 844 | ctx, stopSignals := interruptible(context.Background()) |
| 806 | 845 | defer stopSignals() |
| 807 | 846 | |
| 847 | + if *prompt != "" && claudeOut != nil { | |
| 848 | + claudeOut.session = session | |
| 849 | + a.Observe = claudeOut.observe | |
| 850 | + claudeOut.begin(workRoot, client.Model(), cli.Mode, toolNames(reg)) | |
| 851 | + return printOnce(ctx, a, claudeOut, sessionDir, session, root, client, *prompt) | |
| 852 | + } | |
| 808 | 853 | if *prompt != "" { |
| 809 | 854 | return once(ctx, a, render, sessionDir, session, root, client, *prompt) |
| 810 | 855 | } |
| @@ -0,0 +1,408 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + // What `-p --output-format ...` prints, in the shapes `claude -p` prints. | |
| 4 | + // | |
| 5 | + // The reader is a program: a CI step that runs `jq -r .result`, a wrapper that | |
| 6 | + // reads stream-json line by line to show progress, an SDK that was written | |
| 7 | + // against Claude Code. None of them can be asked to change, so the shapes are | |
| 8 | + // Claude Code's — the same type and subtype names, the same field names, the | |
| 9 | + // assistant message as the Messages API returns it — and where Codey has | |
| 10 | + // nothing to put in a field Claude Code fills, the field is left out rather | |
| 11 | + // than filled with something made up. | |
| 12 | + // | |
| 13 | + // Nothing else may reach stdout in these modes. Notices go to stderr, and so | |
| 14 | + // do approval prompts; see cliRun.UIOut. | |
| 15 | + | |
| 16 | + import ( | |
| 17 | + "bytes" | |
| 18 | + "context" | |
| 19 | + "crypto/rand" | |
| 20 | + "encoding/json" | |
| 21 | + "errors" | |
| 22 | + "fmt" | |
| 23 | + "io" | |
| 24 | + "os" | |
| 25 | + "sort" | |
| 26 | + "strings" | |
| 27 | + "sync" | |
| 28 | + "time" | |
| 29 | + | |
| 30 | + "github.com/johannSo/codey/internal/agent" | |
| 31 | + "github.com/johannSo/codey/internal/llm" | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // Result subtypes, as Claude Code names them. | |
| 35 | + const ( | |
| 36 | + resultSuccess = "success" | |
| 37 | + resultMaxTurns = "error_max_turns" | |
| 38 | + resultExecution = "error_during_execution" | |
| 39 | + ) | |
| 40 | + | |
| 41 | + // printer writes one run in a Claude Code output format. It is the agent's | |
| 42 | + // observer for the length of the run, and the event log of it as far as the | |
| 43 | + // reader on stdout is concerned. | |
| 44 | + type printer struct { | |
| 45 | + mu sync.Mutex | |
| 46 | + w io.Writer | |
| 47 | + notes io.Writer | |
| 48 | + format string | |
| 49 | + // array is json with --verbose, which Claude Code prints as every line | |
| 50 | + // stream-json would have printed, collected into one JSON array. | |
| 51 | + array bool | |
| 52 | + records []any | |
| 53 | + | |
| 54 | + session string | |
| 55 | + model string | |
| 56 | + start time.Time | |
| 57 | + // api is the time spent waiting on the model, measured from the start of | |
| 58 | + // each turn to the usage that ends it. Compaction is a model call too and | |
| 59 | + // falls inside that window, which is where it belongs. | |
| 60 | + api time.Duration | |
| 61 | + turnStart time.Time | |
| 62 | + done bool | |
| 63 | + | |
| 64 | + // now and newID are the clock and the uuid source, replaceable so a test | |
| 65 | + // can compare output byte for byte. | |
| 66 | + now func() time.Time | |
| 67 | + newID func() string | |
| 68 | + } | |
| 69 | + | |
| 70 | + func newPrinter(w, notes io.Writer, format string, verbose bool) *printer { | |
| 71 | + return &printer{ | |
| 72 | + w: w, notes: notes, format: format, | |
| 73 | + array: format == formatJSON && verbose, | |
| 74 | + start: time.Now(), now: time.Now, newID: uuid4, | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + // claudeOut is the printer of a Claude-shaped -p run, once there is one. It is | |
| 79 | + // a package variable for main, which reports a run that failed before it got | |
| 80 | + // as far as the model — no credentials, a config error — and whose reader is | |
| 81 | + // still waiting for a result object. | |
| 82 | + var claudeOut *printer | |
| 83 | + | |
| 84 | + // reportFailure gives the reader on stdout its result object when the run | |
| 85 | + // ended before printing one. | |
| 86 | + func reportFailure(err error) { | |
| 87 | + if claudeOut != nil { | |
| 88 | + claudeOut.finish(outcome{Err: err}) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + // begin prints the init line, which only stream-json has. | |
| 93 | + func (p *printer) begin(cwd, model, mode string, tools []string) { | |
| 94 | + p.mu.Lock() | |
| 95 | + defer p.mu.Unlock() | |
| 96 | + p.model = model | |
| 97 | + p.start = p.now() | |
| 98 | + if p.format != formatStreamJSON && !p.array { | |
| 99 | + return | |
| 100 | + } | |
| 101 | + p.emit(initLine{ | |
| 102 | + Type: "system", Subtype: "init", CWD: cwd, SessionID: p.session, | |
| 103 | + Tools: tools, MCPServers: mcpServers(tools), Model: model, | |
| 104 | + PermissionMode: mode, UUID: p.newID(), | |
| 105 | + }) | |
| 106 | + } | |
| 107 | + | |
| 108 | + // observe is the agent's Observe callback. | |
| 109 | + func (p *printer) observe(ev agent.Event) { | |
| 110 | + p.mu.Lock() | |
| 111 | + defer p.mu.Unlock() | |
| 112 | + switch ev.Kind { | |
| 113 | + case agent.EventTurnStart: | |
| 114 | + p.turnStart = p.now() | |
| 115 | + case agent.EventUsage: | |
| 116 | + if !p.turnStart.IsZero() { | |
| 117 | + p.api += p.now().Sub(p.turnStart) | |
| 118 | + p.turnStart = time.Time{} | |
| 119 | + } | |
| 120 | + case agent.EventNotice: | |
| 121 | + fmt.Fprintln(p.notes, "codey: "+strings.TrimSpace(ev.Text)) | |
| 122 | + } | |
| 123 | + if p.format != formatStreamJSON && !p.array { | |
| 124 | + return | |
| 125 | + } | |
| 126 | + switch ev.Kind { | |
| 127 | + case agent.EventMessage: | |
| 128 | + if r := ev.Response; r != nil { | |
| 129 | + model := r.Model | |
| 130 | + if model == "" { | |
| 131 | + model = p.model | |
| 132 | + } | |
| 133 | + p.emit(messageLine{ | |
| 134 | + Type: "assistant", | |
| 135 | + Message: apiMessage{ | |
| 136 | + ID: r.ID, Type: "message", Role: llm.RoleAssistant, Model: model, | |
| 137 | + Content: nonNil(r.Content), StopReason: r.StopReason, Usage: r.Usage, | |
| 138 | + }, | |
| 139 | + SessionID: p.session, UUID: p.newID(), | |
| 140 | + }) | |
| 141 | + } | |
| 142 | + if m := ev.Message; m != nil { | |
| 143 | + p.emit(messageLine{ | |
| 144 | + Type: "user", | |
| 145 | + Message: userMessage{Role: m.Role, Content: nonNil(m.Content)}, | |
| 146 | + SessionID: p.session, UUID: p.newID(), | |
| 147 | + }) | |
| 148 | + } | |
| 149 | + case agent.EventCompact: | |
| 150 | + p.emit(systemLine{Type: "system", Subtype: "compact_boundary", SessionID: p.session, UUID: p.newID()}) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 154 | + // outcome is how a run ended, for the result object. | |
| 155 | + type outcome struct { | |
| 156 | + Text string | |
| 157 | + Err error | |
| 158 | + Turns int | |
| 159 | + Usage llm.Usage | |
| 160 | + } | |
| 161 | + | |
| 162 | + // finish prints the result, once. A second call — main reporting the error | |
| 163 | + // the result already carries — prints nothing. | |
| 164 | + func (p *printer) finish(o outcome) { | |
| 165 | + p.mu.Lock() | |
| 166 | + defer p.mu.Unlock() | |
| 167 | + if p.done { | |
| 168 | + return | |
| 169 | + } | |
| 170 | + p.done = true | |
| 171 | + | |
| 172 | + if p.format == formatText { | |
| 173 | + // The answer and nothing else, which is what a script that captures | |
| 174 | + // `claude -p` wants. A failure has no answer; its message goes to | |
| 175 | + // stderr with the exit status. | |
| 176 | + if o.Err == nil { | |
| 177 | + fmt.Fprintln(p.w, o.Text) | |
| 178 | + } | |
| 179 | + return | |
| 180 | + } | |
| 181 | + | |
| 182 | + res := resultLine{ | |
| 183 | + Type: "result", Subtype: resultSuccess, | |
| 184 | + DurationMS: p.now().Sub(p.start).Milliseconds(), DurationAPIMS: p.api.Milliseconds(), | |
| 185 | + NumTurns: o.Turns, Result: o.Text, SessionID: p.session, Usage: o.Usage, UUID: p.newID(), | |
| 186 | + } | |
| 187 | + if cost, ok := o.Usage.Cost(p.model); ok { | |
| 188 | + res.TotalCostUSD = cost | |
| 189 | + } | |
| 190 | + if o.Err != nil { | |
| 191 | + res.IsError = true | |
| 192 | + res.Subtype = resultExecution | |
| 193 | + if errors.Is(o.Err, agent.ErrMaxTurns) { | |
| 194 | + res.Subtype = resultMaxTurns | |
| 195 | + } | |
| 196 | + res.Errors = []string{o.Err.Error()} | |
| 197 | + // A run that stopped part-way still hands back what the model last | |
| 198 | + // said, as the line front end does; a run with nothing to hand back | |
| 199 | + // says why, because `.result` is the field everybody prints. | |
| 200 | + if res.Result == "" { | |
| 201 | + res.Result = o.Err.Error() | |
| 202 | + } | |
| 203 | + } | |
| 204 | + p.emit(res) | |
| 205 | + | |
| 206 | + if p.array { | |
| 207 | + p.write(p.records) | |
| 208 | + } | |
| 209 | + } | |
| 210 | + | |
| 211 | + // emit prints one record, or holds it for the array. | |
| 212 | + func (p *printer) emit(v any) { | |
| 213 | + if p.array { | |
| 214 | + p.records = append(p.records, v) | |
| 215 | + return | |
| 216 | + } | |
| 217 | + p.write(v) | |
| 218 | + } | |
| 219 | + | |
| 220 | + // write prints one JSON value on a line of its own. HTML is not escaped, | |
| 221 | + // because the reader is not a browser and Claude Code does not escape it: | |
| 222 | + // a diff full of < would match nothing a script greps for. | |
| 223 | + func (p *printer) write(v any) { | |
| 224 | + var buf bytes.Buffer | |
| 225 | + enc := json.NewEncoder(&buf) | |
| 226 | + enc.SetEscapeHTML(false) | |
| 227 | + if err := enc.Encode(v); err != nil { | |
| 228 | + fmt.Fprintln(p.notes, "codey: encoding output:", err) | |
| 229 | + return | |
| 230 | + } | |
| 231 | + p.w.Write(unescapeHTML(buf.Bytes())) | |
| 232 | + } | |
| 233 | + | |
| 234 | + // unescapeHTML undoes the escapes json.Marshal writes for <, > and & — a | |
| 235 | + // backslash, a u, and the code point in four hex digits (003c, 003e, 0026). | |
| 236 | + // The encoder's own setting does not reach them: a content block marshals | |
| 237 | + // itself, with json.Marshal, before the encoder sees it. The scan steps over | |
| 238 | + // every escape as a pair, so an escaped backslash followed by the letters | |
| 239 | + // u003c is left alone. | |
| 240 | + func unescapeHTML(b []byte) []byte { | |
| 241 | + if !bytes.Contains(b, []byte(`\u00`)) { | |
| 242 | + return b | |
| 243 | + } | |
| 244 | + out := make([]byte, 0, len(b)) | |
| 245 | + for i := 0; i < len(b); i++ { | |
| 246 | + if b[i] != '\\' || i+1 == len(b) { | |
| 247 | + out = append(out, b[i]) | |
| 248 | + continue | |
| 249 | + } | |
| 250 | + if b[i+1] == 'u' && i+6 <= len(b) { | |
| 251 | + switch string(b[i+2 : i+6]) { | |
| 252 | + case "003c": | |
| 253 | + out, i = append(out, '<'), i+5 | |
| 254 | + continue | |
| 255 | + case "003e": | |
| 256 | + out, i = append(out, '>'), i+5 | |
| 257 | + continue | |
| 258 | + case "0026": | |
| 259 | + out, i = append(out, '&'), i+5 | |
| 260 | + continue | |
| 261 | + } | |
| 262 | + } | |
| 263 | + out = append(out, b[i], b[i+1]) | |
| 264 | + i++ | |
| 265 | + } | |
| 266 | + return out | |
| 267 | + } | |
| 268 | + | |
| 269 | + type initLine struct { | |
| 270 | + Type string `json:"type"` | |
| 271 | + Subtype string `json:"subtype"` | |
| 272 | + CWD string `json:"cwd"` | |
| 273 | + SessionID string `json:"session_id"` | |
| 274 | + Tools []string `json:"tools"` | |
| 275 | + MCPServers []mcpServer `json:"mcp_servers"` | |
| 276 | + Model string `json:"model"` | |
| 277 | + PermissionMode string `json:"permissionMode"` | |
| 278 | + UUID string `json:"uuid"` | |
| 279 | + } | |
| 280 | + | |
| 281 | + type mcpServer struct { | |
| 282 | + Name string `json:"name"` | |
| 283 | + Status string `json:"status"` | |
| 284 | + } | |
| 285 | + | |
| 286 | + type systemLine struct { | |
| 287 | + Type string `json:"type"` | |
| 288 | + Subtype string `json:"subtype"` | |
| 289 | + SessionID string `json:"session_id"` | |
| 290 | + UUID string `json:"uuid"` | |
| 291 | + } | |
| 292 | + | |
| 293 | + // messageLine is an assistant or a user line. parent_tool_use_id is always | |
| 294 | + // null: Codey's subagents report through a tool result, not by speaking in | |
| 295 | + // the parent's stream, so nothing printed here belongs to a tool call. | |
| 296 | + type messageLine struct { | |
| 297 | + Type string `json:"type"` | |
| 298 | + Message any `json:"message"` | |
| 299 | + ParentToolUseID *string `json:"parent_tool_use_id"` | |
| 300 | + SessionID string `json:"session_id"` | |
| 301 | + UUID string `json:"uuid"` | |
| 302 | + } | |
| 303 | + | |
| 304 | + // apiMessage is an assistant message as the Messages API returns it. | |
| 305 | + type apiMessage struct { | |
| 306 | + ID string `json:"id"` | |
| 307 | + Type string `json:"type"` | |
| 308 | + Role string `json:"role"` | |
| 309 | + Model string `json:"model"` | |
| 310 | + Content []llm.ContentBlock `json:"content"` | |
| 311 | + StopReason string `json:"stop_reason"` | |
| 312 | + StopSequence *string `json:"stop_sequence"` | |
| 313 | + Usage llm.Usage `json:"usage"` | |
| 314 | + } | |
| 315 | + | |
| 316 | + type userMessage struct { | |
| 317 | + Role string `json:"role"` | |
| 318 | + Content []llm.ContentBlock `json:"content"` | |
| 319 | + } | |
| 320 | + | |
| 321 | + type resultLine struct { | |
| 322 | + Type string `json:"type"` | |
| 323 | + Subtype string `json:"subtype"` | |
| 324 | + IsError bool `json:"is_error"` | |
| 325 | + DurationMS int64 `json:"duration_ms"` | |
| 326 | + DurationAPIMS int64 `json:"duration_api_ms"` | |
| 327 | + NumTurns int `json:"num_turns"` | |
| 328 | + Result string `json:"result"` | |
| 329 | + SessionID string `json:"session_id"` | |
| 330 | + TotalCostUSD float64 `json:"total_cost_usd"` | |
| 331 | + Usage llm.Usage `json:"usage"` | |
| 332 | + Errors []string `json:"errors,omitempty"` | |
| 333 | + UUID string `json:"uuid"` | |
| 334 | + } | |
| 335 | + | |
| 336 | + // mcpServers names the servers whose tools are on offer. A server that failed | |
| 337 | + // to start has none, and is left out rather than reported in a state this | |
| 338 | + // list cannot tell apart from not configured. | |
| 339 | + func mcpServers(tools []string) []mcpServer { | |
| 340 | + seen := map[string]bool{} | |
| 341 | + for _, t := range tools { | |
| 342 | + rest, ok := strings.CutPrefix(t, "mcp__") | |
| 343 | + if !ok { | |
| 344 | + continue | |
| 345 | + } | |
| 346 | + if name, _, ok := strings.Cut(rest, "__"); ok { | |
| 347 | + seen[name] = true | |
| 348 | + } | |
| 349 | + } | |
| 350 | + out := []mcpServer{} | |
| 351 | + for name := range seen { | |
| 352 | + out = append(out, mcpServer{Name: name, Status: "connected"}) | |
| 353 | + } | |
| 354 | + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 355 | + return out | |
| 356 | + } | |
| 357 | + | |
| 358 | + // nonNil keeps an empty content list printing as [] rather than null. | |
| 359 | + func nonNil(blocks []llm.ContentBlock) []llm.ContentBlock { | |
| 360 | + if blocks == nil { | |
| 361 | + return []llm.ContentBlock{} | |
| 362 | + } | |
| 363 | + return blocks | |
| 364 | + } | |
| 365 | + | |
| 366 | + // uuid4 is a random version-4 UUID, which is what Claude Code stamps on every | |
| 367 | + // line. Nothing reads them back; they are there so a reader that keys lines by | |
| 368 | + // uuid has something to key on. | |
| 369 | + func uuid4() string { | |
| 370 | + var b [16]byte | |
| 371 | + rand.Read(b[:]) | |
| 372 | + b[6] = b[6]&0x0f | 0x40 | |
| 373 | + b[8] = b[8]&0x3f | 0x80 | |
| 374 | + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) | |
| 375 | + } | |
| 376 | + | |
| 377 | + // printOnce is once for a Claude-shaped run: the prompt, then the result in | |
| 378 | + // the chosen format. The usage and turns are this run's alone, so a resumed | |
| 379 | + // session reports what this invocation cost rather than everything before it. | |
| 380 | + func printOnce(ctx context.Context, a *agent.Agent, p *printer, sessionDir, id, root string, client *llm.Client, prompt string) error { | |
| 381 | + before, turnsBefore := a.State().Usage() | |
| 382 | + | |
| 383 | + turnCtx, endTurn := beginTurn(ctx) | |
| 384 | + text, runErr := a.Run(turnCtx, prompt) | |
| 385 | + endTurn() | |
| 386 | + | |
| 387 | + after, turnsAfter := a.State().Usage() | |
| 388 | + if err := a.Save(sessionDir, id, root, "", client.Model()); err != nil { | |
| 389 | + fmt.Fprintln(os.Stderr, "codey: saving session:", err) | |
| 390 | + } | |
| 391 | + if errors.Is(runErr, context.Canceled) { | |
| 392 | + // Unlike the line front end, an interrupted -p run is a failure: the | |
| 393 | + // script that started it did not get its answer. The session is saved | |
| 394 | + // all the same, so -c picks it up. | |
| 395 | + runErr = errors.New("interrupted — the session is saved; continue it with -c") | |
| 396 | + } | |
| 397 | + p.finish(outcome{Text: text, Err: runErr, Turns: turnsAfter - turnsBefore, Usage: usageSince(after, before)}) | |
| 398 | + return runErr | |
| 399 | + } | |
| 400 | + | |
| 401 | + func usageSince(after, before llm.Usage) llm.Usage { | |
| 402 | + return llm.Usage{ | |
| 403 | + InputTokens: after.InputTokens - before.InputTokens, | |
| 404 | + OutputTokens: after.OutputTokens - before.OutputTokens, | |
| 405 | + CacheReadInputTokens: after.CacheReadInputTokens - before.CacheReadInputTokens, | |
| 406 | + CacheCreationInputTokens: after.CacheCreationInputTokens - before.CacheCreationInputTokens, | |
| 407 | + } | |
| 408 | + } | |
| @@ -0,0 +1,329 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "io" | |
| 10 | + "net/http" | |
| 11 | + "net/http/httptest" | |
| 12 | + "os" | |
| 13 | + "path/filepath" | |
| 14 | + "strings" | |
| 15 | + "testing" | |
| 16 | + "time" | |
| 17 | + | |
| 18 | + "github.com/johannSo/codey/internal/agent" | |
| 19 | + "github.com/johannSo/codey/internal/approval" | |
| 20 | + "github.com/johannSo/codey/internal/llm" | |
| 21 | + "github.com/johannSo/codey/internal/tools" | |
| 22 | + ) | |
| 23 | + | |
| 24 | + // fixedPrinter is a printer on a clock the test moves and with uuids that | |
| 25 | + // count, so its output can be compared byte for byte. | |
| 26 | + func fixedPrinter(format string, verbose bool) (*printer, *bytes.Buffer, *bytes.Buffer, *time.Time) { | |
| 27 | + var out, notes bytes.Buffer | |
| 28 | + p := newPrinter(&out, ¬es, format, verbose) | |
| 29 | + clock := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) | |
| 30 | + p.now = func() time.Time { return clock } | |
| 31 | + n := 0 | |
| 32 | + p.newID = func() string { n++; return fmt.Sprintf("uuid-%d", n) } | |
| 33 | + p.session = "20261002T120000Z" | |
| 34 | + return p, &out, ¬es, &clock | |
| 35 | + } | |
| 36 | + | |
| 37 | + // script plays one tool round trip into a printer, the way the loop would. | |
| 38 | + func script(p *printer, clock *time.Time) { | |
| 39 | + tick := func(d time.Duration) { *clock = clock.Add(d) } | |
| 40 | + tick(5 * time.Millisecond) | |
| 41 | + p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 1}) | |
| 42 | + tick(700 * time.Millisecond) | |
| 43 | + p.observe(agent.Event{Kind: agent.EventUsage, Turn: 1}) | |
| 44 | + p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Response: &llm.Response{ | |
| 45 | + ID: "msg_1", Model: "claude-opus-4-1", StopReason: "tool_use", | |
| 46 | + Content: []llm.ContentBlock{ | |
| 47 | + llm.TextBlock("Looking <here>."), | |
| 48 | + {Type: llm.BlockToolUse, ID: "tu_1", Name: "bash", Input: json.RawMessage(`{"command":"ls"}`)}, | |
| 49 | + }, | |
| 50 | + Usage: llm.Usage{InputTokens: 10, OutputTokens: 5}, | |
| 51 | + }}) | |
| 52 | + p.observe(agent.Event{Kind: agent.EventText, Turn: 1, Text: "ignored: the message line has it"}) | |
| 53 | + p.observe(agent.Event{Kind: agent.EventNotice, Text: "the window is full"}) | |
| 54 | + results := llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.ToolResultBlock("tu_1", "a.go\nb.go", false)}} | |
| 55 | + p.observe(agent.Event{Kind: agent.EventMessage, Turn: 1, Message: &results}) | |
| 56 | + p.observe(agent.Event{Kind: agent.EventCompact}) | |
| 57 | + p.observe(agent.Event{Kind: agent.EventTurnStart, Turn: 2}) | |
| 58 | + tick(300 * time.Millisecond) | |
| 59 | + p.observe(agent.Event{Kind: agent.EventUsage, Turn: 2}) | |
| 60 | + p.observe(agent.Event{Kind: agent.EventMessage, Turn: 2, Response: &llm.Response{ | |
| 61 | + ID: "msg_2", StopReason: "end_turn", | |
| 62 | + Content: []llm.ContentBlock{llm.TextBlock("Two files.")}, | |
| 63 | + Usage: llm.Usage{InputTokens: 20, OutputTokens: 3, CacheReadInputTokens: 8}, | |
| 64 | + }}) | |
| 65 | + tick(50 * time.Millisecond) | |
| 66 | + } | |
| 67 | + | |
| 68 | + const ( | |
| 69 | + wantInit = `{"type":"system","subtype":"init","cwd":"/work","session_id":"20261002T120000Z","tools":["bash","mcp__db__query","read_file"],"mcp_servers":[{"name":"db","status":"connected"}],"model":"claude-opus-4-1","permissionMode":"default","uuid":"uuid-1"}` | |
| 70 | + wantAssistant = `{"type":"assistant","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Looking <here>.","type":"text"},{"id":"tu_1","input":{"command":"ls"},"name":"bash","type":"tool_use"}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-2"}` | |
| 71 | + wantUser = `{"type":"user","message":{"role":"user","content":[{"content":"a.go\nb.go","tool_use_id":"tu_1","type":"tool_result"}]},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-3"}` | |
| 72 | + wantCompact = `{"type":"system","subtype":"compact_boundary","session_id":"20261002T120000Z","uuid":"uuid-4"}` | |
| 73 | + // The second response named no model, so the session's stands in. | |
| 74 | + wantAssistant2 = `{"type":"assistant","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"text":"Two files.","type":"text"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":3,"cache_read_input_tokens":8,"cache_creation_input_tokens":0}},"parent_tool_use_id":null,"session_id":"20261002T120000Z","uuid":"uuid-5"}` | |
| 75 | + ) | |
| 76 | + | |
| 77 | + func resultFor(usage llm.Usage, model string) float64 { | |
| 78 | + c, _ := usage.Cost(model) | |
| 79 | + return c | |
| 80 | + } | |
| 81 | + | |
| 82 | + func TestStreamJSONShape(t *testing.T) { | |
| 83 | + p, out, notes, clock := fixedPrinter(formatStreamJSON, false) | |
| 84 | + p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"}) | |
| 85 | + script(p, clock) | |
| 86 | + usage := llm.Usage{InputTokens: 30, OutputTokens: 8, CacheReadInputTokens: 8} | |
| 87 | + p.finish(outcome{Text: "Two files.", Turns: 2, Usage: usage}) | |
| 88 | + | |
| 89 | + cost, _ := json.Marshal(resultFor(usage, "claude-opus-4-1")) | |
| 90 | + wantResult := `{"type":"result","subtype":"success","is_error":false,"duration_ms":1055,"duration_api_ms":1000,"num_turns":2,"result":"Two files.","session_id":"20261002T120000Z","total_cost_usd":` + string(cost) + `,"usage":{"input_tokens":30,"output_tokens":8,"cache_read_input_tokens":8,"cache_creation_input_tokens":0},"uuid":"uuid-6"}` | |
| 91 | + | |
| 92 | + want := strings.Join([]string{wantInit, wantAssistant, wantUser, wantCompact, wantAssistant2, wantResult}, "\n") + "\n" | |
| 93 | + if got := out.String(); got != want { | |
| 94 | + t.Errorf("stream-json:\n got %s\nwant %s", got, want) | |
| 95 | + } | |
| 96 | + if !strings.Contains(notes.String(), "codey: the window is full") { | |
| 97 | + t.Errorf("notices go to stderr, got %q", notes.String()) | |
| 98 | + } | |
| 99 | + // Every line is JSON a line-by-line reader can parse on its own. | |
| 100 | + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { | |
| 101 | + if !json.Valid([]byte(line)) { | |
| 102 | + t.Errorf("not JSON: %s", line) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + func TestJSONIsOneResultObject(t *testing.T) { | |
| 108 | + p, out, _, clock := fixedPrinter(formatJSON, false) | |
| 109 | + p.begin("/work", "claude-opus-4-1", "default", nil) | |
| 110 | + script(p, clock) | |
| 111 | + p.finish(outcome{Text: "Two files.", Turns: 2}) | |
| 112 | + | |
| 113 | + var got map[string]any | |
| 114 | + if err := json.Unmarshal(out.Bytes(), &got); err != nil { | |
| 115 | + t.Fatalf("json mode should print one object: %v\n%s", err, out.String()) | |
| 116 | + } | |
| 117 | + if strings.Count(strings.TrimSpace(out.String()), "\n") != 0 { | |
| 118 | + t.Errorf("one line only, got\n%s", out.String()) | |
| 119 | + } | |
| 120 | + for key, want := range map[string]any{ | |
| 121 | + "type": "result", "subtype": "success", "is_error": false, "result": "Two files.", | |
| 122 | + "session_id": "20261002T120000Z", "num_turns": 2.0, "duration_ms": 1055.0, "duration_api_ms": 1000.0, | |
| 123 | + } { | |
| 124 | + if got[key] != want { | |
| 125 | + t.Errorf("%s = %v, want %v", key, got[key], want) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + usage, _ := got["usage"].(map[string]any) | |
| 129 | + for _, k := range []string{"input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"} { | |
| 130 | + if _, ok := usage[k]; !ok { | |
| 131 | + t.Errorf("usage has no %s: %v", k, usage) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + if _, ok := got["total_cost_usd"].(float64); !ok { | |
| 135 | + t.Errorf("total_cost_usd should be a number, got %v", got["total_cost_usd"]) | |
| 136 | + } | |
| 137 | + if _, ok := got["errors"]; ok { | |
| 138 | + t.Error("a success carries no errors field") | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + func TestJSONVerboseIsTheStreamAsAnArray(t *testing.T) { | |
| 143 | + p, out, _, clock := fixedPrinter(formatJSON, true) | |
| 144 | + p.begin("/work", "claude-opus-4-1", "default", []string{"bash", "mcp__db__query", "read_file"}) | |
| 145 | + script(p, clock) | |
| 146 | + p.finish(outcome{Text: "Two files.", Turns: 2}) | |
| 147 | + | |
| 148 | + var lines []map[string]any | |
| 149 | + if err := json.Unmarshal(out.Bytes(), &lines); err != nil { | |
| 150 | + t.Fatalf("json --verbose prints an array: %v\n%s", err, out.String()) | |
| 151 | + } | |
| 152 | + var types []string | |
| 153 | + for _, l := range lines { | |
| 154 | + types = append(types, l["type"].(string)) | |
| 155 | + } | |
| 156 | + if strings.Join(types, ",") != "system,assistant,user,system,assistant,result" { | |
| 157 | + t.Errorf("types = %v", types) | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + func TestResultSubtypes(t *testing.T) { | |
| 162 | + cases := []struct { | |
| 163 | + name string | |
| 164 | + o outcome | |
| 165 | + subtype string | |
| 166 | + wantResult string | |
| 167 | + wantIsError bool | |
| 168 | + }{ | |
| 169 | + {"max turns keeps the last text", outcome{Text: "half done", Err: fmt.Errorf("%w: stopped", agent.ErrMaxTurns), Turns: 3}, resultMaxTurns, "half done", true}, | |
| 170 | + {"an error with no text says why", outcome{Err: errors.New("no credentials")}, resultExecution, "no credentials", true}, | |
| 171 | + {"success", outcome{Text: "ok"}, resultSuccess, "ok", false}, | |
| 172 | + } | |
| 173 | + for _, c := range cases { | |
| 174 | + t.Run(c.name, func(t *testing.T) { | |
| 175 | + p, out, _, _ := fixedPrinter(formatJSON, false) | |
| 176 | + p.finish(c.o) | |
| 177 | + var got struct { | |
| 178 | + Subtype string `json:"subtype"` | |
| 179 | + IsError bool `json:"is_error"` | |
| 180 | + Result string `json:"result"` | |
| 181 | + Errors []string `json:"errors"` | |
| 182 | + } | |
| 183 | + if err := json.Unmarshal(out.Bytes(), &got); err != nil { | |
| 184 | + t.Fatal(err) | |
| 185 | + } | |
| 186 | + if got.Subtype != c.subtype || got.IsError != c.wantIsError || got.Result != c.wantResult { | |
| 187 | + t.Errorf("got %+v", got) | |
| 188 | + } | |
| 189 | + if c.o.Err != nil && (len(got.Errors) != 1 || got.Errors[0] != c.o.Err.Error()) { | |
| 190 | + t.Errorf("errors = %q, want the message", got.Errors) | |
| 191 | + } | |
| 192 | + }) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + func TestFinishPrintsOnce(t *testing.T) { | |
| 197 | + p, out, _, _ := fixedPrinter(formatStreamJSON, false) | |
| 198 | + p.finish(outcome{Err: errors.New("boom")}) | |
| 199 | + claudeOut = p | |
| 200 | + defer func() { claudeOut = nil }() | |
| 201 | + reportFailure(errors.New("boom")) | |
| 202 | + if n := strings.Count(out.String(), `"type":"result"`); n != 1 { | |
| 203 | + t.Errorf("printed %d results, want 1", n) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + func TestTextFormatIsTheAnswerOnly(t *testing.T) { | |
| 208 | + p, out, _, clock := fixedPrinter(formatText, false) | |
| 209 | + p.begin("/work", "m", "default", []string{"bash"}) | |
| 210 | + script(p, clock) | |
| 211 | + p.finish(outcome{Text: "Two files."}) | |
| 212 | + if out.String() != "Two files.\n" { | |
| 213 | + t.Errorf("text = %q", out.String()) | |
| 214 | + } | |
| 215 | + | |
| 216 | + p, out, _, _ = fixedPrinter(formatText, false) | |
| 217 | + p.finish(outcome{Err: errors.New("boom")}) | |
| 218 | + if out.Len() != 0 { | |
| 219 | + t.Errorf("a failure prints nothing to stdout in text mode, got %q", out.String()) | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + func TestUnescapeHTMLLeavesEscapedBackslashesAlone(t *testing.T) { | |
| 224 | + // The escapes are put together at run time so that nothing between this | |
| 225 | + // file and the compiler can mistake them for the characters they stand for. | |
| 226 | + esc := func(hex string) string { return `\` + "u00" + hex } | |
| 227 | + lt, amp, gt := esc("3c"), esc("26"), esc("3e") | |
| 228 | + in := []byte(`{"a":"x ` + lt + ` y ` + amp + ` z ` + gt + `","b":"\\` + lt[1:] + ` stays","c":"\\` + lt + `"}`) | |
| 229 | + want := `{"a":"x < y & z >","b":"\\` + lt[1:] + ` stays","c":"\\<"}` | |
| 230 | + got := unescapeHTML(in) | |
| 231 | + if string(got) != want { | |
| 232 | + t.Errorf("got %s\nwant %s", got, want) | |
| 233 | + } | |
| 234 | + var v map[string]string | |
| 235 | + if err := json.Unmarshal(got, &v); err != nil || v["b"] != lt+" stays" || v["c"] != `\<` { | |
| 236 | + t.Errorf("the result should decode to the same strings: %v %q", err, v) | |
| 237 | + } | |
| 238 | + // And the encoder's own output, end to end. | |
| 239 | + var out bytes.Buffer | |
| 240 | + p := newPrinter(&out, io.Discard, formatStreamJSON, false) | |
| 241 | + p.write(llm.TextBlock("a<b && c>d")) | |
| 242 | + if strings.TrimSpace(out.String()) != `{"text":"a<b && c>d","type":"text"}` { | |
| 243 | + t.Errorf("a content block should print with its HTML unescaped, got %s", out.String()) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + | |
| 247 | + func TestUUID4(t *testing.T) { | |
| 248 | + id := uuid4() | |
| 249 | + if len(id) != 36 || id[14] != '4' || !strings.ContainsRune("89ab", rune(id[19])) { | |
| 250 | + t.Errorf("not a version-4 uuid: %s", id) | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + // A real run through the loop, against a fake Messages API: the lines on | |
| 255 | + // stdout are the agent's own messages, a refused tool call included, and the | |
| 256 | + // session is saved where -c will find it. | |
| 257 | + func TestPrintOnceStreamsARealRun(t *testing.T) { | |
| 258 | + responses := []string{ | |
| 259 | + `{"id":"msg_a","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Checking."},{"type":"tool_use","id":"tu_1","name":"bash","input":{"command":"rm -rf build"}}],"stop_reason":"tool_use","usage":{"input_tokens":12,"output_tokens":4}}`, | |
| 260 | + `{"id":"msg_b","type":"message","role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"Not allowed, so I stopped."}],"stop_reason":"end_turn","usage":{"input_tokens":20,"output_tokens":6}}`, | |
| 261 | + } | |
| 262 | + calls := 0 | |
| 263 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 264 | + io.ReadAll(r.Body) | |
| 265 | + if calls >= len(responses) { | |
| 266 | + w.WriteHeader(http.StatusInternalServerError) | |
| 267 | + return | |
| 268 | + } | |
| 269 | + io.WriteString(w, responses[calls]) | |
| 270 | + calls++ | |
| 271 | + })) | |
| 272 | + defer srv.Close() | |
| 273 | + | |
| 274 | + root := t.TempDir() | |
| 275 | + client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"), llm.WithModel("claude-opus-4-1")) | |
| 276 | + state := agent.NewState() | |
| 277 | + reg := tools.NewRegistry(nil).Register(tools.NewBash(root)) | |
| 278 | + a := agent.New(client, reg, state, nil, agent.Options{}) | |
| 279 | + policy := approval.New(nil) | |
| 280 | + policy.Rules = approval.Ruleset{Deny: []approval.Rule{mustParse(t, "Bash(rm:*)")}} | |
| 281 | + a.Approve = policy | |
| 282 | + | |
| 283 | + p, out, _, _ := fixedPrinter(formatStreamJSON, false) | |
| 284 | + a.Observe = p.observe | |
| 285 | + p.begin(root, client.Model(), "default", []string{"bash"}) | |
| 286 | + sessionDir := filepath.Join(root, agent.SessionDir) | |
| 287 | + if err := printOnce(context.Background(), a, p, sessionDir, "s1", root, client, "clean up"); err != nil { | |
| 288 | + t.Fatalf("printOnce: %v", err) | |
| 289 | + } | |
| 290 | + | |
| 291 | + var lines []map[string]any | |
| 292 | + for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") { | |
| 293 | + var m map[string]any | |
| 294 | + if err := json.Unmarshal([]byte(l), &m); err != nil { | |
| 295 | + t.Fatalf("not JSON: %s", l) | |
| 296 | + } | |
| 297 | + lines = append(lines, m) | |
| 298 | + } | |
| 299 | + var types []string | |
| 300 | + for _, l := range lines { | |
| 301 | + types = append(types, l["type"].(string)) | |
| 302 | + } | |
| 303 | + if strings.Join(types, ",") != "system,assistant,user,assistant,result" { | |
| 304 | + t.Fatalf("types = %v\n%s", types, out.String()) | |
| 305 | + } | |
| 306 | + user := lines[2]["message"].(map[string]any)["content"].([]any)[0].(map[string]any) | |
| 307 | + if user["type"] != "tool_result" || user["is_error"] != true || !strings.Contains(user["content"].(string), "Bash(rm:*)") { | |
| 308 | + t.Errorf("the refused call should come back as an error result naming the rule: %v", user) | |
| 309 | + } | |
| 310 | + res := lines[4] | |
| 311 | + if res["result"] != "Not allowed, so I stopped." || res["num_turns"] != 2.0 || res["is_error"] != false { | |
| 312 | + t.Errorf("result = %v", res) | |
| 313 | + } | |
| 314 | + if u := res["usage"].(map[string]any); u["input_tokens"] != 32.0 || u["output_tokens"] != 10.0 { | |
| 315 | + t.Errorf("usage = %v, want the two responses summed", u) | |
| 316 | + } | |
| 317 | + if _, err := os.Stat(filepath.Join(sessionDir, "s1.json")); err != nil { | |
| 318 | + t.Errorf("the session should be saved for -c: %v", err) | |
| 319 | + } | |
| 320 | + } | |
| 321 | + | |
| 322 | + func mustParse(t *testing.T, s string) approval.Rule { | |
| 323 | + t.Helper() | |
| 324 | + r, err := approval.ParseRule(s) | |
| 325 | + if err != nil { | |
| 326 | + t.Fatal(err) | |
| 327 | + } | |
| 328 | + return r | |
| 329 | + } | |
| @@ -14,6 +14,7 @@ require ( | ||
| 14 | 14 | github.com/invopop/jsonschema v0.14.0 |
| 15 | 15 | github.com/muesli/termenv v0.16.0 |
| 16 | 16 | golang.org/x/net v0.38.0 |
| 17 | + golang.org/x/term v0.45.0 | |
| 17 | 18 | gopkg.in/yaml.v3 v3.0.1 |
| 18 | 19 | mvdan.cc/sh/v3 v3.14.1 |
| 19 | 20 | ) |
| @@ -48,6 +49,5 @@ require ( | ||
| 48 | 49 | github.com/yuin/goldmark-emoji v1.0.6 // indirect |
| 49 | 50 | go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect |
| 50 | 51 | golang.org/x/sys v0.47.0 // indirect |
| 51 | - golang.org/x/term v0.45.0 // indirect | |
| 52 | 52 | golang.org/x/text v0.30.0 // indirect |
| 53 | 53 | ) |