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 <id> 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)
}
}