package main import ( "encoding/json" "flag" "io" "os" "path/filepath" "slices" "strings" "testing" "github.com/johannSo/codey/internal/approval" ) // testFlags is a flag set shaped like main's: the Codey flags the pre-pass has // to know the arity of, and the Claude Code ones. func testFlags() (*flag.FlagSet, *claudeFlags, *string, *string, *string, *bool) { fs := flag.NewFlagSet("codey", flag.ContinueOnError) fs.SetOutput(io.Discard) prompt := fs.String("p", "", "") model := fs.String("model", "", "") resume := fs.String("resume", "", "") v := fs.Bool("v", false, "") fs.Int("max-turns", 0, "") fs.String("C", ".", "") fs.Bool("yolo", false, "") return fs, registerClaudeFlags(fs), prompt, model, resume, v } func TestPrepareArgsReadsBothSpellingsOfP(t *testing.T) { cases := []struct { name string args []string wantPrompt string // what -p ends up holding wantPrint bool // -p given as a switch wantArgs []string wantModel string }{ {name: "codey style", args: []string{"-p", "fix it"}, wantPrompt: "fix it"}, {name: "claude style, prompt last", args: []string{"-p", "--output-format", "json", "fix it"}, wantPrint: true, wantArgs: []string{"fix it"}}, {name: "--print", args: []string{"--print", "--model", "m", "fix", "it"}, wantPrint: true, wantArgs: []string{"fix", "it"}, wantModel: "m"}, {name: "prompt first, flags after", args: []string{"fix it", "-p", "--model", "m"}, wantPrint: true, wantArgs: []string{"fix it"}, wantModel: "m"}, {name: "-p with its prompt, then flags", args: []string{"-p", "fix it", "--model=m"}, wantPrompt: "fix it", wantModel: "m"}, {name: "-p alone", args: []string{"-p"}, wantPrint: true}, {name: "-p= keeps a leading dash", args: []string{"-p=-x is broken"}, wantPrompt: "-x is broken"}, {name: "double-dash value flags", args: []string{"--model", "m", "--max-turns", "3", "-p", "go"}, wantPrompt: "go", wantModel: "m"}, {name: "bool flags take no value", args: []string{"-p", "-v", "go"}, wantPrint: true, wantArgs: []string{"go"}}, {name: "-- ends the flags", args: []string{"-p", "--", "--model", "x"}, wantPrint: true, wantArgs: []string{"--model", "x"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { fs, cc, prompt, model, _, _ := testFlags() if err := fs.Parse(prepareArgs(fs, c.args, cc)); err != nil { t.Fatalf("parse: %v", err) } if *prompt != c.wantPrompt || cc.print != c.wantPrint || *model != c.wantModel { t.Errorf("-p=%q print=%v model=%q, want %q %v %q", *prompt, cc.print, *model, c.wantPrompt, c.wantPrint, c.wantModel) } if got := fs.Args(); !slices.Equal(got, c.wantArgs) && !(len(got) == 0 && len(c.wantArgs) == 0) { t.Errorf("positional = %q, want %q", got, c.wantArgs) } }) } } func TestPrepareArgsTakesSeveralRulesInARow(t *testing.T) { fs, cc, _, _, _, _ := testFlags() args := []string{"-p", "--allowedTools", "Read", "Bash(git log:*)", "Edit,Write", "summarise the log", "--disallowedTools", "WebFetch"} if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil { t.Fatal(err) } if want := []string{"Read", "Bash(git log:*)", "Edit", "Write"}; !slices.Equal(cc.allowed, want) { t.Errorf("allowed = %q, want %q", cc.allowed, want) } if !slices.Equal(cc.disallowed, []string{"WebFetch"}) { t.Errorf("disallowed = %q", cc.disallowed) } if got := fs.Args(); !slices.Equal(got, []string{"summarise the log"}) { t.Errorf("the prompt should stop the rule list: positional = %q", got) } // A one-word prompt is not a tool name, so it is not eaten as a rule. fs, cc, _, _, _, _ = testFlags() fs.Parse(prepareArgs(fs, []string{"-p", "--allowed-tools", "Read", "hello"}, cc)) if !slices.Equal(cc.allowed, []string{"Read"}) || !slices.Equal(fs.Args(), []string{"hello"}) { t.Errorf("allowed=%q positional=%q", cc.allowed, fs.Args()) } } func TestPrepareArgsLeavesTheUnknownToTheFlagPackage(t *testing.T) { fs, cc, _, _, _, _ := testFlags() if err := fs.Parse(prepareArgs(fs, []string{"--mcp-config", "x.json", "-p", "go"}, cc)); err == nil { t.Error("an unknown flag should still be an error") } } // The agent-run pre-parse in agentcmd.go still sees the line first, and an // ordinary line still comes through it untouched for prepareArgs. func TestScanArgsStillHandsOrdinaryLinesOn(t *testing.T) { args := []string{"-p", "--permission-mode", "acceptEdits", "--output-format", "json", "go"} inv := scanArgs(args) if inv.Kind != invNormal || !slices.Equal(inv.Rest, args) { t.Fatalf("scanArgs = %+v", inv) } fs, cc, _, _, _, _ := testFlags() if err := fs.Parse(prepareArgs(fs, inv.Rest, cc)); err != nil { t.Fatal(err) } if cc.permissionMode != "acceptEdits" || cc.outputFormat != "json" || !cc.print { t.Errorf("flags = %+v", cc) } } // ttyless is a stdin that is a file, not a terminal: what a CI job has. func ttyless(t *testing.T, content string) *os.File { t.Helper() p := filepath.Join(t.TempDir(), "stdin") os.WriteFile(p, []byte(content), 0o644) f, err := os.Open(p) if err != nil { t.Fatal(err) } t.Cleanup(func() { f.Close() }) return f } func parseResolve(t *testing.T, stdin *os.File, args ...string) (cliRun, error) { t.Helper() fs, cc, prompt, _, resume, _ := testFlags() if err := fs.Parse(prepareArgs(fs, args, cc)); err != nil { t.Fatalf("parse %q: %v", args, err) } return cc.resolve(*prompt, fs.Args(), *resume, "/work", stdin) } func TestResolveJoinsThePrompt(t *testing.T) { r, err := parseResolve(t, ttyless(t, "ignored"), "-p", "fix", "the", "bug") if err != nil || r.Prompt != "fix the bug" || r.FromStdin { t.Errorf("prompt=%q fromStdin=%v err=%v", r.Prompt, r.FromStdin, err) } } func TestResolveReadsThePromptFromStdin(t *testing.T) { r, err := parseResolve(t, ttyless(t, " explain main.go\n"), "-p", "--output-format", "json") if err != nil { t.Fatal(err) } if r.Prompt != "explain main.go" || !r.FromStdin || !r.NoPrompt || r.Format != formatJSON { t.Errorf("got %+v", r) } if _, err := parseResolve(t, ttyless(t, ""), "-p"); err == nil || !strings.Contains(err.Error(), "needs a prompt") { t.Errorf("no prompt anywhere should be an error, got %v", err) } } func TestResolveRefusesWhatItCannotDo(t *testing.T) { stdin := ttyless(t, "") for _, args := range [][]string{ {"-p", "x", "--output-format", "yaml"}, {"--output-format", "json"}, {"-p", "x", "--input-format", "stream-json"}, {"-p", "x", "--permission-mode", "plan"}, {"-p", "x", "--permission-mode", "whatever"}, {"-c", "-resume", "20260101T000000Z", "-p", "x"}, } { if _, err := parseResolve(t, stdin, args...); err == nil { t.Errorf("%q should be refused", args) } } _, err := parseResolve(t, stdin, "-p", "x", "--permission-mode", "plan") if err == nil || !strings.Contains(err.Error(), "plan mode is not supported") { t.Errorf("plan mode should say so plainly, got %v", err) } } func TestResolvePermissionModes(t *testing.T) { stdin := ttyless(t, "") edit := json.RawMessage(`{"path":"a.go"}`) r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "acceptEdits") if v, _ := r.Rules.Check("write_file", edit); v != approval.Allowed || r.Mode != "acceptEdits" || r.YOLO { t.Errorf("acceptEdits should allow edits and only edits: %+v", r) } if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"ls"}`)); v == approval.Allowed { t.Error("acceptEdits still asks before a command") } for _, args := range [][]string{ {"-p", "x", "--permission-mode", "bypassPermissions"}, {"-p", "x", "--dangerously-skip-permissions"}, } { if r, _ := parseResolve(t, stdin, args...); !r.YOLO || r.Mode != "bypassPermissions" { t.Errorf("%q should be -yolo: %+v", args, r) } } if r, _ := parseResolve(t, stdin, "-p", "x", "--permission-mode", "dontAsk"); !r.NoPrompt { t.Error("dontAsk refuses rather than asking") } // Codey's own -p keeps asking on stdin, as `yes | codey -p` relies on. if r, _ := parseResolve(t, stdin, "-p", "x"); r.NoPrompt { t.Error("plain -p with a prompt argument should still read approvals from stdin") } // A Claude-shaped run with no terminal never asks. if r, _ := parseResolve(t, stdin, "-p", "x", "--output-format", "text"); !r.NoPrompt { t.Error("an output format with no terminal should not ask") } } func TestResolveContinue(t *testing.T) { stdin := ttyless(t, "") for _, args := range [][]string{{"-c", "-p", "x"}, {"--continue", "-p", "x"}, {"-c", "-resume", "latest", "-p", "x"}} { if r, err := parseResolve(t, stdin, args...); err != nil || r.Resume != "latest" { t.Errorf("%q: resume=%q err=%v", args, r.Resume, err) } } if r, _ := parseResolve(t, stdin, "--resume", "abc", "-p", "x"); r.Resume != "abc" { t.Errorf("--resume is unchanged, got %q", r.Resume) } } func TestResolveRules(t *testing.T) { stdin := ttyless(t, "") r, err := parseResolve(t, stdin, "-p", "x", "--allowedTools", "Bash(go test:*),Read(./src/**)", "--disallowedTools", "WebSearch Bash(git push:*) mcp__db NotebookEdit") if err != nil { t.Fatal(err) } if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "NotebookEdit") { t.Errorf("an unknown tool is a warning, not an error: %q", r.Warnings) } for name, want := range map[string]bool{ "web_search": true, "mcp__db__query": true, "bash": false, "read_file": false, } { if got := r.Drop(name); got != want { t.Errorf("Drop(%q) = %v, want %v", name, got, want) } } if !r.DropHostedSearch { t.Error("WebSearch disallowed should take hosted search off the request too") } if v, _ := r.Rules.Check("bash", json.RawMessage(`{"command":"go test ./... && git push"}`)); v != approval.Denied { t.Errorf("a scoped deny rule is enforced at approval time, got %v", v) } if v, _ := r.Rules.Check("read_file", json.RawMessage(`{"path":"/work/src/a.go"}`)); v != approval.Allowed { t.Errorf("relative path rules are read against the working root, got %v", v) } } func TestResolveWarnsAboutAPromptWithoutP(t *testing.T) { r, err := parseResolve(t, ttyless(t, ""), "fix it") if err != nil || r.Prompt != "" || len(r.Warnings) != 1 { t.Errorf("prompt=%q warnings=%q err=%v", r.Prompt, r.Warnings, err) } }