Vectrum
Sign In
338 lines · 12.4 KB · Go
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()))
}