diff --git a/.roborev.toml b/.roborev.toml index b5780cb..c78ca3d 100644 --- a/.roborev.toml +++ b/.roborev.toml @@ -14,6 +14,44 @@ code actually does not compile, the toolchain will fail before the PR can land. Focus reviews on logic, architecture, behavior, security boundaries, and test coverage for behavior. Trust the build for compilability. +## External CLI argument grammars + +Do not guess how an external command parses options, option values, +subcommands, prompts, session selectors, or argument ordering. In particular, +do not report an agent CLI argument-shape finding from naming conventions, +another CLI's behavior, general POSIX conventions, or synthetic test fixtures. + +Such a finding requires authoritative, version-matched evidence for the exact +command under review. Acceptable evidence is the installed command's relevant +`--help` output captured for the review, official documentation for the pinned +version, or source from that version's argument parser. If the review cannot +obtain that evidence, omit the finding. Do not ask for speculative validation, +reordering, compatibility handling, or a regression test based on an assumed +grammar. + +Do not assume every positional prompt needs a double-dash separator. Require +evidence that the exact CLI continues option parsing at that position and that +the package's selected prompt transport leaves the prompt exposed to it. + +Do not invent a shared safety-policy lattice across configured options and +request fields. Different CLIs define precedence and interaction differently. +Report a conflict only when authoritative evidence shows that the exact +combination changes or defeats behavior the caller requested. A stricter, +redundant, or differently expressed option is not by itself a defect. Do not +request extra rejection logic as general hardening. + +Do not assume JSON Schema requires a separate JSON output-format flag. Some +CLIs use the schema option itself to select or validate structured output. +Report an invalid schema invocation only when authoritative evidence for that +CLI and mode shows the constructed command cannot produce the requested +schema-conforming result. + +The `agentcli` package intentionally owns the supported Codex, Claude Code, +Gemini, GitHub Copilot, OpenCode, Cursor Agent, Kiro, Kilo, Factory Droid, and +Pi command shapes. Review its internal consistency and its behavior against +supplied authoritative evidence. Do not infer that a listed option or arity is +wrong only because roborev-ci did not execute the external CLI. + ## Managed Git trust boundary `git/managed` trusts the existing local repository, its remotes and diff --git a/agentcli/README.md b/agentcli/README.md new file mode 100644 index 0000000..efddf6a --- /dev/null +++ b/agentcli/README.md @@ -0,0 +1,121 @@ +# Agent CLI command construction + +`agentcli` builds argument vectors for coding-agent CLIs. It owns agent-specific +flag placement, configured-option grammar and arity, start and resume forms, +prompt delivery, and rejection of unsupported options. It does not execute +commands, inspect installed versions, resolve session files, or manage +terminals and persistent state. + +Constructors accept an executable separately from configured options and +validate the options immediately. Configured options may not contain a prompt, +subcommand, session selector, `--`, or an option with ambiguous arity. Put prompt +text in `Request.Prompt`; its zero value means no prompt. The adapter chooses the +CLI's normal argument or stdin transport. Use `Resume` for a saved session. + +## Consumer examples + +Forge can preserve a configured interactive command and resume without sending +the original prompt again: + +```go +agent, err := agentcli.NewCodex(agentcli.Command{ + Executable: "codex", + Options: []string{"--profile", "forge"}, +}) +if err != nil { + return err +} +invocation, err := agent.Resume(sessionID, agentcli.Request{}) +// invocation.Argv: codex --profile forge resume +``` + +RoboRev can select an adapter by name and request a noninteractive event stream: + +```go +prompt := agentcli.Prompt{Text: reviewPrompt} +agent, err := agentcli.New(agentcli.Codex, agentcli.Command{Executable: configuredExecutable}) +if err != nil { + return err +} +invocation, err := agent.Start(agentcli.Request{ + Mode: agentcli.NonInteractive, + Prompt: prompt, + OutputFormat: agentcli.OutputJSONL, + Sandbox: agentcli.SandboxReadOnly, + Approval: agentcli.ApprovalNever, +}) +// Pass invocation.Argv[0], invocation.Argv[1:], and *invocation.Stdin to os/exec. +``` + +Call `Capabilities` before presenting options in a UI. `Start` and `Resume` +still validate every request and return `UnsupportedOptionError` when an option +cannot keep its requested meaning. Constructors return `InvalidCommandError` +for unsafe configured command shapes. A request also returns that error when it +would duplicate a configured singleton option, such as a model or session +policy; remove one of the two settings instead of relying on CLI precedence. + +## Supported agents + +`Names` returns ten concrete CLI adapters. The modes below describe this +package, not every mode offered by the underlying command. Beyond Forge's +current three CLI families, the adapters expose the noninteractive command +shape that RoboRev currently needs. + +| Agent | Modes | Prompt | Resume | Output | Reasoning | +| --- | --- | --- | --- | --- | --- | +| Codex | interactive, noninteractive | argument when interactive, stdin when noninteractive | `resume ID`, `exec resume ID` | text, JSONL | low, medium, high, xhigh, maximum | +| Claude Code | interactive, noninteractive | argument when interactive, stdin when noninteractive | `--resume ID` | text, JSON, JSONL | low, medium, high, xhigh, maximum | +| Gemini | noninteractive | stdin through `--prompt` | `--resume ID` | text, JSON, JSONL | none | +| GitHub Copilot | noninteractive | `--prompt` | `--resume=ID` | text, JSONL | low, medium, high, xhigh, maximum | +| OpenCode | noninteractive | stdin | `run --session ID` | text, JSONL | none | +| Cursor Agent | noninteractive | stdin | `--resume ID` | text, JSON, JSONL | none | +| Kiro | noninteractive | argument | `chat --resume-id ID` | text | low, medium, high, xhigh, maximum | +| Kilo | noninteractive | stdin | `run --session ID` | text, JSONL | low, medium, high, xhigh, maximum | +| Factory Droid | noninteractive | stdin | `exec --session-id ID` | text, JSON, JSONL | low, medium, high, xhigh, maximum | +| Pi | interactive, noninteractive | argument and `@file` | `--session ID` | text, JSONL | low, medium, high, xhigh, maximum | + +`ReasoningXHigh` and `ReasoningMaximum` are distinct. Adapters with a native +`max` value, including Codex, map only `ReasoningMaximum` to it. Droid accepts +model-dependent reasoning values, and Kilo passes the value as a +provider-specific model variant, so the selected model remains the final +authority for those two commands. + +The remaining controls are intentionally uneven: + +| Agent | JSON Schema | Execution controls | Customization controls | +| --- | --- | --- | --- | +| Codex | schema file and output path | sandbox and approval modes | disable skill instructions, hooks, user config, or session storage; config overrides | +| Claude Code | inline schema | approval modes; allow, deny, or disable built-in tools | disable skills, all customizations including hooks, or session storage | +| Gemini | none | plan or bypass approval mode | none | +| GitHub Copilot | none | allow and deny tools; full permission bypass | disable built-in MCP servers or context instructions | +| OpenCode | none | none | none | +| Cursor Agent | none | plan or bypass mode | none | +| Kiro | none | trusted-tool allowlist or trust all tools | none | +| Kilo | none | automatic approval | none | +| Factory Droid | none | tool allowlist and denylist; low, medium, or high autonomy; permission bypass | disable built-in skills | +| Pi | inline schema through an explicit extension and output file | allow, deny, or disable built-in tools | skill paths; disable skills, extensions, prompt templates, themes, context files, hooks through extension discovery, or session storage | + +The adapters reflect these CLI contracts: + +- [Codex noninteractive mode](https://learn.chatgpt.com/docs/non-interactive-mode) +- [Codex configuration reference](https://developers.openai.com/codex/config-reference) +- [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference) +- [Gemini CLI reference](https://geminicli.com/docs/cli/commands/) +- [GitHub Copilot CLI reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference) +- [OpenCode CLI reference](https://opencode.ai/docs/cli/) +- [Cursor Agent CLI reference](https://docs.cursor.com/en/cli/reference/parameters) +- [Kiro CLI command reference](https://kiro.dev/docs/reference/cli-commands/) +- [Kilo CLI source](https://github.com/Kilo-Org/kilocode) +- [Factory Droid CLI reference](https://docs.factory.ai/droid-cli/cli-reference) +- [Pi README](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) + +The first consumer migrations should replace Forge's temporary command-option +validator together with its interactive resume switch; Forge should pass its +configured executable and option slice directly to the matching constructor. +RoboRev should replace the argument builders for its ten command-based agents +while keeping stream parsing, installed-version capability probes, environment +filtering, Pi session-file lookup, and process lifecycle code. Its Agent Client +Protocol adapter remains outside this argv package because it owns a protocol +session and process, not a one-shot command shape. Keeping command-shape parsing +in either consumer would create a second grammar that can drift from these +adapters. diff --git a/agentcli/agentcli.go b/agentcli/agentcli.go new file mode 100644 index 0000000..dd739a1 --- /dev/null +++ b/agentcli/agentcli.go @@ -0,0 +1,530 @@ +// Package agentcli validates configuration and builds command lines for +// supported coding-agent CLIs. +// +// The package does not start processes or own terminal, session-storage, or +// persistence concerns. Callers retain those responsibilities and may use the +// returned Stdin value with os/exec when the prompt is delivered over stdin. +// Agent constructors accept configured options separately from prompts and +// reject command shapes that cannot be safely extended for start or resume. +package agentcli + +import ( + "fmt" + "slices" + "strings" +) + +// Name identifies a supported agent CLI family. +type Name string + +const ( + Codex Name = "codex" + Claude Name = "claude" + Pi Name = "pi" + Gemini Name = "gemini" + Copilot Name = "copilot" + OpenCode Name = "opencode" + Cursor Name = "cursor" + Kilo Name = "kilo" + Kiro Name = "kiro" + Droid Name = "droid" +) + +var supportedNames = []Name{Codex, Claude, Gemini, Copilot, OpenCode, Cursor, Kiro, Kilo, Droid, Pi} + +// Names returns the CLI families with concrete adapters. +func Names() []Name { + return slices.Clone(supportedNames) +} + +// New returns the concrete adapter for name. +func New(name Name, command Command) (Adapter, error) { + switch name { + case Codex: + return NewCodex(command) + case Claude: + return NewClaude(command) + case Gemini: + return NewGemini(command) + case Copilot: + return NewCopilot(command) + case OpenCode: + return NewOpenCode(command) + case Cursor: + return NewCursor(command) + case Kiro: + return NewKiro(command) + case Kilo: + return NewKilo(command) + case Droid: + return NewDroid(command) + case Pi: + return NewPi(command) + default: + return nil, fmt.Errorf("unsupported agent CLI %q", name) + } +} + +// Mode selects an interactive terminal session or a one-shot invocation. +type Mode string + +const ( + Interactive Mode = "interactive" + NonInteractive Mode = "noninteractive" +) + +// OutputFormat selects the process output contract. +type OutputFormat string + +const ( + OutputDefault OutputFormat = "" + OutputText OutputFormat = "text" + OutputJSON OutputFormat = "json" + OutputJSONL OutputFormat = "jsonl" +) + +// Prompt is an optional initial or resumed-turn prompt. Its zero value means no +// prompt. Each adapter chooses its CLI's normal argument or stdin transport. +// Files are supported by agents with native file-reference syntax. +type Prompt struct { + Text string + Files []string +} + +// ReasoningLevel is the portable subset of agent reasoning controls. +type ReasoningLevel string + +const ( + ReasoningDefault ReasoningLevel = "" + ReasoningLow ReasoningLevel = "low" + ReasoningMedium ReasoningLevel = "medium" + ReasoningHigh ReasoningLevel = "high" + ReasoningXHigh ReasoningLevel = "xhigh" + ReasoningMaximum ReasoningLevel = "maximum" +) + +// SandboxMode selects restrictions for model-generated commands. +type SandboxMode string + +const ( + SandboxDefault SandboxMode = "" + SandboxReadOnly SandboxMode = "read-only" + SandboxWorkspaceWrite SandboxMode = "workspace-write" + SandboxDangerFullAccess SandboxMode = "danger-full-access" +) + +// ApprovalMode selects how tool approval requests are handled. +type ApprovalMode string + +const ( + ApprovalDefault ApprovalMode = "" + ApprovalOnRequest ApprovalMode = "on-request" + ApprovalNever ApprovalMode = "never" + ApprovalBypass ApprovalMode = "bypass" +) + +// AutonomyLevel selects an agent's native tier of unattended actions. +type AutonomyLevel string + +const ( + AutonomyDefault AutonomyLevel = "" + AutonomyLow AutonomyLevel = "low" + AutonomyMedium AutonomyLevel = "medium" + AutonomyHigh AutonomyLevel = "high" +) + +// JSONSchema configures a CLI's native structured-output mechanism. Codex +// accepts Path, while Claude and Pi accept Inline. Pi additionally requires an +// Extension and OutputPath. +type JSONSchema struct { + Inline string + Path string + OutputPath string + Extension string + Fallback string +} + +// Request describes one agent turn. Its zero value requests an interactive +// invocation using the agent's configured defaults. Discovery controls are +// separate because extensions, skills, hooks, MCP servers, and context files +// are distinct concepts in the supported CLIs. Explicit configured command +// options remain the caller's responsibility. +type Request struct { + Mode Mode + Prompt Prompt + Model string + Provider string + Reasoning ReasoningLevel + OutputFormat OutputFormat + OutputPath string + Schema JSONSchema + Sandbox SandboxMode + Approval ApprovalMode + Autonomy AutonomyLevel + AllowedTools []string + DeniedTools []string + DisableBuiltInTools bool + SkillPaths []string + DisableSkills bool + DisableHooks bool + DisableExtensions bool + DisablePromptTemplates bool + DisableThemes bool + DisableContextFiles bool + DisableBuiltInMCPs bool + DisableUserConfig bool + DisableSessionStorage bool + ConfigOverrides []string +} + +// DisableScope describes what a CLI must turn off to disable hooks. +type DisableScope string + +const ( + DisableUnsupported DisableScope = "" + DisableHooksOnly DisableScope = "hooks-only" + DisableAllCustomizations DisableScope = "all-customizations" + DisableExtensionDiscovery DisableScope = "extension-discovery" +) + +// ToolCapabilities describes native tool-selection flags. +type ToolCapabilities struct { + AllowList bool + DenyList bool + DisableBuiltIns bool +} + +// Capabilities reports which Request fields an adapter can honor. +type Capabilities struct { + Modes []Mode + PromptFiles bool + Resume bool + OutputFormats []OutputFormat + JSONSchemaInline bool + JSONSchemaPath bool + JSONSchemaOutputPath bool + JSONSchemaExtension bool + JSONSchemaFallback bool + Model bool + Provider bool + ReasoningLevels []ReasoningLevel + SandboxModes []SandboxMode + ApprovalModes []ApprovalMode + AutonomyLevels []AutonomyLevel + Tools ToolCapabilities + SkillPaths bool + DisableSkills bool + DisableHooks DisableScope + DisableExtensions bool + DisablePromptTemplates bool + DisableThemes bool + DisableContextFiles bool + DisableBuiltInMCPs bool + DisableUserConfig bool + DisableSessionStorage bool + ConfigOverrides bool +} + +// Invocation is a complete argv plus optional stdin content. Argv includes the +// configured executable and its configured options. +type Invocation struct { + Argv []string + Stdin *string +} + +// Command identifies an agent executable and the options that must be present +// on every invocation. Options must contain flags and their values only; +// prompts, subcommands, session selectors, and -- are rejected by the +// agent-specific constructor. +type Command struct { + Executable string + Options []string +} + +// Adapter builds start and resume invocations for one CLI family. +type Adapter interface { + Name() Name + Capabilities() Capabilities + Start(Request) (Invocation, error) + Resume(sessionID string, request Request) (Invocation, error) +} + +// UnsupportedOptionError reports a requested option that an adapter cannot +// represent without changing its meaning. +type UnsupportedOptionError struct { + Agent Name + Option string + Value string + Mode Mode + Hint string +} + +// InvalidCommandError reports a configured command token that cannot be +// safely combined with commands built by this package. +type InvalidCommandError struct { + Agent Name + Token string + Index int + Reason string + Hint string +} + +func (e *InvalidCommandError) Error() string { + message := fmt.Sprintf("agent %q configured command", e.Agent) + if e.Index >= 0 { + message += fmt.Sprintf(" token %d", e.Index) + } + if e.Token != "" { + message += " " + fmt.Sprintf("%q", e.Token) + } + if e.Reason != "" { + message += ": " + e.Reason + } + if e.Hint != "" { + message += "; " + e.Hint + } + return message +} + +func (e *UnsupportedOptionError) Error() string { + message := fmt.Sprintf("agent %q does not support %s", e.Agent, e.Option) + if e.Value != "" { + message += "=" + fmt.Sprintf("%q", e.Value) + } + if e.Mode != "" { + message += " in " + string(e.Mode) + " mode" + } + if e.Hint != "" { + message += "; " + e.Hint + } + return message +} + +type adapter struct { + name Name + executable string + options []string + configured map[string]bool + capabilities Capabilities + build func(*adapter, string, Request) (Invocation, error) +} + +func newAdapter(name Name, command Command, defaultExecutable string, grammar optionGrammar, capabilities Capabilities, build func(*adapter, string, Request) (Invocation, error)) (Adapter, error) { + executable := command.Executable + if executable == "" { + executable = defaultExecutable + } + if strings.TrimSpace(executable) == "" { + return nil, fmt.Errorf("agent %q requires a configured executable", name) + } + configured, err := validateConfiguredOptions(name, command.Options, grammar) + if err != nil { + return nil, err + } + return &adapter{ + name: name, + executable: executable, + options: slices.Clone(command.Options), + configured: configured, + capabilities: capabilities, + build: build, + }, nil +} + +func (a *adapter) Name() Name { return a.name } +func (a *adapter) Capabilities() Capabilities { return cloneCapabilities(a.capabilities) } +func (a *adapter) Start(request Request) (Invocation, error) { return a.invoke("", request) } +func (a *adapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.invoke(sessionID, request) +} + +func (a *adapter) invoke(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if !slices.Contains(a.capabilities.Modes, mode) { + return Invocation{}, unsupported(a.name, mode, "mode", string(mode), "choose a mode listed by Capabilities") + } + request.Mode = mode + if err := validateSupportedRequest(a.name, mode, request, a.capabilities); err != nil { + return Invocation{}, err + } + for _, values := range []struct { + name string + values []string + }{ + {"allowed tools", request.AllowedTools}, {"denied tools", request.DeniedTools}, + {"skill paths", request.SkillPaths}, {"config overrides", request.ConfigOverrides}, + } { + if err := validateValues(values.name, values.values); err != nil { + return Invocation{}, err + } + } + if err := a.validateConfiguredRequest(request); err != nil { + return Invocation{}, err + } + return a.build(a, sessionID, request) +} + +func (a adapter) base() []string { + return append([]string{a.executable}, a.options...) +} + +func (a adapter) validateConfiguredRequest(request Request) error { + checks := []struct { + set bool + name string + keys []string + }{ + {request.Provider != "", "provider", []string{"provider"}}, {request.Model != "", "model", []string{"model"}}, + {request.Reasoning != ReasoningDefault, "reasoning", []string{"reasoning", "effort", "thinking"}}, + {request.OutputFormat != OutputDefault, "output format", []string{"output-format", "stream", "mode"}}, + {request.Schema.Inline != "" || request.Schema.Path != "", "JSON schema", []string{"extension", "json-schema", "json-output", "json-fallback"}}, + {request.Sandbox != SandboxDefault, "sandbox", []string{"sandbox"}}, + {request.Approval != ApprovalDefault, "approval mode", []string{"approval", "approve-for-me", "approval-bypass", "permission-mode", "approval-mode"}}, + {request.Autonomy != AutonomyDefault, "autonomy", []string{"autonomy"}}, + {len(request.AllowedTools) != 0 || request.DisableBuiltInTools, "allowed tools", []string{"allowed-tools", "tools", "no-tools", "no-builtin-tools"}}, + {len(request.DeniedTools) != 0, "denied tools", []string{"denied-tools", "exclude-tools"}}, + {len(request.SkillPaths) != 0 || request.DisableSkills, "skills", []string{"skill", "no-skills", "disable-skills", "disable-builtin-skills", "safe-mode", "bare"}}, + {request.DisableHooks || request.DisableExtensions, "extensions or hooks", []string{"disable", "hook-trust", "disable-skills", "safe-mode", "bare", "extension", "no-extensions"}}, + {request.DisablePromptTemplates, "prompt templates", []string{"prompt-template", "no-prompt-templates"}}, + {request.DisableThemes, "themes", []string{"theme", "use-theme", "no-themes"}}, + {request.DisableContextFiles, "context files", []string{"no-context-files", "no-custom-instructions"}}, + {request.DisableBuiltInMCPs, "built-in MCP servers", []string{"disable-builtin-mcps"}}, + {request.DisableSessionStorage, "session persistence", []string{"no-session", "no-session-persistence"}}, + } + for _, check := range checks { + for _, key := range check.keys { + if check.set && a.configured[key] { + return &InvalidCommandError{Agent: a.name, Token: key, Index: -1, Reason: "conflicts with the same option requested for this invocation", Hint: "remove the configured option or leave the request setting empty"} + } + } + } + return nil +} + +func invocationMode(mode Mode) (Mode, error) { + if mode == "" { + return Interactive, nil + } + if mode != Interactive && mode != NonInteractive { + return "", fmt.Errorf("unknown agent invocation mode %q", mode) + } + return mode, nil +} + +func validateSessionID(sessionID string) (string, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" || strings.HasPrefix(sessionID, "-") { + return "", fmt.Errorf("agent resume requires a session ID that does not begin with '-'") + } + return sessionID, nil +} + +func validateValues(option string, values []string) error { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("agent %s contains an empty value", option) + } + } + return nil +} + +func joinComma(values []string) string { + return strings.Join(values, ",") +} + +func reasoningValue(level ReasoningLevel) string { + if level == ReasoningMaximum { + return "max" + } + return string(level) +} + +func unsupported(name Name, mode Mode, option, value, hint string) error { + return &UnsupportedOptionError{Agent: name, Option: option, Value: value, Mode: mode, Hint: hint} +} + +func validateSupportedRequest(name Name, mode Mode, request Request, capabilities Capabilities) error { + checks := []struct { + requested bool + supported bool + option string + value string + }{ + {len(request.Prompt.Files) != 0, capabilities.PromptFiles, "prompt files", ""}, + {request.OutputFormat != OutputDefault, slices.Contains(capabilities.OutputFormats, request.OutputFormat), "output format", string(request.OutputFormat)}, + {request.Schema.Inline != "", capabilities.JSONSchemaInline, "inline JSON schema", ""}, + {request.Schema.Path != "", capabilities.JSONSchemaPath, "JSON schema path", ""}, + {request.OutputPath != "" || request.Schema.OutputPath != "", capabilities.JSONSchemaOutputPath, "output path", ""}, + {request.Schema.Extension != "", capabilities.JSONSchemaExtension, "JSON schema extension", ""}, + {request.Schema.Fallback != "", capabilities.JSONSchemaFallback, "JSON schema fallback", request.Schema.Fallback}, + {request.Model != "", capabilities.Model, "model", request.Model}, + {request.Provider != "", capabilities.Provider, "provider", request.Provider}, + {request.Reasoning != ReasoningDefault, slices.Contains(capabilities.ReasoningLevels, request.Reasoning), "reasoning", string(request.Reasoning)}, + {request.Sandbox != SandboxDefault, slices.Contains(capabilities.SandboxModes, request.Sandbox), "sandbox", string(request.Sandbox)}, + {request.Approval != ApprovalDefault, slices.Contains(capabilities.ApprovalModes, request.Approval), "approval mode", string(request.Approval)}, + {request.Autonomy != AutonomyDefault, slices.Contains(capabilities.AutonomyLevels, request.Autonomy), "autonomy", string(request.Autonomy)}, + {len(request.AllowedTools) != 0, capabilities.Tools.AllowList, "allowed tools", ""}, + {len(request.DeniedTools) != 0, capabilities.Tools.DenyList, "denied tools", ""}, + {request.DisableBuiltInTools, capabilities.Tools.DisableBuiltIns, "disable built-in tools", ""}, + {len(request.SkillPaths) != 0, capabilities.SkillPaths, "skill paths", ""}, + {request.DisableSkills, capabilities.DisableSkills, "disable skills", ""}, + {request.DisableHooks, capabilities.DisableHooks != DisableUnsupported, "disable hooks", ""}, + {request.DisableExtensions, capabilities.DisableExtensions, "disable extensions", ""}, + {request.DisablePromptTemplates, capabilities.DisablePromptTemplates, "disable prompt templates", ""}, + {request.DisableThemes, capabilities.DisableThemes, "disable themes", ""}, + {request.DisableContextFiles, capabilities.DisableContextFiles, "disable context files", ""}, + {request.DisableBuiltInMCPs, capabilities.DisableBuiltInMCPs, "disable built-in MCP servers", ""}, + {request.DisableUserConfig, capabilities.DisableUserConfig, "disable user config", ""}, + {request.DisableSessionStorage, capabilities.DisableSessionStorage, "disable session storage", ""}, + {len(request.ConfigOverrides) != 0, capabilities.ConfigOverrides, "config overrides", ""}, + } + for _, check := range checks { + if check.requested && !check.supported { + return unsupported(name, mode, check.option, check.value, "remove this request option or choose an adapter that lists the capability") + } + } + return nil +} + +func appendArgumentPrompt(args []string, prompt Prompt, supportsFiles bool) ([]string, error) { + if len(prompt.Files) != 0 && !supportsFiles { + return nil, fmt.Errorf("agent does not support prompt file arguments") + } + for _, file := range prompt.Files { + if strings.TrimSpace(file) == "" { + return nil, fmt.Errorf("agent prompt file path is empty") + } + args = append(args, "@"+file) + } + if prompt.Text != "" { + args = append(args, prompt.Text) + } + return args, nil +} + +func stdinPrompt(prompt Prompt) (*string, error) { + if len(prompt.Files) != 0 { + return nil, fmt.Errorf("agent does not support prompt file arguments") + } + if prompt.Text == "" { + return nil, nil + } + return new(prompt.Text), nil +} + +func cloneCapabilities(capabilities Capabilities) Capabilities { + capabilities.Modes = slices.Clone(capabilities.Modes) + capabilities.OutputFormats = slices.Clone(capabilities.OutputFormats) + capabilities.ReasoningLevels = slices.Clone(capabilities.ReasoningLevels) + capabilities.SandboxModes = slices.Clone(capabilities.SandboxModes) + capabilities.ApprovalModes = slices.Clone(capabilities.ApprovalModes) + capabilities.AutonomyLevels = slices.Clone(capabilities.AutonomyLevels) + return capabilities +} diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go new file mode 100644 index 0000000..7cdce23 --- /dev/null +++ b/agentcli/agentcli_test.go @@ -0,0 +1,236 @@ +package agentcli_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/kit/agentcli" +) + +func mustAgent(t *testing.T, name agentcli.Name, command agentcli.Command) agentcli.Adapter { + t.Helper() + agent, err := agentcli.New(name, command) + require.NoError(t, err) + return agent +} + +func TestSupportedAgents(t *testing.T) { + t.Parallel() + assert := assert.New(t) + want := []agentcli.Name{agentcli.Codex, agentcli.Claude, agentcli.Gemini, agentcli.Copilot, agentcli.OpenCode, agentcli.Cursor, agentcli.Kiro, agentcli.Kilo, agentcli.Droid, agentcli.Pi} + assert.Equal(want, agentcli.Names()) + for _, name := range want { + assert.Equal(name, mustAgent(t, name, agentcli.Command{}).Name()) + } + names := agentcli.Names() + names[0] = "changed" + assert.Equal(agentcli.Codex, agentcli.Names()[0]) + _, err := agentcli.New("unknown", agentcli.Command{}) + require.Error(t, err) +} + +func TestInvocationContracts(t *testing.T) { + t.Parallel() + prompt := "review this change" + tests := []struct { + name agentcli.Name + session string + request agentcli.Request + want []string + }{ + {agentcli.Codex, "thread-id", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "gpt-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Sandbox: agentcli.SandboxReadOnly, Approval: agentcli.ApprovalNever, DisableSkills: true, DisableHooks: true, DisableUserConfig: true, DisableSessionStorage: true, ConfigOverrides: []string{"feature.test=true"}}, []string{"codex", "exec", "resume", "-c", "feature.test=true", "--ignore-user-config", "-c", "skills.include_instructions=false", "--disable", "hooks", "--ephemeral", "--model", "gpt-test", "-c", `model_reasoning_effort="xhigh"`, "-c", `sandbox_mode="read-only"`, "-c", `approval_policy="never"`, "--json", "thread-id", "-"}}, + {agentcli.Claude, "", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "sonnet", Reasoning: agentcli.ReasoningHigh, OutputFormat: agentcli.OutputJSONL, Schema: agentcli.JSONSchema{Inline: `{"type":"object"}`}, Approval: agentcli.ApprovalNever, AllowedTools: []string{"Read", "Glob"}, DeniedTools: []string{"Bash"}, DisableSkills: true}, []string{"claude", "--print", "--verbose", "--output-format", "stream-json", "--json-schema", `{"type":"object"}`, "--model", "sonnet", "--effort", "high", "--disable-slash-commands", "--permission-mode", "dontAsk", "--allowedTools", "Read,Glob", "--disallowedTools", "Bash"}}, + {agentcli.Gemini, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "gemini-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, []string{"gemini", "--output-format", "stream-json", "--resume", "session-1", "--model", "gemini-test", "--approval-mode", "plan", "--prompt", ""}}, + {agentcli.Copilot, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "copilot-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass, DeniedTools: []string{"write"}, DisableBuiltInMCPs: true, DisableContextFiles: true}, []string{"copilot", "--silent", "--allow-all-tools", "--stream", "off", "--output-format", "json", "--resume=session-1", "--model", "copilot-test", "--reasoning-effort", "xhigh", "--allow-all", "--deny-tool", "write", "--disable-builtin-mcps", "--no-custom-instructions", "--prompt", prompt}}, + {agentcli.OpenCode, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "provider/model", OutputFormat: agentcli.OutputJSONL}, []string{"opencode", "run", "--format", "json", "--session", "session-1", "--model", "provider/model"}}, + {agentcli.Cursor, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "cursor-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, []string{"agent", "--print", "--output-format", "stream-json", "--resume", "session-1", "--model", "cursor-test", "--mode", "plan"}}, + {agentcli.Kiro, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Reasoning: agentcli.ReasoningXHigh, Approval: agentcli.ApprovalBypass}, []string{"kiro-cli", "chat", "--no-interactive", "--resume-id", "session-1", "--effort", "xhigh", "--trust-all-tools", "--", prompt}}, + {agentcli.Kilo, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "provider/model", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass}, []string{"kilo", "run", "--format", "json", "--session", "session-1", "--model", "provider/model", "--auto", "--variant", "xhigh"}}, + {agentcli.Droid, "session-1", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: prompt}, Model: "droid-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Autonomy: agentcli.AutonomyMedium, DeniedTools: []string{"execute-cli"}, DisableSkills: true}, []string{"droid", "exec", "--session-id", "session-1", "--model", "droid-test", "--reasoning-effort", "xhigh", "--auto", "medium", "--disabled-tools", "execute-cli", "--disable-builtin-skills", "--output-format", "stream-json"}}, + {agentcli.Pi, "", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: "--classify", Files: []string{"prompt.md"}}, Provider: "test-provider", Model: "test-model", Reasoning: agentcli.ReasoningMaximum, Schema: agentcli.JSONSchema{Inline: `{"type":"object"}`, Extension: "schema-extension", OutputPath: "result.json"}, DisableBuiltInTools: true, DisableSkills: true, DisableHooks: true, DisablePromptTemplates: true, DisableThemes: true, DisableContextFiles: true, DisableSessionStorage: true}, []string{"pi", "--no-session", "--no-extensions", "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--extension", "schema-extension", "--json-schema", `{"type":"object"}`, "--json-output", "result.json", "--json-fallback", "none", "--print", "--provider", "test-provider", "--model", "test-model", "--thinking", "max", "--", "@prompt.md", "--classify"}}, + } + for _, test := range tests { + t.Run(string(test.name), func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + agent := mustAgent(t, test.name, agentcli.Command{}) + var got agentcli.Invocation + var err error + if test.session == "" { + got, err = agent.Start(test.request) + } else { + got, err = agent.Resume(test.session, test.request) + } + require.NoError(err) + assert.Equal(test.want, got.Argv) + if test.name == agentcli.Codex || test.name == agentcli.Claude || test.name == agentcli.Gemini || test.name == agentcli.OpenCode || test.name == agentcli.Cursor || test.name == agentcli.Kilo || test.name == agentcli.Droid { + require.NotNil(got.Stdin) + assert.Equal(test.request.Prompt.Text, *got.Stdin) + } else { + assert.Nil(got.Stdin) + } + }) + } +} + +func TestInteractiveResumePreservesConfiguredOptions(t *testing.T) { + t.Parallel() + tests := []struct { + name agentcli.Name + command agentcli.Command + want []string + }{ + {agentcli.Codex, agentcli.Command{Executable: "codex-custom", Options: []string{"--profile", "team"}}, []string{"codex-custom", "--profile", "team", "resume", "session-1"}}, + {agentcli.Claude, agentcli.Command{Executable: "claude-custom", Options: []string{"--setting-sources", "project"}}, []string{"claude-custom", "--setting-sources", "project", "--resume", "session-1"}}, + {agentcli.Pi, agentcli.Command{Executable: "pi-custom", Options: []string{"--offline"}}, []string{"pi-custom", "--offline", "--session", "session-1"}}, + } + for _, test := range tests { + got, err := mustAgent(t, test.name, test.command).Resume("session-1", agentcli.Request{}) + require.NoError(t, err) + assert.Equal(t, test.want, got.Argv) + } +} + +func TestUnsupportedRequestsReturnTypedErrors(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + tests := []struct { + name agentcli.Name + request agentcli.Request + option string + }{ + {agentcli.Codex, agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSON}, "output format"}, + {agentcli.Claude, agentcli.Request{Sandbox: agentcli.SandboxReadOnly}, "sandbox"}, + {agentcli.Pi, agentcli.Request{Approval: agentcli.ApprovalNever}, "approval mode"}, + } + for _, test := range tests { + _, err := mustAgent(t, test.name, agentcli.Command{}).Start(test.request) + var unsupported *agentcli.UnsupportedOptionError + require.ErrorAs(err, &unsupported) + assert.Equal(test.option, unsupported.Option) + assert.NotEmpty(unsupported.Hint) + } + _, err := mustAgent(t, agentcli.Codex, agentcli.Command{}).Resume("--last", agentcli.Request{}) + require.Error(err) +} + +func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { + t.Parallel() + assert := assert.New(t) + codex := mustAgent(t, agentcli.Codex, agentcli.Command{}) + got := codex.Capabilities() + assert.True(got.Resume) + assert.True(got.JSONSchemaPath) + assert.False(got.JSONSchemaInline) + assert.Equal(agentcli.DisableHooksOnly, got.DisableHooks) + assert.Equal([]agentcli.ReasoningLevel{agentcli.ReasoningLow, agentcli.ReasoningMedium, agentcli.ReasoningHigh, agentcli.ReasoningXHigh, agentcli.ReasoningMaximum}, got.ReasoningLevels) + got.Modes[0], got.ReasoningLevels[0] = "changed", "changed" + assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) + assert.Equal(agentcli.ReasoningLow, codex.Capabilities().ReasoningLevels[0]) +} + +func TestConfiguredCommandValidation(t *testing.T) { + t.Parallel() + tests := []struct { + name agentcli.Name + options []string + token string + }{ + {agentcli.Codex, []string{"old prompt"}, "old prompt"}, {agentcli.Codex, []string{"exec"}, "exec"}, + {agentcli.Codex, []string{"--profile"}, "--profile"}, {agentcli.Codex, []string{"--profile", "--help"}, "--profile"}, + {agentcli.Codex, []string{"--future-flag"}, "--future-flag"}, {agentcli.Claude, []string{"--resume", "old-session"}, "--resume"}, + {agentcli.Claude, []string{"--settings", "--resume"}, "--settings"}, {agentcli.Claude, []string{"--debug", "api"}, "--debug"}, + {agentcli.Pi, []string{"--session", "old-session"}, "--session"}, {agentcli.Pi, []string{"--", "old prompt"}, "--"}, + {agentcli.Gemini, []string{"--resume", "old-session"}, "--resume"}, {agentcli.Copilot, []string{"--model"}, "--model"}, + {agentcli.OpenCode, []string{"--session", "old-session"}, "--session"}, {agentcli.Cursor, []string{"old prompt"}, "old prompt"}, + {agentcli.Kilo, []string{"--session", "old-session"}, "--session"}, {agentcli.Kiro, []string{"--wrap"}, "--wrap"}, + {agentcli.Droid, []string{"--session-id", "old-session"}, "--session-id"}, + } + for _, test := range tests { + _, err := agentcli.New(test.name, agentcli.Command{Options: test.options}) + var invalid *agentcli.InvalidCommandError + require.ErrorAs(t, err, &invalid) + assert.Equal(t, test.token, invalid.Token) + assert.NotEmpty(t, invalid.Hint) + } +} + +func TestConfiguredOptionsKeepTheirArityAndOrder(t *testing.T) { + t.Parallel() + tests := []struct { + name agentcli.Name + command agentcli.Command + mode agentcli.Mode + want []string + }{ + {agentcli.Codex, agentcli.Command{Executable: "codex-custom", Options: []string{"--profile=team", "-c", "feature.test=true", "--add-dir", "-shared"}}, "", []string{"codex-custom", "--profile=team", "-c", "feature.test=true", "--add-dir", "-shared", "resume", "session-1"}}, + {agentcli.Claude, agentcli.Command{Options: []string{"--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two"}}, "", []string{"claude", "--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two", "--resume", "session-1"}}, + {agentcli.Pi, agentcli.Command{Options: []string{"-ne", "--tui-mode", "fullscreen", "--offline"}}, "", []string{"pi", "-ne", "--tui-mode", "fullscreen", "--offline", "--session", "session-1"}}, + {agentcli.Kiro, agentcli.Command{Executable: "kiro-custom", Options: []string{"--wrap", "never"}}, agentcli.NonInteractive, []string{"kiro-custom", "chat", "--wrap", "never", "--no-interactive", "--resume-id", "session-1"}}, + {agentcli.Droid, agentcli.Command{Executable: "droid-custom", Options: []string{"--append-system-prompt", "review only"}}, agentcli.NonInteractive, []string{"droid-custom", "exec", "--append-system-prompt", "review only", "--session-id", "session-1"}}, + } + for _, test := range tests { + got, err := mustAgent(t, test.name, test.command).Resume("session-1", agentcli.Request{Mode: test.mode}) + require.NoError(t, err) + assert.Equal(t, test.want, got.Argv) + } +} + +func TestConfiguredOptionsConflictWithRequest(t *testing.T) { + t.Parallel() + tests := []struct { + name agentcli.Name + options []string + request agentcli.Request + }{ + {agentcli.Codex, []string{"--model", "configured"}, agentcli.Request{Model: "requested"}}, + {agentcli.Claude, []string{"--effort", "high"}, agentcli.Request{Reasoning: agentcli.ReasoningXHigh}}, + {agentcli.Pi, []string{"--provider", "configured"}, agentcli.Request{Provider: "requested"}}, + {agentcli.Pi, []string{"--extension", "configured"}, agentcli.Request{Mode: agentcli.NonInteractive, Schema: agentcli.JSONSchema{Inline: `{}`, Extension: "requested", OutputPath: "result.json"}}}, + {agentcli.Gemini, []string{"--output-format", "json"}, agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSONL}}, + {agentcli.Copilot, []string{"--disable-builtin-mcps"}, agentcli.Request{Mode: agentcli.NonInteractive, DisableBuiltInMCPs: true}}, + {agentcli.OpenCode, []string{"--model", "configured"}, agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {agentcli.Cursor, []string{"--model", "configured"}, agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {agentcli.Kilo, []string{"--model", "configured"}, agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {agentcli.Kiro, []string{"--effort", "high"}, agentcli.Request{Mode: agentcli.NonInteractive, Reasoning: agentcli.ReasoningXHigh}}, + {agentcli.Droid, []string{"--auto", "low"}, agentcli.Request{Mode: agentcli.NonInteractive, Autonomy: agentcli.AutonomyMedium}}, + } + for _, test := range tests { + _, err := mustAgent(t, test.name, agentcli.Command{Options: test.options}).Start(test.request) + var invalid *agentcli.InvalidCommandError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Reason, "conflicts") + } +} + +func TestXHighAndMaximumStayDistinct(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + tests := []struct { + name agentcli.Name + xhigh, maximum string + }{ + {agentcli.Codex, `model_reasoning_effort="xhigh"`, `model_reasoning_effort="max"`}, + {agentcli.Claude, "xhigh", "max"}, {agentcli.Pi, "xhigh", "max"}, {agentcli.Copilot, "xhigh", "max"}, + {agentcli.Kilo, "xhigh", "max"}, {agentcli.Kiro, "xhigh", "max"}, {agentcli.Droid, "xhigh", "max"}, + } + for _, test := range tests { + agent := mustAgent(t, test.name, agentcli.Command{}) + xhigh, err := agent.Start(agentcli.Request{Mode: agentcli.NonInteractive, Reasoning: agentcli.ReasoningXHigh}) + require.NoError(err) + maximum, err := agent.Start(agentcli.Request{Mode: agentcli.NonInteractive, Reasoning: agentcli.ReasoningMaximum}) + require.NoError(err) + assert.Contains(xhigh.Argv, test.xhigh) + assert.Contains(maximum.Argv, test.maximum) + } +} + +func TestClaudeCanDisableAllBuiltInTools(t *testing.T) { + got, err := mustAgent(t, agentcli.Claude, agentcli.Command{}).Start(agentcli.Request{Mode: agentcli.NonInteractive, DisableBuiltInTools: true}) + require.NoError(t, err) + assert.Equal(t, []string{"claude", "--print", "--tools", ""}, got.Argv) +} diff --git a/agentcli/claude.go b/agentcli/claude.go new file mode 100644 index 0000000..6894083 --- /dev/null +++ b/agentcli/claude.go @@ -0,0 +1,152 @@ +package agentcli + +import ( + "fmt" + "strings" +) + +// NewClaude returns a Claude Code adapter after validating its configured +// options. A zero Command uses "claude". +func NewClaude(command Command) (Adapter, error) { + return newAdapter(Claude, command, "claude", claudeOptionGrammar, claudeCapabilities, buildClaude) +} + +var claudeOptionGrammar = optionGrammar{ + "--add-dir": value("add-dir"), "--agent": value("agent"), "--agents": value("agents"), + "--allow-dangerously-skip-permissions": flag("allow-permission-bypass"), + "--allowedTools": value("allowed-tools"), "--allowed-tools": value("allowed-tools"), + "--append-system-prompt": value("append-system-prompt"), "--autocompact": value("autocompact"), + "--ax-screen-reader": flag("screen-reader"), "--bare": flag("bare"), "--betas": value("betas"), + "--brief": flag("brief"), "--chrome": flag("chrome"), "--dangerously-skip-permissions": flag("approval-bypass"), + "--debug-file": value("debug-file"), "--disable-slash-commands": flag("disable-skills"), + "--disallowedTools": value("denied-tools"), "--disallowed-tools": value("denied-tools"), + "--effort": value("effort"), "--exclude-dynamic-system-prompt-sections": flag("exclude-dynamic-prompt"), + "--fallback-model": value("fallback-model"), "--file": value("file"), + "--forward-subagent-text": flag("forward-subagent-text"), "--ide": flag("ide"), + "--include-hook-events": flag("include-hook-events"), "--include-partial-messages": flag("include-partial-messages"), + "--input-format": value("input-format"), "--json-schema": value("json-schema"), + "--max-budget-usd": value("max-budget-usd"), "--mcp-config": value("mcp-config"), + "--model": value("model"), "-n": value("name"), "--name": value("name"), + "--no-chrome": flag("no-chrome"), "--no-session-persistence": flag("no-session-persistence"), + "--output-format": value("output-format"), "--permission-mode": value("permission-mode"), + "--permission-prompts": value("permission-prompts"), "--plugin-dir": value("plugin-dir"), + "--plugin-url": value("plugin-url"), "--replay-user-messages": flag("replay-user-messages"), + "--restricted": flag("restricted"), "--safe-mode": flag("safe-mode"), + "--setting-sources": value("setting-sources"), "--settings": value("settings"), + "--strict-mcp-config": flag("strict-mcp-config"), "--system-prompt": value("system-prompt"), + "--system-prompt-snapshot": value("system-prompt-snapshot"), "--tools": value("tools"), + "--verbose": flag("verbose"), + "-p": forbidden("print mode is selected by Request.Mode"), "--print": forbidden("print mode is selected by Request.Mode"), + "-c": forbidden("continue selects a session"), "--continue": forbidden("continue selects a session"), + "-r": forbidden("resume selects a session"), "--resume": forbidden("resume selects a session"), + "--session-id": forbidden("session ID is owned by Start or Resume"), + "--fork-session": forbidden("fork changes resume identity"), "--from-pr": forbidden("from-pr selects a session"), + "--teleport": forbidden("teleport selects a session"), "--cloud": forbidden("cloud changes the command target"), + "--environment": forbidden("environment starts a cloud session"), "--bg": forbidden("background process ownership is a caller concern"), + "--background": forbidden("background process ownership is a caller concern"), + "--remote-control": forbidden("remote control changes process ownership"), + "--remote-control-session-name-prefix": forbidden("remote control changes process ownership"), + "--tmux": forbidden("tmux process ownership is a caller concern"), "-w": forbidden("worktree creation is a caller concern"), + "--worktree": forbidden("worktree creation is a caller concern"), + "-d": forbidden("debug has an optional value and is ambiguous in configured options"), + "--debug": forbidden("debug has an optional value and is ambiguous in configured options"), + "--prompt-suggestions": forbidden("prompt-suggestions has an optional value and is ambiguous in configured options"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var claudeCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, + JSONSchemaInline: true, + Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, + Tools: ToolCapabilities{AllowList: true, DenyList: true, DisableBuiltIns: true}, + DisableSkills: true, + DisableHooks: DisableAllCustomizations, + DisableSessionStorage: true, +} + +func buildClaude(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode + args := a.base() + if err := validateClaudeRequest(mode, request); err != nil { + return Invocation{}, err + } + if mode == NonInteractive { + args = append(args, "--print") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + format := string(request.OutputFormat) + if request.OutputFormat == OutputJSONL { + format = "stream-json" + args = append(args, "--verbose") + } + args = append(args, "--output-format", format) + } + if request.Schema.Inline != "" { + args = append(args, "--json-schema", request.Schema.Inline) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "--effort", reasoningValue(request.Reasoning)) + } + if sessionID != "" { + args = append(args, "--resume", sessionID) + } + if request.DisableHooks { + args = append(args, "--safe-mode") + } else if request.DisableSkills { + args = append(args, "--disable-slash-commands") + } + if request.DisableSessionStorage { + args = append(args, "--no-session-persistence") + } + if request.Approval == ApprovalBypass { + args = append(args, "--dangerously-skip-permissions") + } else if request.Approval != ApprovalDefault { + permissionMode := "manual" + if request.Approval == ApprovalNever { + permissionMode = "dontAsk" + } + args = append(args, "--permission-mode", permissionMode) + } + if request.DisableBuiltInTools { + args = append(args, "--tools", "") + } else if len(request.AllowedTools) != 0 { + args = append(args, "--allowedTools", strings.Join(request.AllowedTools, ",")) + } + if len(request.DeniedTools) != 0 { + args = append(args, "--disallowedTools", strings.Join(request.DeniedTools, ",")) + } + var stdin *string + var err error + if mode == Interactive { + args, err = appendArgumentPrompt(args, request.Prompt, false) + } else { + stdin, err = stdinPrompt(request.Prompt) + } + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Claude, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} + +func validateClaudeRequest(mode Mode, request Request) error { + if request.DisableBuiltInTools && len(request.AllowedTools) != 0 { + return fmt.Errorf("agent %q cannot disable built-in tools and set an allowed tool list", Claude) + } + if mode == Interactive { + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + return unsupported(Claude, mode, "output format", string(request.OutputFormat), "use noninteractive mode") + } + if request.Schema.Inline != "" || request.DisableSessionStorage { + return unsupported(Claude, mode, "automation-only output controls", "", "use noninteractive mode") + } + } + return nil +} diff --git a/agentcli/codex.go b/agentcli/codex.go new file mode 100644 index 0000000..e66d441 --- /dev/null +++ b/agentcli/codex.go @@ -0,0 +1,155 @@ +package agentcli + +import ( + "fmt" +) + +// NewCodex returns a Codex CLI adapter after validating its configured global +// options. A zero Command uses "codex". +func NewCodex(command Command) (Adapter, error) { + return newAdapter(Codex, command, "codex", codexOptionGrammar, codexCapabilities, buildCodex) +} + +var codexOptionGrammar = optionGrammar{ + "-c": value("config"), "--config": value("config"), + "--enable": value("enable"), "--disable": value("disable"), + "--remote": value("remote"), "--remote-auth-token-env": value("remote-auth-token-env"), + "--strict-config": flag("strict-config"), + "-i": value("image"), "--image": value("image"), + "-m": value("model"), "--model": value("model"), + "--oss": flag("oss"), "--local-provider": value("local-provider"), + "-p": value("profile"), "--profile": value("profile"), + "-s": value("sandbox"), "--sandbox": value("sandbox"), + "--approve-for-me": flag("approve-for-me"), + "--dangerously-bypass-approvals-and-sandbox": flag("approval-bypass"), + "--dangerously-bypass-hook-trust": flag("hook-trust"), + "-C": value("cd"), "--cd": value("cd"), "--add-dir": value("add-dir"), + "-a": value("approval"), "--ask-for-approval": value("approval"), + "--search": flag("search"), "--no-alt-screen": flag("no-alt-screen"), + "-h": forbidden("help is an action, not a launch option"), + "--help": forbidden("help is an action, not a launch option"), + "-V": forbidden("version is an action, not a launch option"), + "--version": forbidden("version is an action, not a launch option"), +} + +var codexCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + JSONSchemaPath: true, + JSONSchemaOutputPath: true, + Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + SandboxModes: []SandboxMode{SandboxReadOnly, SandboxWorkspaceWrite, SandboxDangerFullAccess}, + ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, + DisableSkills: true, + DisableHooks: DisableHooksOnly, + DisableUserConfig: true, + DisableSessionStorage: true, + ConfigOverrides: true, +} + +func buildCodex(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode + args := a.base() + if err := validateCodexRequest(mode, request); err != nil { + return Invocation{}, err + } + if mode == NonInteractive { + args = append(args, "exec") + } + if sessionID != "" { + args = append(args, "resume") + } + + for _, override := range request.ConfigOverrides { + args = append(args, "-c", override) + } + if request.DisableUserConfig { + args = append(args, "--ignore-user-config") + } + if request.DisableSkills { + args = append(args, "-c", "skills.include_instructions=false") + } + if request.DisableHooks { + args = append(args, "--disable", "hooks") + } + if request.DisableSessionStorage { + args = append(args, "--ephemeral") + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", reasoningValue(request.Reasoning))) + } + if request.Approval == ApprovalBypass { + args = append(args, "--dangerously-bypass-approvals-and-sandbox") + } else { + if request.Sandbox != SandboxDefault { + if mode == NonInteractive && sessionID != "" { + args = append(args, "-c", fmt.Sprintf("sandbox_mode=%q", request.Sandbox)) + } else { + args = append(args, "--sandbox", string(request.Sandbox)) + } + } + if request.Approval != ApprovalDefault { + if mode == NonInteractive && sessionID != "" { + args = append(args, "-c", fmt.Sprintf("approval_policy=%q", request.Approval)) + } else { + args = append(args, "--ask-for-approval", string(request.Approval)) + } + } + } + if request.OutputFormat == OutputJSONL { + args = append(args, "--json") + } + if request.Schema.Path != "" { + args = append(args, "--output-schema", request.Schema.Path) + } + outputPath := request.OutputPath + if request.Schema.OutputPath != "" { + outputPath = request.Schema.OutputPath + } + if outputPath != "" { + args = append(args, "--output-last-message", outputPath) + } + if sessionID != "" { + args = append(args, sessionID) + } + var stdin *string + var err error + if mode == Interactive { + args, err = appendArgumentPrompt(args, request.Prompt, false) + } else { + stdin, err = stdinPrompt(request.Prompt) + if stdin != nil { + args = append(args, "-") + } + } + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Codex, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} + +func validateCodexRequest(mode Mode, request Request) error { + if request.OutputPath != "" && request.Schema.OutputPath != "" && request.OutputPath != request.Schema.OutputPath { + return fmt.Errorf("agent %q received conflicting output paths", Codex) + } + if mode == Interactive { + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + return unsupported(Codex, mode, "output format", string(request.OutputFormat), "use noninteractive mode for machine-readable output") + } + if request.Schema.Path != "" || request.OutputPath != "" || request.Schema.OutputPath != "" { + return unsupported(Codex, mode, "structured output", "", "use noninteractive mode") + } + if request.DisableUserConfig || request.DisableSessionStorage { + return unsupported(Codex, mode, "automation-only config controls", "", "use noninteractive mode") + } + } + if request.Approval == ApprovalBypass && request.Sandbox != SandboxDefault { + return fmt.Errorf("agent %q cannot combine approval bypass with sandbox %q", Codex, request.Sandbox) + } + return nil +} diff --git a/agentcli/command.go b/agentcli/command.go new file mode 100644 index 0000000..8f38279 --- /dev/null +++ b/agentcli/command.go @@ -0,0 +1,68 @@ +package agentcli + +import "strings" + +type optionSpec struct { + canonical string + values int + forbidden string +} + +type optionGrammar map[string]optionSpec + +func flag(canonical string) optionSpec { + return optionSpec{canonical: canonical} +} + +func value(canonical string) optionSpec { + return optionSpec{canonical: canonical, values: 1} +} + +func forbidden(reason string) optionSpec { + return optionSpec{forbidden: reason} +} + +func validateConfiguredOptions(agent Name, options []string, grammar optionGrammar) (map[string]bool, error) { + configured := make(map[string]bool) + for i := 0; i < len(options); i++ { + token := options[i] + if token == "--" { + return nil, invalidCommand(agent, i, token, "prompt boundary is not a configured option", "put prompts in Request.Prompt") + } + name, inline, hasInline := strings.Cut(token, "=") + if !strings.HasPrefix(name, "-") || name == "-" { + return nil, invalidCommand(agent, i, token, "positional operands and subcommands are not allowed", "put prompts in Request.Prompt and let Start or Resume select the command shape") + } + spec, ok := grammar[name] + if !ok { + return nil, invalidCommand(agent, i, token, "unknown or ambiguous option", "use an option documented for this agent, with its value as a separate token or --option=value") + } + if spec.forbidden != "" { + return nil, invalidCommand(agent, i, token, spec.forbidden, "express this through Start or Resume instead") + } + if hasInline { + if spec.values != 1 || inline == "" { + return nil, invalidCommand(agent, i, token, "option does not accept this inline value", "use the option's documented form") + } + configured[spec.canonical] = true + continue + } + if spec.values == 0 { + configured[spec.canonical] = true + continue + } + if i+1 >= len(options) || options[i+1] == "" || options[i+1] == "--" { + return nil, invalidCommand(agent, i, token, "option requires one value", "add the value immediately after the option") + } + if nextName, _, _ := strings.Cut(options[i+1], "="); grammar[nextName].canonical != "" || grammar[nextName].forbidden != "" { + return nil, invalidCommand(agent, i, token, "option requires one value before the next option", "use --option=value when the intended value begins like a documented option") + } + i++ + configured[spec.canonical] = true + } + return configured, nil +} + +func invalidCommand(agent Name, index int, token, reason, hint string) error { + return &InvalidCommandError{Agent: agent, Token: token, Index: index, Reason: reason, Hint: hint} +} diff --git a/agentcli/copilot.go b/agentcli/copilot.go new file mode 100644 index 0000000..d9502cf --- /dev/null +++ b/agentcli/copilot.go @@ -0,0 +1,83 @@ +package agentcli + +import ( + "fmt" +) + +// NewCopilot returns a GitHub Copilot CLI adapter after validating configured +// options. A zero Command uses "copilot". +func NewCopilot(command Command) (Adapter, error) { + return newAdapter(Copilot, command, "copilot", copilotOptionGrammar, copilotCapabilities, buildCopilot) +} + +var copilotOptionGrammar = optionGrammar{ + "--add-dir": value("add-dir"), "--agent": value("agent"), "--additional-mcp-config": value("mcp-config"), + "--attachment": value("attachment"), "-C": value("cd"), "--context": value("context"), + "--disable-builtin-mcps": flag("disable-builtin-mcps"), "--disable-mcp-server": value("disable-mcp-server"), + "--effort": value("reasoning"), "--reasoning-effort": value("reasoning"), "--model": value("model"), + "--allow-all": flag("approval-bypass"), "--yolo": flag("approval-bypass"), "--allow-all-tools": flag("allow-all-tools"), + "--allow-tool": value("allowed-tools"), "--deny-tool": value("denied-tools"), + "--output-format": value("output-format"), "--stream": value("stream"), + "--no-auto-update": flag("no-auto-update"), "--no-color": flag("no-color"), + "--no-custom-instructions": flag("no-custom-instructions"), "--plugin-dir": value("plugin-dir"), + "--screen-reader": flag("screen-reader"), "--secret-env-vars": value("secret-env-vars"), + "--acp": forbidden("ACP server mode is a different protocol"), + "-p": forbidden("prompt belongs in Request.Prompt"), "--prompt": forbidden("prompt belongs in Request.Prompt"), + "-i": forbidden("interactive prompt belongs in Request.Prompt"), "--interactive": forbidden("interactive prompt belongs in Request.Prompt"), + "--continue": forbidden("continue selects a session"), "-r": forbidden("resume selects a session"), + "--resume": forbidden("resume selects a session"), "--session-id": forbidden("session identity belongs in Start or Resume"), + "--connect": forbidden("remote session process ownership is a caller concern"), + "--share": forbidden("sharing is a caller concern"), "--share-gist": forbidden("sharing is a caller concern"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var copilotCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, + Tools: ToolCapabilities{AllowList: true, DenyList: true}, + DisableBuiltInMCPs: true, DisableContextFiles: true, +} + +func buildCopilot(a *adapter, sessionID string, request Request) (Invocation, error) { + // Copilot requires --allow-all-tools in noninteractive prompt mode. Callers + // can still restrict automatic tool use with --deny-tool rules. + args := append(a.base(), "--silent", "--allow-all-tools") + if request.OutputFormat == OutputJSONL { + args = append(args, "--stream", "off", "--output-format", "json") + } + if sessionID != "" { + args = append(args, "--resume="+sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "--reasoning-effort", reasoningValue(request.Reasoning)) + } + if request.Approval == ApprovalBypass { + args = append(args, "--allow-all") + } + for _, tool := range request.AllowedTools { + args = append(args, "--allow-tool", tool) + } + for _, tool := range request.DeniedTools { + args = append(args, "--deny-tool", tool) + } + if request.DisableBuiltInMCPs { + args = append(args, "--disable-builtin-mcps") + } + if request.DisableContextFiles { + args = append(args, "--no-custom-instructions") + } + if len(request.Prompt.Files) != 0 { + return Invocation{}, fmt.Errorf("build %s invocation: agent does not support prompt file arguments", Copilot) + } + if request.Prompt.Text != "" { + args = append(args, "--prompt", request.Prompt.Text) + } + return Invocation{Argv: args}, nil +} diff --git a/agentcli/cursor.go b/agentcli/cursor.go new file mode 100644 index 0000000..f0c40e8 --- /dev/null +++ b/agentcli/cursor.go @@ -0,0 +1,60 @@ +package agentcli + +import "fmt" + +// NewCursor returns a Cursor Agent adapter after validating configured +// options. A zero Command uses "agent". +func NewCursor(command Command) (Adapter, error) { + return newAdapter(Cursor, command, "agent", cursorOptionGrammar, cursorCapabilities, buildCursor) +} + +var cursorOptionGrammar = optionGrammar{ + "--api-key": value("api-key"), "-H": value("header"), "--header": value("header"), + "--stream-partial-output": flag("stream-partial-output"), "--model": value("model"), + "--sandbox": value("sandbox"), "--approve-mcps": flag("approve-mcps"), "--trust": flag("trust"), + "--workspace": value("workspace"), "--add-dir": value("add-dir"), "--plugin-dir": value("plugin-dir"), + "-p": forbidden("print mode is selected by Request.Mode"), "--print": forbidden("print mode is selected by Request.Mode"), + "--output-format": forbidden("output format belongs in Request.OutputFormat"), "--mode": forbidden("execution mode belongs in Request.Approval"), + "--plan": forbidden("execution mode belongs in Request.Approval"), "-f": forbidden("permission bypass belongs in Request.Approval"), + "--force": forbidden("permission bypass belongs in Request.Approval"), "--yolo": forbidden("permission bypass belongs in Request.Approval"), + "--resume": forbidden("resume selects a session"), "--continue": forbidden("continue selects a session"), + "--list-models": forbidden("list-models is an action, not a launch option"), + "-w": forbidden("worktree creation is a caller concern"), "--worktree": forbidden("worktree creation is a caller concern"), + "--worktree-base": forbidden("worktree creation is a caller concern"), "--skip-worktree-setup": forbidden("worktree creation is a caller concern"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var cursorCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, + ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, +} + +func buildCursor(a *adapter, sessionID string, request Request) (Invocation, error) { + args := append(a.base(), "--print") + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + format := string(request.OutputFormat) + if request.OutputFormat == OutputJSONL { + format = "stream-json" + } + args = append(args, "--output-format", format) + } + if sessionID != "" { + args = append(args, "--resume", sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + switch request.Approval { + case ApprovalNever: + args = append(args, "--mode", "plan") + case ApprovalBypass: + args = append(args, "--force") + } + stdin, err := stdinPrompt(request.Prompt) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Cursor, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} diff --git a/agentcli/droid.go b/agentcli/droid.go new file mode 100644 index 0000000..45d44fb --- /dev/null +++ b/agentcli/droid.go @@ -0,0 +1,74 @@ +package agentcli + +import "fmt" + +// NewDroid returns a Factory Droid CLI adapter after validating configured +// global options. A zero Command uses "droid". +func NewDroid(command Command) (Adapter, error) { + return newAdapter(Droid, command, "droid", droidOptionGrammar, droidCapabilities, buildDroid) +} + +var droidOptionGrammar = optionGrammar{ + "--disable-builtin-skills": flag("disable-builtin-skills"), "--append-system-prompt": value("append-system-prompt"), + "--append-system-prompt-file": value("append-system-prompt-file"), + "-m": value("model"), "--model": value("model"), + "--auto": value("autonomy"), "--reasoning-effort": value("reasoning"), + "--restrict-tools": value("allowed-tools"), "--disabled-tools": value("denied-tools"), + "-o": value("output-format"), "--output-format": value("output-format"), + "--skip-permissions-unsafe": flag("approval-bypass"), + "-w": forbidden("worktree creation is a caller concern"), "--worktree": forbidden("worktree creation is a caller concern"), + "--resume": forbidden("resume selects a session"), "-r": forbidden("resume has command-dependent meaning and is ambiguous in configured options"), + "-s": forbidden("session selects a session"), "--session-id": forbidden("session selects a session"), + "--fork": forbidden("fork changes session identity"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var droidCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + AutonomyLevels: []AutonomyLevel{AutonomyLow, AutonomyMedium, AutonomyHigh}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, Tools: ToolCapabilities{AllowList: true, DenyList: true}, + DisableSkills: true, +} + +func buildDroid(a *adapter, sessionID string, request Request) (Invocation, error) { + args := []string{a.executable, "exec"} + args = append(args, a.options...) + if sessionID != "" { + args = append(args, "--session-id", sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "--reasoning-effort", reasoningValue(request.Reasoning)) + } + if request.Autonomy != AutonomyDefault { + args = append(args, "--auto", string(request.Autonomy)) + } + if request.Approval == ApprovalBypass { + args = append(args, "--skip-permissions-unsafe") + } + if len(request.AllowedTools) != 0 { + args = append(args, "--restrict-tools", joinComma(request.AllowedTools)) + } + if len(request.DeniedTools) != 0 { + args = append(args, "--disabled-tools", joinComma(request.DeniedTools)) + } + if request.DisableSkills { + args = append(args, "--disable-builtin-skills") + } + switch request.OutputFormat { + case OutputJSON: + args = append(args, "--output-format", "json") + case OutputJSONL: + args = append(args, "--output-format", "stream-json") + } + stdin, err := stdinPrompt(request.Prompt) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Droid, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} diff --git a/agentcli/example_test.go b/agentcli/example_test.go new file mode 100644 index 0000000..182e877 --- /dev/null +++ b/agentcli/example_test.go @@ -0,0 +1,45 @@ +package agentcli_test + +import ( + "fmt" + + "go.kenn.io/kit/agentcli" +) + +func ExampleAdapter_Resume() { + agent, err := agentcli.NewCodex(agentcli.Command{ + Executable: "codex", + Options: []string{"--profile", "team"}, + }) + if err != nil { + panic(err) + } + + invocation, err := agent.Resume("session-1", agentcli.Request{}) + if err != nil { + panic(err) + } + fmt.Println(invocation.Argv) + // Output: [codex --profile team resume session-1] +} + +func ExampleAdapter_Start() { + agent, err := agentcli.New(agentcli.OpenCode, agentcli.Command{}) + if err != nil { + panic(err) + } + prompt := "review this change" + invocation, err := agent.Start(agentcli.Request{ + Mode: agentcli.NonInteractive, + Prompt: agentcli.Prompt{Text: prompt}, + OutputFormat: agentcli.OutputJSONL, + }) + if err != nil { + panic(err) + } + fmt.Println(invocation.Argv) + fmt.Println(*invocation.Stdin) + // Output: + // [opencode run --format json] + // review this change +} diff --git a/agentcli/gemini.go b/agentcli/gemini.go new file mode 100644 index 0000000..b3e61ff --- /dev/null +++ b/agentcli/gemini.go @@ -0,0 +1,67 @@ +package agentcli + +import "fmt" + +// NewGemini returns a Gemini CLI adapter after validating configured options. +// A zero Command uses "gemini". +func NewGemini(command Command) (Adapter, error) { + return newAdapter(Gemini, command, "gemini", geminiOptionGrammar, geminiCapabilities, buildGemini) +} + +var geminiOptionGrammar = optionGrammar{ + "-d": flag("debug"), "--debug": flag("debug"), "-m": value("model"), "--model": value("model"), + "--skip-trust": flag("skip-trust"), "--policy": value("policy"), "--admin-policy": value("admin-policy"), + "--allowed-mcp-server-names": value("allowed-mcp-server-names"), "--allowed-tools": value("allowed-tools"), + "-e": value("extensions"), "--extensions": value("extensions"), "--include-directories": value("include-directories"), + "--screen-reader": flag("screen-reader"), "--raw-output": flag("raw-output"), "--accept-raw-output-risk": flag("accept-raw-output-risk"), + "-p": forbidden("prompt belongs in Request.Prompt"), "--prompt": forbidden("prompt belongs in Request.Prompt"), + "-i": forbidden("interactive prompt belongs in Request.Prompt"), "--prompt-interactive": forbidden("interactive prompt belongs in Request.Prompt"), + "-r": forbidden("resume selects a session"), "--resume": forbidden("resume selects a session"), + "--session-file": forbidden("session file selects a session"), "--session-id": forbidden("session identity belongs in Start or Resume"), + "--list-sessions": forbidden("list-sessions is an action"), "--delete-session": forbidden("delete-session is an action"), + "--acp": forbidden("ACP server mode is a different protocol"), "--experimental-acp": forbidden("ACP server mode is a different protocol"), + "-w": forbidden("worktree creation is a caller concern"), "--worktree": forbidden("worktree creation is a caller concern"), + "-s": forbidden("sandbox selection belongs in Request.Sandbox"), "--sandbox": forbidden("sandbox selection belongs in Request.Sandbox"), + "-y": flag("approval-bypass"), "--yolo": flag("approval-bypass"), + "--approval-mode": value("approval-mode"), + "-o": value("output-format"), "--output-format": value("output-format"), + "-l": forbidden("list-extensions is an action"), "--list-extensions": forbidden("list-extensions is an action"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var geminiCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, + ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, +} + +func buildGemini(a *adapter, sessionID string, request Request) (Invocation, error) { + args := a.base() + if request.OutputFormat == OutputJSON { + args = append(args, "--output-format", "json") + } + if request.OutputFormat == OutputJSONL { + args = append(args, "--output-format", "stream-json") + } + if sessionID != "" { + args = append(args, "--resume", sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + switch request.Approval { + case ApprovalNever: + args = append(args, "--approval-mode", "plan") + case ApprovalBypass: + args = append(args, "--approval-mode", "yolo") + } + stdin, err := stdinPrompt(request.Prompt) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Gemini, err) + } + if stdin != nil { + args = append(args, "--prompt", "") + } + return Invocation{Argv: args, Stdin: stdin}, nil +} diff --git a/agentcli/kilo.go b/agentcli/kilo.go new file mode 100644 index 0000000..675bff0 --- /dev/null +++ b/agentcli/kilo.go @@ -0,0 +1,53 @@ +package agentcli + +import "fmt" + +// NewKilo returns a Kilo adapter after validating configured global options. +// A zero Command uses "kilo". +func NewKilo(command Command) (Adapter, error) { + return newAdapter(Kilo, command, "kilo", kiloOptionGrammar, kiloCapabilities, buildKilo) +} + +var kiloOptionGrammar = optionGrammar{ + "--print-logs": flag("print-logs"), "--log-level": value("log-level"), + "-m": value("model"), "--model": value("model"), "--agent": value("agent"), + "-c": forbidden("continue selects a session"), "--continue": forbidden("continue selects a session"), + "-s": forbidden("session selects a session"), "--session": forbidden("session selects a session"), + "--fork": forbidden("fork changes session identity"), "--cloud-fork": forbidden("cloud-fork changes session identity"), + "--prompt": forbidden("prompt belongs in Request.Prompt"), "--auto": forbidden("automatic permission approval belongs in Request.Approval"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var kiloCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, +} + +func buildKilo(a *adapter, sessionID string, request Request) (Invocation, error) { + args := append(a.base(), "run") + if request.OutputFormat == OutputJSONL { + args = append(args, "--format", "json") + } + if sessionID != "" { + args = append(args, "--session", sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Approval == ApprovalBypass { + args = append(args, "--auto") + } + if variant := reasoningValue(request.Reasoning); variant != "" { + args = append(args, "--variant", variant) + } + stdin, err := stdinPrompt(request.Prompt) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Kilo, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} diff --git a/agentcli/kiro.go b/agentcli/kiro.go new file mode 100644 index 0000000..017b6e2 --- /dev/null +++ b/agentcli/kiro.go @@ -0,0 +1,59 @@ +package agentcli + +import "fmt" + +// NewKiro returns a Kiro CLI adapter after validating configured global +// options. A zero Command uses "kiro-cli". +func NewKiro(command Command) (Adapter, error) { + return newAdapter(Kiro, command, "kiro-cli", kiroOptionGrammar, kiroCapabilities, buildKiro) +} + +var kiroOptionGrammar = optionGrammar{ + "--verbose": flag("verbose"), "-v": flag("verbose"), "--agent": value("agent"), + "--require-mcp-startup": flag("require-mcp-startup"), "--wrap": value("wrap"), + "--no-interactive": forbidden("mode belongs in Request.Mode"), + "--resume": forbidden("resume selects a session"), "-r": forbidden("resume selects a session"), + "--resume-picker": forbidden("resume-picker selects a session"), "--resume-id": forbidden("resume-id selects a session"), + "--list-sessions": forbidden("list-sessions is an action"), "--delete-session": forbidden("delete-session is an action"), + "--list-models": forbidden("list-models is an action"), + "--trust-all-tools": flag("approval-bypass"), "--trust-tools": value("allowed-tools"), + "--effort": value("reasoning"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-V": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var kiroCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText}, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, + Tools: ToolCapabilities{AllowList: true}, +} + +func buildKiro(a *adapter, sessionID string, request Request) (Invocation, error) { + args := []string{a.executable, "chat"} + args = append(args, a.options...) + args = append(args, "--no-interactive") + if sessionID != "" { + args = append(args, "--resume-id", sessionID) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "--effort", reasoningValue(request.Reasoning)) + } + if request.Approval == ApprovalBypass { + args = append(args, "--trust-all-tools") + } + if len(request.AllowedTools) != 0 { + args = append(args, "--trust-tools", joinComma(request.AllowedTools)) + } + if request.Prompt.Text != "" || len(request.Prompt.Files) != 0 { + args = append(args, "--") + promptArgs, err := appendArgumentPrompt(args, request.Prompt, false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Kiro, err) + } + return Invocation{Argv: promptArgs}, nil + } + return Invocation{Argv: args}, nil +} diff --git a/agentcli/opencode.go b/agentcli/opencode.go new file mode 100644 index 0000000..90ec81e --- /dev/null +++ b/agentcli/opencode.go @@ -0,0 +1,46 @@ +package agentcli + +import "fmt" + +// NewOpenCode returns an OpenCode adapter after validating configured global +// options. A zero Command uses "opencode". +func NewOpenCode(command Command) (Adapter, error) { + return newAdapter(OpenCode, command, "opencode", openCodeOptionGrammar, openCodeCapabilities, buildOpenCode) +} + +var openCodeOptionGrammar = optionGrammar{ + "--print-logs": flag("print-logs"), "--log-level": value("log-level"), + "--pure": flag("pure"), "-m": value("model"), "--model": value("model"), + "--agent": value("agent"), + "-c": forbidden("continue selects a session"), "--continue": forbidden("continue selects a session"), + "-s": forbidden("session selects a session"), "--session": forbidden("session selects a session"), + "--fork": forbidden("fork changes session identity"), "--prompt": forbidden("prompt belongs in Request.Prompt"), + "--auto": forbidden("automatic permission approval belongs in Request.Approval"), + "-h": forbidden("help is an action, not a launch option"), "--help": forbidden("help is an action, not a launch option"), + "-v": forbidden("version is an action, not a launch option"), "--version": forbidden("version is an action, not a launch option"), +} + +var openCodeCapabilities = Capabilities{ + Modes: []Mode{NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + Model: true, +} + +func buildOpenCode(a *adapter, sessionID string, request Request) (Invocation, error) { + args := append(a.base(), "run") + if request.OutputFormat == OutputJSONL { + args = append(args, "--format", "json") + } + if sessionID != "" { + args = append(args, "--session", sessionID) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + stdin, err := stdinPrompt(request.Prompt) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", OpenCode, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} diff --git a/agentcli/pi.go b/agentcli/pi.go new file mode 100644 index 0000000..8b1e239 --- /dev/null +++ b/agentcli/pi.go @@ -0,0 +1,165 @@ +package agentcli + +import ( + "fmt" + "strings" +) + +// NewPi returns a Pi adapter after validating its configured options. A zero +// Command uses "pi". +func NewPi(command Command) (Adapter, error) { + return newAdapter(Pi, command, "pi", piOptionGrammar, piCapabilities, buildPi) +} + +var piOptionGrammar = optionGrammar{ + "--provider": value("provider"), "--model": value("model"), "--api-key": value("api-key"), + "--system-prompt": value("system-prompt"), "--append-system-prompt": value("append-system-prompt"), + "--mode": value("mode"), "--session-dir": value("session-dir"), "--no-session": flag("no-session"), + "--name": value("name"), "-n": value("name"), "--models": value("models"), + "--no-tools": flag("no-tools"), "-nt": flag("no-tools"), + "--no-builtin-tools": flag("no-builtin-tools"), "-nbt": flag("no-builtin-tools"), + "--tools": value("tools"), "-t": value("tools"), "--exclude-tools": value("exclude-tools"), "-xt": value("exclude-tools"), + "--thinking": value("thinking"), "--extension": value("extension"), "-e": value("extension"), + "--no-extensions": flag("no-extensions"), "-ne": flag("no-extensions"), + "--skill": value("skill"), "--no-skills": flag("no-skills"), "-ns": flag("no-skills"), + "--prompt-template": value("prompt-template"), "--no-prompt-templates": flag("no-prompt-templates"), "-np": flag("no-prompt-templates"), + "--theme": value("theme"), "--use-theme": value("use-theme"), "--no-themes": flag("no-themes"), + "--no-context-files": flag("no-context-files"), "-nc": flag("no-context-files"), + "--verbose": flag("verbose"), "--tui-mode": value("tui-mode"), "--approve": flag("approve"), "-a": flag("approve"), + "--no-approve": flag("no-approve"), "-na": flag("no-approve"), "--offline": flag("offline"), + "--mcp-config": value("mcp-config"), "--json-schema": value("json-schema"), + "--json-output": value("json-output"), "--json-fallback": value("json-fallback"), + "--fff-mode": value("fff-mode"), "--fff-frecency-db": value("fff-frecency-db"), + "--fff-history-db": value("fff-history-db"), "--fff-enable-root-scan": flag("fff-enable-root-scan"), + "--fff-enable-home-scan": flag("fff-enable-home-scan"), + "--print": forbidden("print mode is selected by Request.Mode"), "-p": forbidden("print mode is selected by Request.Mode"), + "--continue": forbidden("continue selects a session"), "-c": forbidden("continue selects a session"), + "--resume": forbidden("resume selects a session"), "-r": forbidden("resume selects a session"), + "--session": forbidden("session selector is owned by Resume"), "--session-id": forbidden("session identity is owned by Start or Resume"), + "--fork": forbidden("fork changes session identity"), "--export": forbidden("export is an action, not a launch option"), + "--list-models": forbidden("list-models has an optional value and is an action"), + "--help": forbidden("help is an action, not a launch option"), "-h": forbidden("help is an action, not a launch option"), + "--version": forbidden("version is an action, not a launch option"), "-v": forbidden("version is an action, not a launch option"), +} + +var piCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + PromptFiles: true, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + JSONSchemaInline: true, + JSONSchemaOutputPath: true, + JSONSchemaExtension: true, + JSONSchemaFallback: true, + Model: true, + Provider: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + Tools: ToolCapabilities{AllowList: true, DenyList: true, DisableBuiltIns: true}, + SkillPaths: true, + DisableSkills: true, + DisableHooks: DisableExtensionDiscovery, + DisableExtensions: true, + DisablePromptTemplates: true, + DisableThemes: true, + DisableContextFiles: true, + DisableSessionStorage: true, +} + +func buildPi(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode + args := a.base() + if err := validatePiRequest(mode, request); err != nil { + return Invocation{}, err + } + if request.DisableSessionStorage { + args = append(args, "--no-session") + } + if request.DisableHooks || request.DisableExtensions { + args = append(args, "--no-extensions") + } + if request.DisableBuiltInTools { + args = append(args, "--no-builtin-tools") + } + if request.DisableSkills { + args = append(args, "--no-skills") + } + if request.DisablePromptTemplates { + args = append(args, "--no-prompt-templates") + } + if request.DisableThemes { + args = append(args, "--no-themes") + } + if request.DisableContextFiles { + args = append(args, "--no-context-files") + } + for _, path := range request.SkillPaths { + args = append(args, "--skill", path) + } + if request.Schema.Inline != "" { + args = append(args, + "--extension", request.Schema.Extension, + "--json-schema", request.Schema.Inline, + "--json-output", request.Schema.OutputPath, + ) + fallback := request.Schema.Fallback + if fallback == "" { + fallback = "none" + } + args = append(args, "--json-fallback", fallback) + } + if mode == NonInteractive { + args = append(args, "--print") + } + if request.OutputFormat == OutputJSONL { + args = append(args, "--mode", "json") + } + if sessionID != "" { + args = append(args, "--session", sessionID) + } + if request.Provider != "" { + args = append(args, "--provider", request.Provider) + } + if request.Model != "" { + args = append(args, "--model", request.Model) + } + if request.Reasoning != ReasoningDefault { + args = append(args, "--thinking", reasoningValue(request.Reasoning)) + } + if len(request.AllowedTools) != 0 { + args = append(args, "--tools", strings.Join(request.AllowedTools, ",")) + } + if len(request.DeniedTools) != 0 { + args = append(args, "--exclude-tools", strings.Join(request.DeniedTools, ",")) + } + if request.Prompt.Text != "" || len(request.Prompt.Files) != 0 { + args = append(args, "--") + } + args, err := appendArgumentPrompt(args, request.Prompt, true) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Pi, err) + } + return Invocation{Argv: args}, nil +} + +func validatePiRequest(mode Mode, request Request) error { + if request.OutputPath != "" || request.Schema.Path != "" { + return unsupported(Pi, mode, "output path", "", "Pi output files require its JSON-schema extension") + } + if request.Schema.Inline != "" { + if mode != NonInteractive { + return unsupported(Pi, mode, "JSON schema", "", "use noninteractive mode") + } + if strings.TrimSpace(request.Schema.Extension) == "" || strings.TrimSpace(request.Schema.OutputPath) == "" { + return fmt.Errorf("agent %q JSON schema requires Schema.Extension and Schema.OutputPath", Pi) + } + if request.Schema.Fallback != "" && request.Schema.Fallback != "none" && request.Schema.Fallback != "force" && request.Schema.Fallback != "best-effort" { + return unsupported(Pi, mode, "JSON fallback", request.Schema.Fallback, "request none, force, or best-effort") + } + } else if request.Schema.Extension != "" || request.Schema.OutputPath != "" || request.Schema.Fallback != "" { + return fmt.Errorf("agent %q schema extension options require Schema.Inline", Pi) + } + if mode == Interactive && request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + return unsupported(Pi, mode, "output format", string(request.OutputFormat), "use noninteractive mode for jsonl") + } + return nil +} diff --git a/git/cmd/gitcmd_test.go b/git/cmd/gitcmd_test.go index a5fda2f..29eae8b 100644 --- a/git/cmd/gitcmd_test.go +++ b/git/cmd/gitcmd_test.go @@ -264,7 +264,9 @@ func TestReadSafeDirectoriesBoundsProbeRuntime(t *testing.T) { got := readSafeDirectories(context.Background(), env, "") Assert.Empty(t, got) - Assert.Less(t, time.Since(start), time.Second, "safe.directory probes are best-effort and must not stall git commands") + // Windows process teardown can take more than a second on a busy CI runner. + // This remains well below the sleeping fixture's ten-second runtime. + Assert.Less(t, time.Since(start), 5*time.Second, "safe.directory probes are best-effort and must not stall git commands") } func TestReadSafeDirectoriesConditionalInclude(t *testing.T) {