Vectrum
Sign In

cli: answer to claude -p's flags, and print in its output formats

-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>
Jjonicommitted 1 parent 3916e1dcommit9b07bd5

Showing 6 changed files with 1381 additions and 5 deletions.

+338−0cmd/codey/claudecli.goaddedView File
@@ -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+ }
+256−0cmd/codey/claudecli_test.goaddedView File
@@ -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+ }
+49−4cmd/codey/main.goView File
@@ -61,6 +61,7 @@ const hostedSearchesPerTurn = 5
6161
6262 func main() {
6363 if err := run(); err != nil {
64+ reportFailure(err)
6465 fmt.Fprintln(os.Stderr, "codey:", err)
6566 os.Exit(1)
6667 }
@@ -93,6 +94,8 @@ func run() error {
9394 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9495 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9596 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)
9699
97100 // Read the line before the flag package does. Three invocations belong to
98101 // a program driving Codey rather than to a person — see agentcmd.go — and
@@ -102,7 +105,7 @@ func run() error {
102105 printVersion(os.Stdout)
103106 return nil
104107 }
105- if err := flag.CommandLine.Parse(inv.Rest); err != nil {
108+ if err := flag.CommandLine.Parse(prepareArgs(flag.CommandLine, inv.Rest, cc)); err != nil {
106109 return err
107110 }
108111
@@ -111,6 +114,19 @@ func run() error {
111114 return err
112115 }
113116
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+
114130 // A clone last used under the old name keeps its sessions and its event
115131 // log. This runs before anything opens a file inside the state directory,
116132 // and is reported rather than fatal: a stale directory that cannot be
@@ -222,6 +238,9 @@ func run() error {
222238 fmt.Fprintln(os.Stderr, "codey: "+msg)
223239 }
224240
241+ for _, w := range cli.Warnings {
242+ notice("%s", w)
243+ }
225244 if cfgErr != nil {
226245 notice("%v — using defaults; /setup will write a fresh one", cfgErr)
227246 }
@@ -333,6 +352,11 @@ func run() error {
333352 }
334353 }
335354 }
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+ }
336360
337361 // Whatever this machine can say about a file the agent has just written.
338362 // A nil Runner is the off switch, and every call site takes one, so the
@@ -392,9 +416,13 @@ func run() error {
392416 // /worktree rebuilds the tool set against a different directory: each tool
393417 // closes over the root it is handed, so moving the work means building the
394418 // 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.
395423 toolsIn := func(root string) func(*agent.State, *metrics.Logger) *tools.Registry {
396424 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(
398426 tools.NewRead(root, state, skillDirs...),
399427 tools.NewGlob(root, skillDirs...),
400428 tools.NewGrep(root, skillDirs...),
@@ -433,14 +461,19 @@ func run() error {
433461 in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
434462
435463 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()}
437465 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+ }
438471
439472 // Questions go to the line front end until a TUI takes them over in
440473 // tui.Run. The tool belongs to the session's own agent and is registered
441474 // here rather than in newTools, so a subagent still cannot stop and ask:
442475 // 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)
444477 reg.Register(ask.Tool(asker.Ask))
445478 // The plan belongs to the session agent for the same reason the question
446479 // does: a subagent keeping its own list would show the user a plan for
@@ -532,6 +565,11 @@ func run() error {
532565 base.Models = models
533566 }
534567
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+
535573 a := agent.New(client, reg, state, log, base)
536574 a.Observe = render.Handle
537575 a.Approve = policy
@@ -668,6 +706,7 @@ func run() error {
668706 // to come up in the tab whose agent is asking.
669707 pol := approval.New(nil)
670708 pol.YOLO = acceptAll
709+ pol.Rules = cli.Rules
671710
672711 var tabSup *subagent.Supervisor
673712 if !*noAgents {
@@ -805,6 +844,12 @@ func run() error {
805844 ctx, stopSignals := interruptible(context.Background())
806845 defer stopSignals()
807846
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+ }
808853 if *prompt != "" {
809854 return once(ctx, a, render, sessionDir, session, root, client, *prompt)
810855 }
+408−0cmd/codey/printout.goaddedView File
@@ -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+ }
+329−0cmd/codey/printout_test.goaddedView File
@@ -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, &notes, 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, &notes, &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+ }
+1−1go.modView File
@@ -14,6 +14,7 @@ require (
1414 github.com/invopop/jsonschema v0.14.0
1515 github.com/muesli/termenv v0.16.0
1616 golang.org/x/net v0.38.0
17+ golang.org/x/term v0.45.0
1718 gopkg.in/yaml.v3 v3.0.1
1819 mvdan.cc/sh/v3 v3.14.1
1920 )
@@ -48,6 +49,5 @@ require (
4849 github.com/yuin/goldmark-emoji v1.0.6 // indirect
4950 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
5051 golang.org/x/sys v0.47.0 // indirect
51- golang.org/x/term v0.45.0 // indirect
5252 golang.org/x/text v0.30.0 // indirect
5353 )