package main
// The command line a script written for Claude Code's `claude` sends.
//
// `claude -p` is how a lot of CI jobs and shell scripts run an agent, and the
// point of this file is that pointing one of them at Codey instead should be a
// matter of changing the binary's name. So the flags those scripts pass are
// read here, in Claude Code's spelling and with Claude Code's meaning, and
// turned into the settings Codey already had: --dangerously-skip-permissions
// is -yolo, --continue is -resume latest, --allowedTools is a list of allow
// rules on the approval policy.
//
// Two of them do not fit Go's flag package as they are. `claude -p` is a
// switch and the prompt is a positional argument — `claude -p --output-format
// json "fix it"` — where Codey's -p takes the prompt as its value; and
// --allowedTools takes several values in a row. prepareArgs reads the line
// first and hands the flag package something it can parse, so that both
// `codey -p "fix it"` and the Claude Code spelling work.
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"golang.org/x/term"
"github.com/johannSo/codey/internal/approval"
)
// Output formats `-p` can print in. The empty format is Codey's own: the
// streamed transcript and a usage line, for a person reading along.
const (
formatText = "text"
formatJSON = "json"
formatStreamJSON = "stream-json"
)
// claudeFlags are the flags `claude` takes that Codey answers to as well.
type claudeFlags struct {
print bool
outputFormat string
inputFormat string
verbose bool
cont bool
allowed ruleList
disallowed ruleList
appendSystem string
systemPrompt string
permissionMode string
skipPermissions bool
partial bool
}
// ruleList collects permission rules from every time the flag is given, each
// time a list of its own: --allowedTools "Read,Edit" --allowedTools "Bash(go test:*)".
type ruleList []string
func (l *ruleList) String() string { return strings.Join(*l, ",") }
func (l *ruleList) Set(v string) error {
*l = append(*l, approval.SplitRules(v)...)
return nil
}
// ruleFlags are the flags prepareArgs lets take several values in a row.
var ruleFlags = map[string]bool{
"allowedTools": true, "allowed-tools": true,
"disallowedTools": true, "disallowed-tools": true,
}
func registerClaudeFlags(fs *flag.FlagSet) *claudeFlags {
c := &claudeFlags{}
fs.BoolVar(&c.print, "print", false, "the same as -p; the prompt may follow it, or come after the other flags, or on stdin")
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")
fs.StringVar(&c.inputFormat, "input-format", formatText, "with -p: only text is supported")
fs.BoolVar(&c.verbose, "verbose", false, "the same as -v")
fs.BoolVar(&c.cont, "c", false, "continue the most recent session in this working root, the same as -resume latest")
fs.BoolVar(&c.cont, "continue", false, "the same as -c")
ruleHelp := "comma- or space-separated rules in Claude Code's syntax: Bash, Bash(git diff:*), Read(./src/**), mcp__server"
fs.Var(&c.allowed, "allowedTools", "tool calls to approve without asking; "+ruleHelp)
fs.Var(&c.allowed, "allowed-tools", "the same as -allowedTools")
fs.Var(&c.disallowed, "disallowedTools", "tools to take away, or calls to refuse whatever else says yes; "+ruleHelp)
fs.Var(&c.disallowed, "disallowed-tools", "the same as -disallowedTools")
fs.StringVar(&c.appendSystem, "append-system-prompt", "", "text added to the end of the system prompt")
fs.StringVar(&c.systemPrompt, "system-prompt", "", "replace Codey's system prompt with this text")
fs.StringVar(&c.permissionMode, "permission-mode", "", "default, acceptEdits (edits without asking), bypassPermissions (the same as -yolo) or dontAsk (refuse what no rule allows)")
fs.BoolVar(&c.skipPermissions, "dangerously-skip-permissions", false, "the same as -yolo")
fs.BoolVar(&c.partial, "include-partial-messages", false, "accepted for compatibility and ignored: stream-json prints whole messages only")
return c
}
// prepareArgs rewrites the command line into one the flag package reads the
// way `claude` would have.
//
// Go's flag parsing stops at the first argument that is not a flag, and
// commander, which `claude` uses, does not: `claude "fix it" --model x` is a
// prompt and a model. So the flags are pulled out wherever they are, and the
// positional arguments go after a "--" at the end, where flag.Args finds them.
// Knowing which flags take a value is what makes that possible, and the flag
// set already knows.
//
// -p and --print take the next argument as the prompt when it is not a flag,
// which is both Codey's -p "prompt" and Claude Code's -p "prompt"; followed by
// a flag or by nothing, they are a switch, and the prompt is whatever
// positional arguments there are, or stdin. The rule flags take every
// following argument that reads as a rule, as commander's variadic options do
// — except that "reads as a rule" here means it parses as one, so a one-word
// prompt after --allowedTools Read is still a prompt.
//
// Anything it does not recognise is passed through untouched, for the flag
// package to report in its own words.
func prepareArgs(fs *flag.FlagSet, args []string, c *claudeFlags) []string {
var flags, positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
positional = append(positional, args[i+1:]...)
break
}
if !isFlag(a) {
positional = append(positional, a)
continue
}
name, _, hasValue := strings.Cut(strings.TrimLeft(a, "-"), "=")
if (name == "p" || name == "print") && !hasValue {
if i+1 < len(args) && !isFlag(args[i+1]) {
flags = append(flags, "-p", args[i+1])
i++
continue
}
c.print = true
continue
}
f := fs.Lookup(name)
if f == nil || hasValue || isBoolFlag(f) || i+1 >= len(args) {
flags = append(flags, a)
continue
}
flags = append(flags, a, args[i+1])
i++
if ruleFlags[name] {
for i+1 < len(args) && !isFlag(args[i+1]) && readsAsRules(args[i+1]) {
flags = append(flags, a, args[i+1])
i++
}
}
}
return append(append(flags, "--"), positional...)
}
// isFlag is the flag package's own test: a dash and something after it.
func isFlag(a string) bool { return len(a) > 1 && a[0] == '-' }
func isBoolFlag(f *flag.Flag) bool {
b, ok := f.Value.(interface{ IsBoolFlag() bool })
return ok && b.IsBoolFlag()
}
// readsAsRules reports whether every item in an argument parses as a rule.
func readsAsRules(a string) bool {
items := approval.SplitRules(a)
if len(items) == 0 {
return false
}
for _, s := range items {
if _, err := approval.ParseRule(s); err != nil {
return false
}
}
return true
}
// cliRun is what the Claude Code flags came to.
type cliRun struct {
// Prompt is the -p prompt, put together from the flag, the positional
// arguments and stdin; empty for an interactive session.
Prompt string
// FromStdin is set when the prompt was read from stdin, which leaves
// nothing there to read an approval from.
FromStdin bool
// Format is the -p output format, empty for Codey's own.
Format string
// Resume is -resume, or "latest" for --continue.
Resume string
// YOLO is --dangerously-skip-permissions or bypassPermissions.
YOLO bool
// NoPrompt refuses whatever no rule allows rather than asking.
NoPrompt bool
// Mode is the permission mode, in Claude Code's words, for the init line.
Mode string
// Rules go on the approval policy; Drop is the part of them that takes
// whole tools away, for the registry; and DropHostedSearch is the same
// for the one tool the registry never holds.
Rules approval.Ruleset
Drop func(name string) bool
DropHostedSearch bool
// System replaces the system prompt when set, and SystemAppend goes on
// the end of it.
System, SystemAppend string
// Warnings are for the user, once there is somewhere to say them.
Warnings []string
}
// UIOut is where the line front end may write its prompts. In a Claude-shaped
// run stdout belongs to the output format, so a question goes to stderr.
func (r cliRun) UIOut() io.Writer {
if r.Format != "" {
return os.Stderr
}
return os.Stdout
}
// resolve turns the parsed flags into a cliRun. prompt and resume are Codey's
// own -p and -resume, positional is what flag parsing left over, and root is
// what relative path rules are read against.
func (c *claudeFlags) resolve(prompt string, positional []string, resume, root string, stdin *os.File) (cliRun, error) {
r := cliRun{Resume: resume, System: c.systemPrompt, SystemAppend: c.appendSystem, Mode: "default"}
printing := c.print || prompt != ""
switch c.outputFormat {
case "", formatText, formatJSON, formatStreamJSON:
default:
return r, fmt.Errorf("--output-format %q: want text, json or stream-json", c.outputFormat)
}
if c.outputFormat != "" && !printing {
return r, errors.New("--output-format applies only with -p")
}
r.Format = c.outputFormat
if c.inputFormat != formatText {
return r, fmt.Errorf("--input-format %q is not supported: the prompt is read as text", c.inputFormat)
}
if c.partial {
r.Warnings = append(r.Warnings, "--include-partial-messages is ignored: stream-json prints whole messages only")
}
if c.cont {
if resume != "" && resume != "latest" {
return r, fmt.Errorf("--continue and -resume %s ask for different sessions; give one", resume)
}
r.Resume = "latest"
}
if printing {
parts := positional
if prompt != "" {
parts = append([]string{prompt}, positional...)
}
r.Prompt = strings.TrimSpace(strings.Join(parts, " "))
if r.Prompt == "" && !isTerminal(stdin) {
// What Claude Code does with `echo "fix it" | claude -p`. Stdin
// is read only when there is no prompt otherwise: a CI runner can
// leave it as a pipe nobody ever closes, and a run that waited on
// it would hang there for good.
b, err := io.ReadAll(stdin)
if err != nil {
return r, fmt.Errorf("reading the prompt from stdin: %w", err)
}
r.Prompt, r.FromStdin = strings.TrimSpace(string(b)), true
}
if r.Prompt == "" {
return r, errors.New("-p needs a prompt: give it after -p, as an argument, or on stdin")
}
} else if len(positional) > 0 {
r.Warnings = append(r.Warnings, fmt.Sprintf("ignoring %q: a prompt on the command line needs -p", strings.Join(positional, " ")))
}
// With nobody at a terminal there is nobody to ask, and a Claude-shaped
// run never asks anyway: what no rule allows is refused, and the model
// is told so. Codey's own -p keeps reading answers from stdin, which is
// how `yes | codey -p ...` has always worked.
r.NoPrompt = r.FromStdin || (r.Format != "" && !isTerminal(stdin))
allow, errs := approval.ParseRules(c.allowed)
deny, denyErrs := approval.ParseRules(c.disallowed)
for _, err := range append(errs, denyErrs...) {
r.Warnings = append(r.Warnings, fmt.Sprintf("%v — ignored", err))
}
r.YOLO = c.skipPermissions
switch c.permissionMode {
case "", "default":
case "acceptEdits":
// The mode the ACP front end calls by the same name: edits go
// through, commands still ask. An Edit rule says exactly that.
edit, _ := approval.ParseRule("Edit")
allow = append(allow, edit)
r.Mode = c.permissionMode
case "bypassPermissions":
r.YOLO, r.Mode = true, c.permissionMode
case "dontAsk":
r.NoPrompt, r.Mode = true, c.permissionMode
case "plan":
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")
default:
return r, fmt.Errorf("--permission-mode %q: want default, acceptEdits, bypassPermissions or dontAsk", c.permissionMode)
}
if r.YOLO {
r.Mode = "bypassPermissions"
}
for i := range allow {
allow[i].Root = root
}
for i := range deny {
deny[i].Root = root
}
r.Rules = approval.Ruleset{Allow: allow, Deny: deny}
var whole []approval.Rule
for _, d := range deny {
if d.WholeTool() {
whole = append(whole, d)
}
}
r.Drop = func(name string) bool {
for _, d := range whole {
if d.Covers(name) {
return true
}
}
return false
}
r.DropHostedSearch = r.Drop("web_search")
return r, nil
}
// isTerminal reports whether f is a terminal rather than a pipe or a file. It
// asks the terminal driver rather than checking for a character device,
// because /dev/null is one too — and `< /dev/null` is exactly how a CI job
// says there is nobody here.
func isTerminal(f *os.File) bool {
return term.IsTerminal(int(f.Fd()))
}