From edbab6cd2c508741e79f35f9f5979c1a94d86504 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 00:22:04 -0400 Subject: [PATCH 1/9] Add shared agent CLI invocation builders Forge and RoboRev currently encode overlapping agent command rules in their own packages. That makes resume behavior and automation flags drift as the CLIs change. Give both callers one command-building interface with explicit capabilities and typed errors. Keep process execution, terminal ownership, stream parsing, and session persistence in the calling applications. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/README.md | 69 +++++++++ agentcli/agentcli.go | 319 ++++++++++++++++++++++++++++++++++++++ agentcli/agentcli_test.go | 244 +++++++++++++++++++++++++++++ agentcli/claude.go | 175 +++++++++++++++++++++ agentcli/codex.go | 198 +++++++++++++++++++++++ agentcli/pi.go | 195 +++++++++++++++++++++++ 6 files changed, 1200 insertions(+) create mode 100644 agentcli/README.md create mode 100644 agentcli/agentcli.go create mode 100644 agentcli/agentcli_test.go create mode 100644 agentcli/claude.go create mode 100644 agentcli/codex.go create mode 100644 agentcli/pi.go diff --git a/agentcli/README.md b/agentcli/README.md new file mode 100644 index 0000000..6fbc146 --- /dev/null +++ b/agentcli/README.md @@ -0,0 +1,69 @@ +# Agent CLI command construction + +`agentcli` builds argument vectors for coding-agent CLIs. It owns agent-specific +flag placement, 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. + +## Consumer examples + +Forge can preserve a configured interactive command and resume without sending +the original prompt again: + +```go +agent := agentcli.NewCodex([]string{"codex", "--profile", "forge"}) +invocation, err := agent.Resume(sessionID, agentcli.Request{}) +// invocation.Argv: codex --profile forge resume +``` + +RoboRev can request a noninteractive event stream with explicit safety and +prompt transport: + +```go +prompt := agentcli.Prompt{Source: agentcli.PromptStdin, Text: reviewPrompt} +invocation, err := agentcli.NewCodex(configuredCommand).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. + +## Capability matrix + +| Capability | Codex | Claude Code | Pi | +| --- | --- | --- | --- | +| Interactive and noninteractive | yes | yes | yes | +| Resume by caller-supplied identity | `resume ID` or `exec resume ID` | `--resume ID` | `--session ID` | +| Output | text, JSONL | text, JSON, stream JSONL | text, JSONL | +| JSON Schema | schema file | inline schema | inline schema through an explicit extension and output file | +| Model and reasoning | yes | yes | yes | +| Provider | configured options | configured options | `--provider` | +| Sandbox | read-only, workspace-write, full access | no filesystem sandbox flag | no sandbox flag | +| Approval policy | on-request, never, bypass | manual, dontAsk, bypass | no tool-approval policy | +| Tool lists | no | allow, deny, disable built-ins | allow, deny, disable built-ins | +| Skill paths | no | no | yes | +| Disable skills | suppress skill instructions | disable slash commands | disable discovery | +| Disable hooks | hooks feature only | safe mode disables all customizations | disable extension discovery | +| Disable session storage | noninteractive | noninteractive | yes | +| Disable user config | noninteractive | configured options | configured options | +| Config overrides | `-c` | configured options | configured options | + +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) +- [Pi README](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) + +The first consumer migrations should replace Forge's interactive resume switch +and RoboRev's Codex, Claude, and Pi argument builders. RoboRev should keep its +stream parsing, capability probes, environment filtering, Pi session-file +lookup, and process lifecycle code. Adapters for its other agent families can +follow without widening this package into a process runner. diff --git a/agentcli/agentcli.go b/agentcli/agentcli.go new file mode 100644 index 0000000..fd2dca0 --- /dev/null +++ b/agentcli/agentcli.go @@ -0,0 +1,319 @@ +// Package agentcli 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. +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" +) + +// 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" +) + +// PromptSource describes how a prompt reaches the agent process. +type PromptSource string + +const ( + PromptNone PromptSource = "" + PromptArgument PromptSource = "argument" + PromptStdin PromptSource = "stdin" +) + +// Prompt is an optional initial or resumed-turn prompt. Files are supported by +// agents whose command line has a native file-reference syntax. +type Prompt struct { + Source PromptSource + 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" + 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" +) + +// 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. DisableExtensions, +// DisableSkills, and DisableHooks control discovery; 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 + AllowedTools []string + DeniedTools []string + DisableBuiltInTools bool + SkillPaths []string + DisableSkills bool + DisableHooks bool + DisableExtensions bool + DisablePromptTemplates bool + DisableThemes bool + DisableContextFiles bool + DisableUserConfig bool + DisableSessionStorage bool + ConfigOverrides []string +} + +// DisableScope describes what a CLI must turn off to disable hooks. +type DisableScope string + +const ( + DisableUnsupported DisableScope = "unsupported" + 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 + Resume bool + OutputFormats []OutputFormat + JSONSchemaInline bool + JSONSchemaPath bool + JSONSchemaOutputPath bool + Model bool + Provider bool + Reasoning bool + SandboxModes []SandboxMode + ApprovalModes []ApprovalMode + Tools ToolCapabilities + SkillPaths bool + DisableSkills bool + DisableHooks DisableScope + DisableExtensions bool + DisablePromptTemplates bool + DisableThemes bool + DisableContextFiles 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 +} + +// 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 +} + +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 + command []string +} + +func newAdapter(name Name, command []string, defaultCommand string) adapter { + if len(command) == 0 { + command = []string{defaultCommand} + } + return adapter{name: name, command: slices.Clone(command)} +} + +func (a adapter) Name() Name { + return a.name +} + +func (a adapter) base() ([]string, error) { + if len(a.command) == 0 || strings.TrimSpace(a.command[0]) == "" { + return nil, fmt.Errorf("agent %q requires a configured executable", a.name) + } + return slices.Clone(a.command), 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 validatePrompt(prompt Prompt) error { + switch prompt.Source { + case PromptNone: + if prompt.Text != "" || len(prompt.Files) != 0 { + return fmt.Errorf("agent prompt source is required when prompt content is set") + } + case PromptArgument, PromptStdin: + default: + return fmt.Errorf("unknown agent prompt source %q", prompt.Source) + } + if prompt.Source == PromptStdin && len(prompt.Files) != 0 { + return fmt.Errorf("agent prompt files require argument delivery") + } + return 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 unsupported(name Name, mode Mode, option, value, hint string) error { + return &UnsupportedOptionError{Agent: name, Option: option, Value: value, Mode: mode, Hint: hint} +} + +func appendPrompt(args []string, prompt Prompt, stdinMarker string, supportsFiles bool) ([]string, *string, error) { + if err := validatePrompt(prompt); err != nil { + return nil, nil, err + } + if len(prompt.Files) != 0 && !supportsFiles { + return nil, nil, fmt.Errorf("agent does not support prompt file arguments") + } + switch prompt.Source { + case PromptNone: + return args, nil, nil + case PromptStdin: + if stdinMarker != "" { + args = append(args, stdinMarker) + } + return args, new(prompt.Text), nil + case PromptArgument: + for _, file := range prompt.Files { + if strings.TrimSpace(file) == "" { + return nil, nil, fmt.Errorf("agent prompt file path is empty") + } + args = append(args, "@"+file) + } + if prompt.Text != "" { + args = append(args, prompt.Text) + } + return args, nil, nil + default: + panic("prompt source validated above") + } +} + +func cloneCapabilities(capabilities Capabilities) Capabilities { + capabilities.Modes = slices.Clone(capabilities.Modes) + capabilities.OutputFormats = slices.Clone(capabilities.OutputFormats) + capabilities.SandboxModes = slices.Clone(capabilities.SandboxModes) + capabilities.ApprovalModes = slices.Clone(capabilities.ApprovalModes) + return capabilities +} diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go new file mode 100644 index 0000000..bd50840 --- /dev/null +++ b/agentcli/agentcli_test.go @@ -0,0 +1,244 @@ +package agentcli_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/kit/agentcli" +) + +func TestInteractiveResumePreservesConfiguredCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent agentcli.Adapter + expected []string + }{ + { + name: "codex subcommand", + agent: agentcli.NewCodex([]string{"codex-custom", "--profile", "team"}), + expected: []string{"codex-custom", "--profile", "team", "resume", "session-1"}, + }, + { + name: "claude flag", + agent: agentcli.NewClaude([]string{"claude-custom", "--setting-sources", "project"}), + expected: []string{"claude-custom", "--setting-sources", "project", "--resume", "session-1"}, + }, + { + name: "pi flag", + agent: agentcli.NewPi([]string{"pi-custom", "--offline"}), + expected: []string{"pi-custom", "--offline", "--session", "session-1"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + invocation, err := test.agent.Resume("session-1", agentcli.Request{}) + require.NoError(err) + assert.Equal(test.expected, invocation.Argv) + assert.Nil(invocation.Stdin) + }) + } +} + +func TestCodexNonInteractiveResume(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + prompt := "continue from the saved state" + invocation, err := agentcli.NewCodex(nil).Resume("thread-id", agentcli.Request{ + Mode: agentcli.NonInteractive, + Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, + Model: "gpt-test", + Reasoning: agentcli.ReasoningMaximum, + OutputFormat: agentcli.OutputJSONL, + Sandbox: agentcli.SandboxReadOnly, + Approval: agentcli.ApprovalNever, + DisableSkills: true, + DisableHooks: true, + DisableUserConfig: true, + DisableSessionStorage: true, + ConfigOverrides: []string{"feature.test=true"}, + }) + require.NoError(err) + assert.Equal([]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", "-", + }, invocation.Argv) + require.NotNil(invocation.Stdin) + assert.Equal(prompt, *invocation.Stdin) +} + +func TestClaudeNonInteractiveStructuredOutput(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + invocation, err := agentcli.NewClaude(nil).Start(agentcli.Request{ + Mode: agentcli.NonInteractive, + Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "classify"}, + 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, + }) + require.NoError(err) + assert.Equal([]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", + }, invocation.Argv) + require.NotNil(invocation.Stdin) + assert.Equal("classify", *invocation.Stdin) +} + +func TestClaudeCanDisableAllBuiltInTools(t *testing.T) { + t.Parallel() + + invocation, err := agentcli.NewClaude(nil).Start(agentcli.Request{ + Mode: agentcli.NonInteractive, + DisableBuiltInTools: true, + }) + require.NoError(t, err) + assert.Equal(t, []string{"claude", "--print", "--tools", ""}, invocation.Argv) +} + +func TestPiSchemaInvocation(t *testing.T) { + t.Parallel() + + invocation, err := agentcli.NewPi(nil).Start(agentcli.Request{ + Mode: agentcli.NonInteractive, + Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, 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, + }) + require.NoError(t, err) + assert.Equal(t, []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", "high", + "@prompt.md", "classify", + }, invocation.Argv) + assert.Nil(t, invocation.Stdin) +} + +func TestUnsupportedOptionsReturnTypedErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent agentcli.Adapter + request agentcli.Request + option string + }{ + { + name: "codex single JSON document", + agent: agentcli.NewCodex(nil), + request: agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSON}, + option: "output format", + }, + { + name: "claude sandbox", + agent: agentcli.NewClaude(nil), + request: agentcli.Request{Sandbox: agentcli.SandboxReadOnly}, + option: "sandbox", + }, + { + name: "pi approval policy", + agent: agentcli.NewPi(nil), + request: agentcli.Request{Approval: agentcli.ApprovalNever}, + option: "approval mode", + }, + { + name: "interactive stdin prompt", + agent: agentcli.NewCodex(nil), + request: agentcli.Request{Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "prompt"}}, + option: "stdin prompt", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + _, err := test.agent.Start(test.request) + var unsupported *agentcli.UnsupportedOptionError + require.ErrorAs(err, &unsupported) + assert.Equal(test.agent.Name(), unsupported.Agent) + assert.Equal(test.option, unsupported.Option) + assert.NotEmpty(unsupported.Hint) + }) + } +} + +func TestResumeRejectsOptionShapedSessionID(t *testing.T) { + t.Parallel() + + _, err := agentcli.NewCodex(nil).Resume("--last", agentcli.Request{}) + require.Error(t, err) + var unsupported *agentcli.UnsupportedOptionError + assert.NotErrorAs(t, err, &unsupported) +} + +func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + codex := agentcli.NewCodex(nil) + capabilities := codex.Capabilities() + assert.True(capabilities.Resume) + assert.True(capabilities.JSONSchemaPath) + assert.False(capabilities.JSONSchemaInline) + assert.Equal(agentcli.DisableHooksOnly, capabilities.DisableHooks) + + capabilities.Modes[0] = "changed" + assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) +} diff --git a/agentcli/claude.go b/agentcli/claude.go new file mode 100644 index 0000000..ed8018c --- /dev/null +++ b/agentcli/claude.go @@ -0,0 +1,175 @@ +package agentcli + +import ( + "fmt" + "strings" +) + +// NewClaude returns a Claude Code adapter. Command may include configured +// options; an empty command uses "claude". +func NewClaude(command []string) Adapter { + return &claudeAdapter{adapter: newAdapter(Claude, command, "claude")} +} + +type claudeAdapter struct { + adapter +} + +var claudeCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, + JSONSchemaInline: true, + Model: true, + Reasoning: true, + ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, + Tools: ToolCapabilities{AllowList: true, DenyList: true, DisableBuiltIns: true}, + DisableSkills: true, + DisableHooks: DisableAllCustomizations, + DisableSessionStorage: true, +} + +func (a *claudeAdapter) Capabilities() Capabilities { + return cloneCapabilities(claudeCapabilities) +} + +func (a *claudeAdapter) Start(request Request) (Invocation, error) { + return a.build("", request) +} + +func (a *claudeAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} + +func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + args, err := a.base() + if err != nil { + return Invocation{}, err + } + 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", claudeReasoning(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, ",")) + } + args, stdin, err := appendPrompt(args, request.Prompt, "", false) + 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 err := validateValues("allowed tools", request.AllowedTools); err != nil { + return err + } + if err := validateValues("denied tools", request.DeniedTools); err != nil { + return err + } + if mode == Interactive && request.Prompt.Source == PromptStdin { + return unsupported(Claude, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") + } + if request.Provider != "" { + return unsupported(Claude, mode, "provider", request.Provider, "configure the provider outside Claude's argv") + } + if request.Sandbox != SandboxDefault { + return unsupported(Claude, mode, "sandbox", string(request.Sandbox), "Claude permission modes do not provide a filesystem sandbox") + } + 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 len(request.SkillPaths) != 0 { + return unsupported(Claude, mode, "skill paths", "", "install skills through Claude configuration") + } + if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles { + return unsupported(Claude, mode, "Pi customization controls", "", "these controls are specific to Pi") + } + if request.DisableUserConfig || len(request.ConfigOverrides) != 0 { + return unsupported(Claude, mode, "Codex config controls", "", "use configured Claude options such as --settings") + } + if request.Schema.Path != "" || request.Schema.OutputPath != "" || request.Schema.Extension != "" || request.Schema.Fallback != "" || request.OutputPath != "" { + return unsupported(Claude, mode, "schema file or output path", "", "Claude accepts an inline schema and writes structured output to stdout") + } + 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") + } + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { + return unsupported(Claude, mode, "output format", string(request.OutputFormat), "request text, json, or jsonl") + } + if request.Reasoning != ReasoningDefault && claudeReasoning(request.Reasoning) == "" { + return unsupported(Claude, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalOnRequest && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { + return unsupported(Claude, mode, "approval mode", string(request.Approval), "request on-request, never, or bypass") + } + return nil +} + +func claudeReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh: + return string(level) + case ReasoningMaximum: + return "max" + default: + return "" + } +} diff --git a/agentcli/codex.go b/agentcli/codex.go new file mode 100644 index 0000000..8bed111 --- /dev/null +++ b/agentcli/codex.go @@ -0,0 +1,198 @@ +package agentcli + +import ( + "fmt" +) + +// NewCodex returns a Codex CLI adapter. Command may include configured global +// options; an empty command uses "codex". +func NewCodex(command []string) Adapter { + return &codexAdapter{adapter: newAdapter(Codex, command, "codex")} +} + +type codexAdapter struct { + adapter +} + +var codexCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + JSONSchemaPath: true, + JSONSchemaOutputPath: true, + Model: true, + Reasoning: true, + SandboxModes: []SandboxMode{SandboxReadOnly, SandboxWorkspaceWrite, SandboxDangerFullAccess}, + ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, + DisableSkills: true, + DisableHooks: DisableHooksOnly, + DisableUserConfig: true, + DisableSessionStorage: true, + ConfigOverrides: true, +} + +func (a *codexAdapter) Capabilities() Capabilities { + return cloneCapabilities(codexCapabilities) +} + +func (a *codexAdapter) Start(request Request) (Invocation, error) { + return a.build("", request) +} + +func (a *codexAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} + +func (a *codexAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + args, err := a.base() + if err != nil { + return Invocation{}, err + } + 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", codexReasoning(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) + } + args, stdin, err := appendPrompt(args, request.Prompt, "-", false) + 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 err := validateValues("config overrides", request.ConfigOverrides); err != nil { + return err + } + if mode == Interactive && request.Prompt.Source == PromptStdin { + return unsupported(Codex, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") + } + if request.Provider != "" { + return unsupported(Codex, mode, "provider", request.Provider, "put the provider in configured Codex options") + } + if len(request.AllowedTools) != 0 || len(request.DeniedTools) != 0 || request.DisableBuiltInTools { + return unsupported(Codex, mode, "tool policy", "", "Codex has no equivalent per-invocation tool-list flags") + } + if len(request.SkillPaths) != 0 { + return unsupported(Codex, mode, "skill paths", "", "install skills through Codex configuration") + } + if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles { + return unsupported(Codex, mode, "Pi customization controls", "", "these controls are specific to Pi") + } + if request.Schema.Inline != "" || request.Schema.Extension != "" || request.Schema.Fallback != "" { + return unsupported(Codex, mode, "inline JSON schema", "", "write the schema to a file and set Schema.Path") + } + 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.OutputFormat == OutputJSON { + return unsupported(Codex, mode, "output format", string(OutputJSON), "Codex emits an event stream; request jsonl") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { + return unsupported(Codex, mode, "output format", string(request.OutputFormat), "request text or jsonl") + } + if request.Reasoning != ReasoningDefault && codexReasoning(request.Reasoning) == "" { + return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + } + if request.Approval == ApprovalBypass && request.Sandbox != SandboxDefault { + return fmt.Errorf("agent %q cannot combine approval bypass with sandbox %q", Codex, request.Sandbox) + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalOnRequest && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { + return unsupported(Codex, mode, "approval mode", string(request.Approval), "request on-request, never, or bypass") + } + if request.Sandbox != SandboxDefault && request.Sandbox != SandboxReadOnly && request.Sandbox != SandboxWorkspaceWrite && request.Sandbox != SandboxDangerFullAccess { + return unsupported(Codex, mode, "sandbox", string(request.Sandbox), "request read-only, workspace-write, or danger-full-access") + } + return nil +} + +func codexReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh: + return string(level) + case ReasoningMaximum: + return "xhigh" + default: + return "" + } +} diff --git a/agentcli/pi.go b/agentcli/pi.go new file mode 100644 index 0000000..c3e9902 --- /dev/null +++ b/agentcli/pi.go @@ -0,0 +1,195 @@ +package agentcli + +import ( + "fmt" + "strings" +) + +// NewPi returns a Pi adapter. Command may include configured options; an empty +// command uses "pi". +func NewPi(command []string) Adapter { + return &piAdapter{adapter: newAdapter(Pi, command, "pi")} +} + +type piAdapter struct { + adapter +} + +var piCapabilities = Capabilities{ + Modes: []Mode{Interactive, NonInteractive}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + JSONSchemaInline: true, + JSONSchemaOutputPath: true, + Model: true, + Provider: true, + Reasoning: true, + 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 (a *piAdapter) Capabilities() Capabilities { + return cloneCapabilities(piCapabilities) +} + +func (a *piAdapter) Start(request Request) (Invocation, error) { + return a.build("", request) +} + +func (a *piAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} + +func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + args, err := a.base() + if err != nil { + return Invocation{}, err + } + 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", piReasoning(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, ",")) + } + args, stdin, err := appendPrompt(args, request.Prompt, "", true) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Pi, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} + +func validatePiRequest(mode Mode, request Request) error { + if err := validateValues("allowed tools", request.AllowedTools); err != nil { + return err + } + if err := validateValues("denied tools", request.DeniedTools); err != nil { + return err + } + if err := validateValues("skill paths", request.SkillPaths); err != nil { + return err + } + if mode == Interactive && request.Prompt.Source == PromptStdin { + return unsupported(Pi, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") + } + if request.Sandbox != SandboxDefault { + return unsupported(Pi, mode, "sandbox", string(request.Sandbox), "restrict Pi through its tool allowlist or an external sandbox") + } + if request.Approval != ApprovalDefault { + return unsupported(Pi, mode, "approval mode", string(request.Approval), "Pi exposes project trust, not tool approval policy") + } + if request.DisableUserConfig || len(request.ConfigOverrides) != 0 { + return unsupported(Pi, mode, "Codex config controls", "", "use configured Pi options") + } + 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") + } + if request.OutputFormat == OutputJSON { + return unsupported(Pi, mode, "output format", string(OutputJSON), "Pi's native event output is jsonl") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { + return unsupported(Pi, mode, "output format", string(request.OutputFormat), "request text or jsonl") + } + if request.Reasoning != ReasoningDefault && piReasoning(request.Reasoning) == "" { + return unsupported(Pi, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + } + return nil +} + +func piReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh: + return string(level) + case ReasoningMaximum: + return "high" + default: + return "" + } +} From b053e0800f96d145625fca5ad07a5d66bf7a3b2c Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 09:09:35 -0400 Subject: [PATCH 2/9] Reject ambiguous configured agent commands An appended resume selector can have the wrong meaning when an unchecked command already contains a prompt, subcommand, selector, or option boundary. Reject these shapes before a caller starts the process. Keep configured options separate from prompts and validate each agent's option arity. Consumer migrations can now remove their temporary parsers and use one command grammar. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/README.md | 44 +++++++++--- agentcli/agentcli.go | 91 +++++++++++++++++++++--- agentcli/agentcli_test.go | 141 ++++++++++++++++++++++++++++++++++---- agentcli/claude.go | 87 +++++++++++++++++++++-- agentcli/codex.go | 60 ++++++++++++++-- agentcli/command.go | 68 ++++++++++++++++++ agentcli/pi.go | 78 +++++++++++++++++++-- 7 files changed, 513 insertions(+), 56 deletions(-) create mode 100644 agentcli/command.go diff --git a/agentcli/README.md b/agentcli/README.md index 6fbc146..25dcb05 100644 --- a/agentcli/README.md +++ b/agentcli/README.md @@ -1,9 +1,15 @@ # Agent CLI command construction `agentcli` builds argument vectors for coding-agent CLIs. It owns agent-specific -flag placement, 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. +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 every +prompt in `Request.Prompt`; use `Resume` for a saved session identity. ## Consumer examples @@ -11,7 +17,13 @@ Forge can preserve a configured interactive command and resume without sending the original prompt again: ```go -agent := agentcli.NewCodex([]string{"codex", "--profile", "forge"}) +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 ``` @@ -21,7 +33,11 @@ prompt transport: ```go prompt := agentcli.Prompt{Source: agentcli.PromptStdin, Text: reviewPrompt} -invocation, err := agentcli.NewCodex(configuredCommand).Start(agentcli.Request{ +agent, err := agentcli.NewCodex(agentcli.Command{Executable: configuredExecutable}) +if err != nil { + return err +} +invocation, err := agent.Start(agentcli.Request{ Mode: agentcli.NonInteractive, Prompt: prompt, OutputFormat: agentcli.OutputJSONL, @@ -33,7 +49,10 @@ invocation, err := agentcli.NewCodex(configuredCommand).Start(agentcli.Request{ 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. +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. ## Capability matrix @@ -62,8 +81,11 @@ The adapters reflect these CLI contracts: - [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference) - [Pi README](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) -The first consumer migrations should replace Forge's interactive resume switch -and RoboRev's Codex, Claude, and Pi argument builders. RoboRev should keep its -stream parsing, capability probes, environment filtering, Pi session-file -lookup, and process lifecycle code. Adapters for its other agent families can -follow without widening this package into a process runner. +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 its Codex, Claude, and Pi argument builders while keeping +stream parsing, installed-version capability probes, environment filtering, Pi +session-file lookup, and process lifecycle code. 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 index fd2dca0..5a795d4 100644 --- a/agentcli/agentcli.go +++ b/agentcli/agentcli.go @@ -1,8 +1,11 @@ -// Package agentcli builds command lines for supported coding-agent CLIs. +// 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 ( @@ -177,6 +180,15 @@ type Invocation struct { 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 @@ -195,6 +207,33 @@ type UnsupportedOptionError struct { 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 != "" { @@ -210,26 +249,56 @@ func (e *UnsupportedOptionError) Error() string { } type adapter struct { - name Name - command []string + name Name + executable string + options []string + configured map[string]bool } -func newAdapter(name Name, command []string, defaultCommand string) adapter { - if len(command) == 0 { - command = []string{defaultCommand} +func newAdapter(name Name, command Command, defaultExecutable string, grammar optionGrammar) (adapter, error) { + executable := command.Executable + if executable == "" { + executable = defaultExecutable } - return adapter{name: name, command: slices.Clone(command)} + if strings.TrimSpace(executable) == "" { + return adapter{}, fmt.Errorf("agent %q requires a configured executable", name) + } + configured, err := validateConfiguredOptions(name, command.Options, grammar) + if err != nil { + return adapter{}, err + } + return adapter{ + name: name, + executable: executable, + options: slices.Clone(command.Options), + configured: configured, + }, nil } func (a adapter) Name() Name { return a.name } -func (a adapter) base() ([]string, error) { - if len(a.command) == 0 || strings.TrimSpace(a.command[0]) == "" { - return nil, fmt.Errorf("agent %q requires a configured executable", a.name) +func (a adapter) base() []string { + return append([]string{a.executable}, a.options...) +} + +func (a adapter) rejectsConfigured(requested bool, option, hint string, names ...string) error { + if !requested { + return nil } - return slices.Clone(a.command), nil + for _, name := range names { + if a.configured[name] { + return &InvalidCommandError{ + Agent: a.name, + Token: name, + Index: -1, + Reason: "conflicts with the same option requested for this invocation", + Hint: hint, + } + } + } + return nil } func invocationMode(mode Mode) (Mode, error) { diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go index bd50840..518cb63 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -8,6 +8,27 @@ import ( "go.kenn.io/kit/agentcli" ) +func newCodex(t *testing.T, command agentcli.Command) agentcli.Adapter { + t.Helper() + agent, err := agentcli.NewCodex(command) + require.NoError(t, err) + return agent +} + +func newClaude(t *testing.T, command agentcli.Command) agentcli.Adapter { + t.Helper() + agent, err := agentcli.NewClaude(command) + require.NoError(t, err) + return agent +} + +func newPi(t *testing.T, command agentcli.Command) agentcli.Adapter { + t.Helper() + agent, err := agentcli.NewPi(command) + require.NoError(t, err) + return agent +} + func TestInteractiveResumePreservesConfiguredCommand(t *testing.T) { t.Parallel() @@ -18,17 +39,17 @@ func TestInteractiveResumePreservesConfiguredCommand(t *testing.T) { }{ { name: "codex subcommand", - agent: agentcli.NewCodex([]string{"codex-custom", "--profile", "team"}), + agent: newCodex(t, agentcli.Command{Executable: "codex-custom", Options: []string{"--profile", "team"}}), expected: []string{"codex-custom", "--profile", "team", "resume", "session-1"}, }, { name: "claude flag", - agent: agentcli.NewClaude([]string{"claude-custom", "--setting-sources", "project"}), + agent: newClaude(t, agentcli.Command{Executable: "claude-custom", Options: []string{"--setting-sources", "project"}}), expected: []string{"claude-custom", "--setting-sources", "project", "--resume", "session-1"}, }, { name: "pi flag", - agent: agentcli.NewPi([]string{"pi-custom", "--offline"}), + agent: newPi(t, agentcli.Command{Executable: "pi-custom", Options: []string{"--offline"}}), expected: []string{"pi-custom", "--offline", "--session", "session-1"}, }, } @@ -52,7 +73,7 @@ func TestCodexNonInteractiveResume(t *testing.T) { require := require.New(t) prompt := "continue from the saved state" - invocation, err := agentcli.NewCodex(nil).Resume("thread-id", agentcli.Request{ + invocation, err := newCodex(t, agentcli.Command{}).Resume("thread-id", agentcli.Request{ Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "gpt-test", @@ -90,7 +111,7 @@ func TestClaudeNonInteractiveStructuredOutput(t *testing.T) { assert := assert.New(t) require := require.New(t) - invocation, err := agentcli.NewClaude(nil).Start(agentcli.Request{ + invocation, err := newClaude(t, agentcli.Command{}).Start(agentcli.Request{ Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "classify"}, Model: "sonnet", @@ -120,7 +141,7 @@ func TestClaudeNonInteractiveStructuredOutput(t *testing.T) { func TestClaudeCanDisableAllBuiltInTools(t *testing.T) { t.Parallel() - invocation, err := agentcli.NewClaude(nil).Start(agentcli.Request{ + invocation, err := newClaude(t, agentcli.Command{}).Start(agentcli.Request{ Mode: agentcli.NonInteractive, DisableBuiltInTools: true, }) @@ -131,7 +152,7 @@ func TestClaudeCanDisableAllBuiltInTools(t *testing.T) { func TestPiSchemaInvocation(t *testing.T) { t.Parallel() - invocation, err := agentcli.NewPi(nil).Start(agentcli.Request{ + invocation, err := newPi(t, agentcli.Command{}).Start(agentcli.Request{ Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, Text: "classify", Files: []string{"prompt.md"}}, Provider: "test-provider", @@ -180,25 +201,25 @@ func TestUnsupportedOptionsReturnTypedErrors(t *testing.T) { }{ { name: "codex single JSON document", - agent: agentcli.NewCodex(nil), + agent: newCodex(t, agentcli.Command{}), request: agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSON}, option: "output format", }, { name: "claude sandbox", - agent: agentcli.NewClaude(nil), + agent: newClaude(t, agentcli.Command{}), request: agentcli.Request{Sandbox: agentcli.SandboxReadOnly}, option: "sandbox", }, { name: "pi approval policy", - agent: agentcli.NewPi(nil), + agent: newPi(t, agentcli.Command{}), request: agentcli.Request{Approval: agentcli.ApprovalNever}, option: "approval mode", }, { name: "interactive stdin prompt", - agent: agentcli.NewCodex(nil), + agent: newCodex(t, agentcli.Command{}), request: agentcli.Request{Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "prompt"}}, option: "stdin prompt", }, @@ -222,7 +243,7 @@ func TestUnsupportedOptionsReturnTypedErrors(t *testing.T) { func TestResumeRejectsOptionShapedSessionID(t *testing.T) { t.Parallel() - _, err := agentcli.NewCodex(nil).Resume("--last", agentcli.Request{}) + _, err := newCodex(t, agentcli.Command{}).Resume("--last", agentcli.Request{}) require.Error(t, err) var unsupported *agentcli.UnsupportedOptionError assert.NotErrorAs(t, err, &unsupported) @@ -232,7 +253,7 @@ func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { t.Parallel() assert := assert.New(t) - codex := agentcli.NewCodex(nil) + codex := newCodex(t, agentcli.Command{}) capabilities := codex.Capabilities() assert.True(capabilities.Resume) assert.True(capabilities.JSONSchemaPath) @@ -242,3 +263,97 @@ func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { capabilities.Modes[0] = "changed" assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) } + +func TestConfiguredCommandValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + new func(agentcli.Command) (agentcli.Adapter, error) + command agentcli.Command + token string + }{ + {name: "codex prompt", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"old prompt"}}, token: "old prompt"}, + {name: "codex subcommand", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"exec"}}, token: "exec"}, + {name: "codex missing profile", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--profile"}}, token: "--profile"}, + {name: "codex option cannot swallow subcommand-shaped flag", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--profile", "--help"}}, token: "--profile"}, + {name: "codex unknown option", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--future-flag"}}, token: "--future-flag"}, + {name: "claude resume", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--resume", "old-session"}}, token: "--resume"}, + {name: "claude selector cannot become settings value", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--settings", "--resume"}}, token: "--settings"}, + {name: "claude command", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"agents"}}, token: "agents"}, + {name: "claude optional arity", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--debug", "api"}}, token: "--debug"}, + {name: "pi session", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, + {name: "pi prompt boundary", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--", "old prompt"}}, token: "--"}, + {name: "pi action", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"install", "extension"}}, token: "install"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + _, err := test.new(test.command) + var invalid *agentcli.InvalidCommandError + require.ErrorAs(err, &invalid) + assert.Equal(test.token, invalid.Token) + assert.NotEmpty(invalid.Reason) + assert.NotEmpty(invalid.Hint) + }) + } +} + +func TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + new func(agentcli.Command) (agentcli.Adapter, error) + command agentcli.Command + expected []string + }{ + { + name: "codex long inline and short separate values", + new: agentcli.NewCodex, + command: agentcli.Command{Executable: "codex-custom", Options: []string{ + "--profile=team", "-c", "feature.test=true", "--add-dir", "-shared", + }}, + expected: []string{"codex-custom", "--profile=team", "-c", "feature.test=true", "--add-dir", "-shared", "resume", "session-1"}, + }, + { + name: "claude aliases and repeated options", + new: agentcli.NewClaude, + command: agentcli.Command{Options: []string{ + "--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two", + }}, + expected: []string{"claude", "--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two", "--resume", "session-1"}, + }, + { + name: "pi short flag and value", + new: agentcli.NewPi, + command: agentcli.Command{Options: []string{"-ne", "--tui-mode", "fullscreen", "--offline"}}, + expected: []string{"pi", "-ne", "--tui-mode", "fullscreen", "--offline", "--session", "session-1"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + agent, err := test.new(test.command) + require.NoError(t, err) + invocation, err := agent.Resume("session-1", agentcli.Request{}) + require.NoError(t, err) + assert.Equal(t, test.expected, invocation.Argv) + }) + } +} + +func TestConfiguredOptionConflictsWithRequest(t *testing.T) { + t.Parallel() + + agent, err := agentcli.NewCodex(agentcli.Command{Options: []string{"--model", "configured"}}) + require.NoError(t, err) + _, err = agent.Start(agentcli.Request{Model: "requested"}) + var invalid *agentcli.InvalidCommandError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Reason, "conflicts") +} diff --git a/agentcli/claude.go b/agentcli/claude.go index ed8018c..53ecff6 100644 --- a/agentcli/claude.go +++ b/agentcli/claude.go @@ -5,16 +5,64 @@ import ( "strings" ) -// NewClaude returns a Claude Code adapter. Command may include configured -// options; an empty command uses "claude". -func NewClaude(command []string) Adapter { - return &claudeAdapter{adapter: newAdapter(Claude, command, "claude")} +// NewClaude returns a Claude Code adapter after validating its configured +// options. A zero Command uses "claude". +func NewClaude(command Command) (Adapter, error) { + base, err := newAdapter(Claude, command, "claude", claudeOptionGrammar) + if err != nil { + return nil, err + } + return &claudeAdapter{adapter: base}, nil } type claudeAdapter struct { adapter } +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, @@ -50,11 +98,11 @@ func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, er if err != nil { return Invocation{}, err } - args, err := a.base() - if err != nil { + args := a.base() + if err := validateClaudeRequest(mode, request); err != nil { return Invocation{}, err } - if err := validateClaudeRequest(mode, request); err != nil { + if err := a.validateConfiguredRequest(request); err != nil { return Invocation{}, err } if mode == NonInteractive { @@ -112,6 +160,31 @@ func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, er return Invocation{Argv: args, Stdin: stdin}, nil } +func (a *claudeAdapter) validateConfiguredRequest(request Request) error { + checks := []struct { + requested bool + option string + hint string + names []string + }{ + {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, + {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured effort or leave Request.Reasoning empty", []string{"effort"}}, + {request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", []string{"output-format"}}, + {request.Schema.Inline != "", "JSON schema", "remove the configured schema or leave Request.Schema empty", []string{"json-schema"}}, + {request.Approval != ApprovalDefault, "approval mode", "remove the configured permission option or leave Request.Approval empty", []string{"permission-mode", "approval-bypass"}}, + {len(request.AllowedTools) != 0 || request.DisableBuiltInTools, "allowed tools", "remove configured tool selection or leave request tool selection empty", []string{"allowed-tools", "tools"}}, + {len(request.DeniedTools) != 0, "denied tools", "remove configured denied tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, + {request.DisableSkills || request.DisableHooks, "customization controls", "remove configured customization controls or leave request disable controls false", []string{"disable-skills", "safe-mode", "bare"}}, + {request.DisableSessionStorage, "session persistence", "remove --no-session-persistence or leave Request.DisableSessionStorage false", []string{"no-session-persistence"}}, + } + for _, check := range checks { + if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { + return err + } + } + return nil +} + func validateClaudeRequest(mode Mode, request Request) error { if err := validateValues("allowed tools", request.AllowedTools); err != nil { return err diff --git a/agentcli/codex.go b/agentcli/codex.go index 8bed111..fc0982e 100644 --- a/agentcli/codex.go +++ b/agentcli/codex.go @@ -4,16 +4,42 @@ import ( "fmt" ) -// NewCodex returns a Codex CLI adapter. Command may include configured global -// options; an empty command uses "codex". -func NewCodex(command []string) Adapter { - return &codexAdapter{adapter: newAdapter(Codex, command, "codex")} +// NewCodex returns a Codex CLI adapter after validating its configured global +// options. A zero Command uses "codex". +func NewCodex(command Command) (Adapter, error) { + base, err := newAdapter(Codex, command, "codex", codexOptionGrammar) + if err != nil { + return nil, err + } + return &codexAdapter{adapter: base}, nil } type codexAdapter struct { adapter } +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, @@ -52,11 +78,11 @@ func (a *codexAdapter) build(sessionID string, request Request) (Invocation, err if err != nil { return Invocation{}, err } - args, err := a.base() - if err != nil { + args := a.base() + if err := validateCodexRequest(mode, request); err != nil { return Invocation{}, err } - if err := validateCodexRequest(mode, request); err != nil { + if err := a.validateConfiguredRequest(request); err != nil { return Invocation{}, err } @@ -129,6 +155,26 @@ func (a *codexAdapter) build(sessionID string, request Request) (Invocation, err return Invocation{Argv: args, Stdin: stdin}, nil } +func (a *codexAdapter) validateConfiguredRequest(request Request) error { + checks := []struct { + requested bool + option string + hint string + names []string + }{ + {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, + {request.Sandbox != SandboxDefault, "sandbox", "remove the configured sandbox or leave Request.Sandbox empty", []string{"sandbox"}}, + {request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", []string{"approval", "approve-for-me", "approval-bypass"}}, + {request.DisableHooks, "hook controls", "remove the configured hook option or leave Request.DisableHooks false", []string{"disable", "hook-trust"}}, + } + for _, check := range checks { + if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { + return err + } + } + return nil +} + func validateCodexRequest(mode Mode, request Request) error { if err := validateValues("config overrides", request.ConfigOverrides); err != nil { return err 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/pi.go b/agentcli/pi.go index c3e9902..8544a29 100644 --- a/agentcli/pi.go +++ b/agentcli/pi.go @@ -5,16 +5,51 @@ import ( "strings" ) -// NewPi returns a Pi adapter. Command may include configured options; an empty -// command uses "pi". -func NewPi(command []string) Adapter { - return &piAdapter{adapter: newAdapter(Pi, command, "pi")} +// NewPi returns a Pi adapter after validating its configured options. A zero +// Command uses "pi". +func NewPi(command Command) (Adapter, error) { + base, err := newAdapter(Pi, command, "pi", piOptionGrammar) + if err != nil { + return nil, err + } + return &piAdapter{adapter: base}, nil } type piAdapter struct { adapter } +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}, Resume: true, @@ -56,11 +91,11 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) if err != nil { return Invocation{}, err } - args, err := a.base() - if err != nil { + args := a.base() + if err := validatePiRequest(mode, request); err != nil { return Invocation{}, err } - if err := validatePiRequest(mode, request); err != nil { + if err := a.validateConfiguredRequest(request); err != nil { return Invocation{}, err } if request.DisableSessionStorage { @@ -130,6 +165,35 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) return Invocation{Argv: args, Stdin: stdin}, nil } +func (a *piAdapter) validateConfiguredRequest(request Request) error { + checks := []struct { + requested bool + option string + hint string + names []string + }{ + {request.Provider != "", "provider", "remove the configured provider or leave Request.Provider empty", []string{"provider"}}, + {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, + {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured thinking level or leave Request.Reasoning empty", []string{"thinking"}}, + {request.OutputFormat != OutputDefault, "output format", "remove the configured mode or leave Request.OutputFormat empty", []string{"mode"}}, + {request.Schema.Inline != "", "JSON schema", "remove configured schema options or leave Request.Schema empty", []string{"json-schema", "json-output", "json-fallback"}}, + {request.DisableBuiltInTools || len(request.AllowedTools) != 0, "allowed tools", "remove configured tool selection or leave request tool selection empty", []string{"no-tools", "no-builtin-tools", "tools"}}, + {len(request.DeniedTools) != 0, "denied tools", "remove configured excluded tools or leave Request.DeniedTools empty", []string{"exclude-tools"}}, + {len(request.SkillPaths) != 0 || request.DisableSkills, "skills", "remove configured skill options or leave request skill controls empty", []string{"skill", "no-skills"}}, + {request.DisableHooks || request.DisableExtensions, "extensions", "remove configured extension controls or leave request disable controls false", []string{"extension", "no-extensions"}}, + {request.DisablePromptTemplates, "prompt templates", "remove configured prompt-template controls or leave Request.DisablePromptTemplates false", []string{"prompt-template", "no-prompt-templates"}}, + {request.DisableThemes, "themes", "remove configured theme controls or leave Request.DisableThemes false", []string{"theme", "use-theme", "no-themes"}}, + {request.DisableContextFiles, "context files", "remove --no-context-files or leave Request.DisableContextFiles false", []string{"no-context-files"}}, + {request.DisableSessionStorage, "session persistence", "remove --no-session or leave Request.DisableSessionStorage false", []string{"no-session"}}, + } + for _, check := range checks { + if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { + return err + } + } + return nil +} + func validatePiRequest(mode Mode, request Request) error { if err := validateValues("allowed tools", request.AllowedTools); err != nil { return err From d92c7db89e304618a29cf2f4795789e982cadb09 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 09:36:33 -0400 Subject: [PATCH 3/9] Require evidence for agent CLI review findings Reviewers cannot judge an external CLI's argument grammar from flag names or conventions. Unevidenced guesses produce fixes that can conflict with the real parser. Require version-matched help, documentation, or parser source before reporting command-shape, policy-interaction, or structured-output defects. This keeps speculative hardening out of the shared invocation package. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- .roborev.toml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.roborev.toml b/.roborev.toml index b5780cb..7481f46 100644 --- a/.roborev.toml +++ b/.roborev.toml @@ -14,6 +14,43 @@ 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, 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 From 2bda42d4ffc36d38b270d00d10c6c95380b3c70f Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 10:30:07 -0400 Subject: [PATCH 4/9] Support all RoboRev command agents RoboRev invokes ten command-based agent CLIs, but the shared builders covered only the three families that Forge currently resumes. Leaving the other command shapes in a consumer would keep prompt transport, session selectors, reasoning levels, and capability checks split across repositories. Give every current command agent an evidence-backed adapter. Keep xhigh distinct from maximum, and keep Agent Client Protocol sessions and process ownership with callers. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- .roborev.toml | 3 +- agentcli/README.md | 77 +++++++++---- agentcli/agentcli.go | 129 +++++++++++++++++++-- agentcli/agentcli_test.go | 229 ++++++++++++++++++++++++++++++++++++-- agentcli/claude.go | 15 ++- agentcli/codex.go | 17 ++- agentcli/copilot.go | 167 +++++++++++++++++++++++++++ agentcli/cursor.go | 103 +++++++++++++++++ agentcli/droid.go | 156 ++++++++++++++++++++++++++ agentcli/example_test.go | 45 ++++++++ agentcli/gemini.go | 126 +++++++++++++++++++++ agentcli/kilo.go | 110 ++++++++++++++++++ agentcli/kiro.go | 131 ++++++++++++++++++++++ agentcli/opencode.go | 91 +++++++++++++++ agentcli/pi.go | 23 +++- 15 files changed, 1366 insertions(+), 56 deletions(-) create mode 100644 agentcli/copilot.go create mode 100644 agentcli/cursor.go create mode 100644 agentcli/droid.go create mode 100644 agentcli/example_test.go create mode 100644 agentcli/gemini.go create mode 100644 agentcli/kilo.go create mode 100644 agentcli/kiro.go create mode 100644 agentcli/opencode.go diff --git a/.roborev.toml b/.roborev.toml index 7481f46..c78ca3d 100644 --- a/.roborev.toml +++ b/.roborev.toml @@ -46,7 +46,8 @@ 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, and +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. diff --git a/agentcli/README.md b/agentcli/README.md index 25dcb05..e70bb74 100644 --- a/agentcli/README.md +++ b/agentcli/README.md @@ -28,12 +28,12 @@ invocation, err := agent.Resume(sessionID, agentcli.Request{}) // invocation.Argv: codex --profile forge resume ``` -RoboRev can request a noninteractive event stream with explicit safety and -prompt transport: +RoboRev can select an adapter by name and request a noninteractive event +stream. The adapter reports whether it expects the prompt in argv or stdin: ```go prompt := agentcli.Prompt{Source: agentcli.PromptStdin, Text: reviewPrompt} -agent, err := agentcli.NewCodex(agentcli.Command{Executable: configuredExecutable}) +agent, err := agentcli.New(agentcli.Codex, agentcli.Command{Executable: configuredExecutable}) if err != nil { return err } @@ -54,38 +54,69 @@ 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. -## Capability matrix +## Supported agents -| Capability | Codex | Claude Code | Pi | +`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, stdin | `resume ID`, `exec resume ID` | text, JSONL | low, medium, high, xhigh | +| Claude Code | interactive, noninteractive | argument, stdin | `--resume ID` | text, JSON, JSONL | low, medium, high, xhigh, maximum | +| Gemini | noninteractive | `--prompt`, stdin appended to `--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 | argument, 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 map only `ReasoningMaximum` to it. Codex does not advertise +`ReasoningMaximum` because its CLI advertises `xhigh` but not `max`. 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 | | --- | --- | --- | --- | -| Interactive and noninteractive | yes | yes | yes | -| Resume by caller-supplied identity | `resume ID` or `exec resume ID` | `--resume ID` | `--session ID` | -| Output | text, JSONL | text, JSON, stream JSONL | text, JSONL | -| JSON Schema | schema file | inline schema | inline schema through an explicit extension and output file | -| Model and reasoning | yes | yes | yes | -| Provider | configured options | configured options | `--provider` | -| Sandbox | read-only, workspace-write, full access | no filesystem sandbox flag | no sandbox flag | -| Approval policy | on-request, never, bypass | manual, dontAsk, bypass | no tool-approval policy | -| Tool lists | no | allow, deny, disable built-ins | allow, deny, disable built-ins | -| Skill paths | no | no | yes | -| Disable skills | suppress skill instructions | disable slash commands | disable discovery | -| Disable hooks | hooks feature only | safe mode disables all customizations | disable extension discovery | -| Disable session storage | noninteractive | noninteractive | yes | -| Disable user config | noninteractive | configured options | configured options | -| Config overrides | `-c` | configured options | configured options | +| 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 its Codex, Claude, and Pi argument builders while keeping -stream parsing, installed-version capability probes, environment filtering, Pi -session-file lookup, and process lifecycle code. Keeping command-shape parsing +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 index 5a795d4..e7d24b5 100644 --- a/agentcli/agentcli.go +++ b/agentcli/agentcli.go @@ -18,11 +18,53 @@ import ( type Name string const ( - Codex Name = "codex" - Claude Name = "claude" - Pi Name = "pi" + 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 @@ -66,6 +108,7 @@ const ( ReasoningLow ReasoningLevel = "low" ReasoningMedium ReasoningLevel = "medium" ReasoningHigh ReasoningLevel = "high" + ReasoningXHigh ReasoningLevel = "xhigh" ReasoningMaximum ReasoningLevel = "maximum" ) @@ -89,6 +132,16 @@ const ( 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. @@ -101,9 +154,10 @@ type JSONSchema struct { } // Request describes one agent turn. Its zero value requests an interactive -// invocation using the agent's configured defaults. DisableExtensions, -// DisableSkills, and DisableHooks control discovery; explicit configured -// command options remain the caller's responsibility. +// 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 @@ -115,6 +169,7 @@ type Request struct { Schema JSONSchema Sandbox SandboxMode Approval ApprovalMode + Autonomy AutonomyLevel AllowedTools []string DeniedTools []string DisableBuiltInTools bool @@ -125,6 +180,7 @@ type Request struct { DisablePromptTemplates bool DisableThemes bool DisableContextFiles bool + DisableBuiltInMCPs bool DisableUserConfig bool DisableSessionStorage bool ConfigOverrides []string @@ -134,7 +190,7 @@ type Request struct { type DisableScope string const ( - DisableUnsupported DisableScope = "unsupported" + DisableUnsupported DisableScope = "" DisableHooksOnly DisableScope = "hooks-only" DisableAllCustomizations DisableScope = "all-customizations" DisableExtensionDiscovery DisableScope = "extension-discovery" @@ -150,16 +206,21 @@ type ToolCapabilities struct { // Capabilities reports which Request fields an adapter can honor. type Capabilities struct { Modes []Mode + PromptSources []PromptSource + PromptFiles bool Resume bool OutputFormats []OutputFormat JSONSchemaInline bool JSONSchemaPath bool JSONSchemaOutputPath bool + JSONSchemaExtension bool + JSONSchemaFallback bool Model bool Provider bool - Reasoning bool + ReasoningLevels []ReasoningLevel SandboxModes []SandboxMode ApprovalModes []ApprovalMode + AutonomyLevels []AutonomyLevel Tools ToolCapabilities SkillPaths bool DisableSkills bool @@ -168,6 +229,7 @@ type Capabilities struct { DisablePromptTemplates bool DisableThemes bool DisableContextFiles bool + DisableBuiltInMCPs bool DisableUserConfig bool DisableSessionStorage bool ConfigOverrides bool @@ -344,10 +406,58 @@ func validateValues(option string, values []string) error { return nil } +func joinComma(values []string) string { + return strings.Join(values, ",") +} + 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 + }{ + {request.Prompt.Source != PromptNone, slices.Contains(capabilities.PromptSources, request.Prompt.Source), "prompt transport", string(request.Prompt.Source)}, + {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 appendPrompt(args []string, prompt Prompt, stdinMarker string, supportsFiles bool) ([]string, *string, error) { if err := validatePrompt(prompt); err != nil { return nil, nil, err @@ -381,8 +491,11 @@ func appendPrompt(args []string, prompt Prompt, stdinMarker string, supportsFile func cloneCapabilities(capabilities Capabilities) Capabilities { capabilities.Modes = slices.Clone(capabilities.Modes) + capabilities.PromptSources = slices.Clone(capabilities.PromptSources) 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 index 518cb63..b02f1c9 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -29,6 +29,38 @@ func newPi(t *testing.T, command agentcli.Command) agentcli.Adapter { return agent } +func TestSupportedAgentNamesConstructAdapters(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + expected := []agentcli.Name{ + agentcli.Codex, + agentcli.Claude, + agentcli.Gemini, + agentcli.Copilot, + agentcli.OpenCode, + agentcli.Cursor, + agentcli.Kiro, + agentcli.Kilo, + agentcli.Droid, + agentcli.Pi, + } + assert.Equal(expected, agentcli.Names()) + for _, name := range expected { + agent, err := agentcli.New(name, agentcli.Command{}) + require.NoError(err) + assert.Equal(name, agent.Name()) + } + + names := agentcli.Names() + names[0] = "changed" + assert.Equal(agentcli.Codex, agentcli.Names()[0]) + + _, err := agentcli.New("unknown", agentcli.Command{}) + require.Error(err) +} + func TestInteractiveResumePreservesConfiguredCommand(t *testing.T) { t.Parallel() @@ -77,7 +109,7 @@ func TestCodexNonInteractiveResume(t *testing.T) { Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "gpt-test", - Reasoning: agentcli.ReasoningMaximum, + Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Sandbox: agentcli.SandboxReadOnly, Approval: agentcli.ApprovalNever, @@ -184,7 +216,7 @@ func TestPiSchemaInvocation(t *testing.T) { "--print", "--provider", "test-provider", "--model", "test-model", - "--thinking", "high", + "--thinking", "max", "@prompt.md", "classify", }, invocation.Argv) assert.Nil(t, invocation.Stdin) @@ -259,9 +291,25 @@ func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { assert.True(capabilities.JSONSchemaPath) assert.False(capabilities.JSONSchemaInline) assert.Equal(agentcli.DisableHooksOnly, capabilities.DisableHooks) + assert.Equal([]agentcli.ReasoningLevel{ + agentcli.ReasoningLow, + agentcli.ReasoningMedium, + agentcli.ReasoningHigh, + agentcli.ReasoningXHigh, + }, capabilities.ReasoningLevels) capabilities.Modes[0] = "changed" + capabilities.PromptSources[0] = "changed" + capabilities.ReasoningLevels[0] = "changed" assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) + assert.Equal(agentcli.PromptArgument, codex.Capabilities().PromptSources[0]) + assert.Equal(agentcli.ReasoningLow, codex.Capabilities().ReasoningLevels[0]) + + droid, err := agentcli.NewDroid(agentcli.Command{}) + require.NoError(t, err) + droidCapabilities := droid.Capabilities() + droidCapabilities.AutonomyLevels[0] = "changed" + assert.Equal(agentcli.AutonomyLow, droid.Capabilities().AutonomyLevels[0]) } func TestConfiguredCommandValidation(t *testing.T) { @@ -285,6 +333,13 @@ func TestConfiguredCommandValidation(t *testing.T) { {name: "pi session", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, {name: "pi prompt boundary", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--", "old prompt"}}, token: "--"}, {name: "pi action", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"install", "extension"}}, token: "install"}, + {name: "gemini resume", new: agentcli.NewGemini, command: agentcli.Command{Options: []string{"--resume", "old-session"}}, token: "--resume"}, + {name: "copilot missing model", new: agentcli.NewCopilot, command: agentcli.Command{Options: []string{"--model"}}, token: "--model"}, + {name: "opencode session", new: agentcli.NewOpenCode, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, + {name: "cursor prompt", new: agentcli.NewCursor, command: agentcli.Command{Options: []string{"old prompt"}}, token: "old prompt"}, + {name: "kilo session", new: agentcli.NewKilo, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, + {name: "kiro missing wrap", new: agentcli.NewKiro, command: agentcli.Command{Options: []string{"--wrap"}}, token: "--wrap"}, + {name: "droid session", new: agentcli.NewDroid, command: agentcli.Command{Options: []string{"--session-id", "old-session"}}, token: "--session-id"}, } for _, test := range tests { @@ -309,6 +364,7 @@ func TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { name string new func(agentcli.Command) (agentcli.Adapter, error) command agentcli.Command + mode agentcli.Mode expected []string }{ { @@ -333,6 +389,20 @@ func TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { command: agentcli.Command{Options: []string{"-ne", "--tui-mode", "fullscreen", "--offline"}}, expected: []string{"pi", "-ne", "--tui-mode", "fullscreen", "--offline", "--session", "session-1"}, }, + { + name: "kiro chat options follow subcommand", + new: agentcli.NewKiro, + command: agentcli.Command{Executable: "kiro-custom", Options: []string{"--wrap", "never"}}, + mode: agentcli.NonInteractive, + expected: []string{"kiro-custom", "chat", "--wrap", "never", "--no-interactive", "--resume-id", "session-1"}, + }, + { + name: "droid exec options follow subcommand", + new: agentcli.NewDroid, + command: agentcli.Command{Executable: "droid-custom", Options: []string{"--append-system-prompt", "review only"}}, + mode: agentcli.NonInteractive, + expected: []string{"droid-custom", "exec", "--append-system-prompt", "review only", "--session-id", "session-1"}, + }, } for _, test := range tests { @@ -340,7 +410,7 @@ func TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { t.Parallel() agent, err := test.new(test.command) require.NoError(t, err) - invocation, err := agent.Resume("session-1", agentcli.Request{}) + invocation, err := agent.Resume("session-1", agentcli.Request{Mode: test.mode}) require.NoError(t, err) assert.Equal(t, test.expected, invocation.Argv) }) @@ -350,10 +420,151 @@ func TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { func TestConfiguredOptionConflictsWithRequest(t *testing.T) { t.Parallel() - agent, err := agentcli.NewCodex(agentcli.Command{Options: []string{"--model", "configured"}}) - require.NoError(t, err) - _, err = agent.Start(agentcli.Request{Model: "requested"}) - var invalid *agentcli.InvalidCommandError - require.ErrorAs(t, err, &invalid) - assert.Contains(t, invalid.Reason, "conflicts") + tests := []struct { + name string + new func(agentcli.Command) (agentcli.Adapter, error) + options []string + request agentcli.Request + }{ + {name: "codex model", new: agentcli.NewCodex, options: []string{"--model", "configured"}, request: agentcli.Request{Model: "requested"}}, + {name: "claude reasoning", new: agentcli.NewClaude, options: []string{"--effort", "high"}, request: agentcli.Request{Reasoning: agentcli.ReasoningXHigh}}, + {name: "pi provider", new: agentcli.NewPi, options: []string{"--provider", "configured"}, request: agentcli.Request{Provider: "requested"}}, + {name: "gemini output", new: agentcli.NewGemini, options: []string{"--output-format", "json"}, request: agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSONL}}, + {name: "copilot MCPs", new: agentcli.NewCopilot, options: []string{"--disable-builtin-mcps"}, request: agentcli.Request{Mode: agentcli.NonInteractive, DisableBuiltInMCPs: true}}, + {name: "opencode model", new: agentcli.NewOpenCode, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {name: "cursor model", new: agentcli.NewCursor, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {name: "kilo model", new: agentcli.NewKilo, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, + {name: "kiro reasoning", new: agentcli.NewKiro, options: []string{"--effort", "high"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Reasoning: agentcli.ReasoningXHigh}}, + {name: "droid autonomy", new: agentcli.NewDroid, options: []string{"--auto", "low"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Autonomy: agentcli.AutonomyMedium}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + agent, err := test.new(agentcli.Command{Options: test.options}) + require.NoError(t, err) + _, err = agent.Start(test.request) + var invalid *agentcli.InvalidCommandError + require.ErrorAs(t, err, &invalid) + assert.Contains(t, invalid.Reason, "conflicts") + }) + } +} + +func TestAdditionalRoboRevAgentInvocations(t *testing.T) { + t.Parallel() + + prompt := "review this change" + tests := []struct { + name string + new func(agentcli.Command) (agentcli.Adapter, error) + resume bool + request agentcli.Request + expected []string + }{ + { + name: "gemini", + new: agentcli.NewGemini, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "gemini-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, + expected: []string{"gemini", "--output-format", "stream-json", "--resume", "session-1", "--model", "gemini-test", "--approval-mode", "plan", "--prompt", ""}, + }, + { + name: "copilot", + new: agentcli.NewCopilot, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, Text: prompt}, Model: "copilot-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass, DeniedTools: []string{"write"}, DisableBuiltInMCPs: true, DisableContextFiles: true}, + expected: []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}, + }, + { + name: "opencode", + new: agentcli.NewOpenCode, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "provider/model", OutputFormat: agentcli.OutputJSONL}, + expected: []string{"opencode", "run", "--format", "json", "--session", "session-1", "--model", "provider/model"}, + }, + { + name: "cursor", + new: agentcli.NewCursor, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "cursor-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, + expected: []string{"agent", "--print", "--output-format", "stream-json", "--resume", "session-1", "--model", "cursor-test", "--mode", "plan"}, + }, + { + name: "kilo", + new: agentcli.NewKilo, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "provider/model", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass}, + expected: []string{"kilo", "run", "--format", "json", "--session", "session-1", "--model", "provider/model", "--auto", "--variant", "xhigh"}, + }, + { + name: "kiro", + new: agentcli.NewKiro, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, Text: prompt}, Reasoning: agentcli.ReasoningXHigh, Approval: agentcli.ApprovalBypass}, + expected: []string{"kiro-cli", "chat", "--no-interactive", "--resume-id", "session-1", "--effort", "xhigh", "--trust-all-tools", "--", prompt}, + }, + { + name: "droid", + new: agentcli.NewDroid, + resume: true, + request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "droid-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Autonomy: agentcli.AutonomyMedium, DeniedTools: []string{"execute-cli"}, DisableSkills: true}, + expected: []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"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + agent, err := test.new(agentcli.Command{}) + require.NoError(err) + var invocation agentcli.Invocation + if test.resume { + invocation, err = agent.Resume("session-1", test.request) + } else { + invocation, err = agent.Start(test.request) + } + require.NoError(err) + assert.Equal(test.expected, invocation.Argv) + if test.request.Prompt.Source == agentcli.PromptStdin { + require.NotNil(invocation.Stdin) + assert.Equal(prompt, *invocation.Stdin) + } else { + assert.Nil(invocation.Stdin) + } + }) + } +} + +func TestReasoningXHighRemainsDistinctFromMaximum(t *testing.T) { + t.Parallel() + assert := assert.New(t) + require := require.New(t) + + constructors := []func(agentcli.Command) (agentcli.Adapter, error){ + agentcli.NewClaude, + agentcli.NewPi, + agentcli.NewCopilot, + agentcli.NewKilo, + agentcli.NewKiro, + agentcli.NewDroid, + } + for _, constructor := range constructors { + agent, err := constructor(agentcli.Command{}) + require.NoError(err) + 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, "xhigh", agent.Name()) + assert.Contains(maximum.Argv, "max", agent.Name()) + } + + codex := newCodex(t, agentcli.Command{}) + _, err := codex.Start(agentcli.Request{Reasoning: agentcli.ReasoningMaximum}) + var unsupported *agentcli.UnsupportedOptionError + require.ErrorAs(err, &unsupported) + assert.Equal("reasoning", unsupported.Option) } diff --git a/agentcli/claude.go b/agentcli/claude.go index 53ecff6..a603b83 100644 --- a/agentcli/claude.go +++ b/agentcli/claude.go @@ -65,11 +65,12 @@ var claudeOptionGrammar = optionGrammar{ var claudeCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, + PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, JSONSchemaInline: true, Model: true, - Reasoning: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, Tools: ToolCapabilities{AllowList: true, DenyList: true, DisableBuiltIns: true}, DisableSkills: true, @@ -99,6 +100,9 @@ func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, er return Invocation{}, err } args := a.base() + if err := validateSupportedRequest(Claude, mode, request, claudeCapabilities); err != nil { + return Invocation{}, err + } if err := validateClaudeRequest(mode, request); err != nil { return Invocation{}, err } @@ -198,6 +202,9 @@ func validateClaudeRequest(mode Mode, request Request) error { if request.Provider != "" { return unsupported(Claude, mode, "provider", request.Provider, "configure the provider outside Claude's argv") } + if request.Autonomy != AutonomyDefault { + return unsupported(Claude, mode, "autonomy", string(request.Autonomy), "use approval and tool controls") + } if request.Sandbox != SandboxDefault { return unsupported(Claude, mode, "sandbox", string(request.Sandbox), "Claude permission modes do not provide a filesystem sandbox") } @@ -207,7 +214,7 @@ func validateClaudeRequest(mode Mode, request Request) error { if len(request.SkillPaths) != 0 { return unsupported(Claude, mode, "skill paths", "", "install skills through Claude configuration") } - if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles { + if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles || request.DisableBuiltInMCPs { return unsupported(Claude, mode, "Pi customization controls", "", "these controls are specific to Pi") } if request.DisableUserConfig || len(request.ConfigOverrides) != 0 { @@ -228,7 +235,7 @@ func validateClaudeRequest(mode Mode, request Request) error { return unsupported(Claude, mode, "output format", string(request.OutputFormat), "request text, json, or jsonl") } if request.Reasoning != ReasoningDefault && claudeReasoning(request.Reasoning) == "" { - return unsupported(Claude, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + return unsupported(Claude, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") } if request.Approval != ApprovalDefault && request.Approval != ApprovalOnRequest && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { return unsupported(Claude, mode, "approval mode", string(request.Approval), "request on-request, never, or bypass") @@ -238,7 +245,7 @@ func validateClaudeRequest(mode Mode, request Request) error { func claudeReasoning(level ReasoningLevel) string { switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh: + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: return string(level) case ReasoningMaximum: return "max" diff --git a/agentcli/codex.go b/agentcli/codex.go index fc0982e..e846929 100644 --- a/agentcli/codex.go +++ b/agentcli/codex.go @@ -42,12 +42,13 @@ var codexOptionGrammar = optionGrammar{ var codexCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, + PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, JSONSchemaPath: true, JSONSchemaOutputPath: true, Model: true, - Reasoning: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh}, SandboxModes: []SandboxMode{SandboxReadOnly, SandboxWorkspaceWrite, SandboxDangerFullAccess}, ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, DisableSkills: true, @@ -79,6 +80,9 @@ func (a *codexAdapter) build(sessionID string, request Request) (Invocation, err return Invocation{}, err } args := a.base() + if err := validateSupportedRequest(Codex, mode, request, codexCapabilities); err != nil { + return Invocation{}, err + } if err := validateCodexRequest(mode, request); err != nil { return Invocation{}, err } @@ -185,13 +189,16 @@ func validateCodexRequest(mode Mode, request Request) error { if request.Provider != "" { return unsupported(Codex, mode, "provider", request.Provider, "put the provider in configured Codex options") } + if request.Autonomy != AutonomyDefault { + return unsupported(Codex, mode, "autonomy", string(request.Autonomy), "use sandbox and approval controls") + } if len(request.AllowedTools) != 0 || len(request.DeniedTools) != 0 || request.DisableBuiltInTools { return unsupported(Codex, mode, "tool policy", "", "Codex has no equivalent per-invocation tool-list flags") } if len(request.SkillPaths) != 0 { return unsupported(Codex, mode, "skill paths", "", "install skills through Codex configuration") } - if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles { + if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles || request.DisableBuiltInMCPs { return unsupported(Codex, mode, "Pi customization controls", "", "these controls are specific to Pi") } if request.Schema.Inline != "" || request.Schema.Extension != "" || request.Schema.Fallback != "" { @@ -218,7 +225,7 @@ func validateCodexRequest(mode Mode, request Request) error { return unsupported(Codex, mode, "output format", string(request.OutputFormat), "request text or jsonl") } if request.Reasoning != ReasoningDefault && codexReasoning(request.Reasoning) == "" { - return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or xhigh") } if request.Approval == ApprovalBypass && request.Sandbox != SandboxDefault { return fmt.Errorf("agent %q cannot combine approval bypass with sandbox %q", Codex, request.Sandbox) @@ -234,10 +241,8 @@ func validateCodexRequest(mode Mode, request Request) error { func codexReasoning(level ReasoningLevel) string { switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh: + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: return string(level) - case ReasoningMaximum: - return "xhigh" default: return "" } diff --git a/agentcli/copilot.go b/agentcli/copilot.go new file mode 100644 index 0000000..1b2b776 --- /dev/null +++ b/agentcli/copilot.go @@ -0,0 +1,167 @@ +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) { + base, err := newAdapter(Copilot, command, "copilot", copilotOptionGrammar) + if err != nil { + return nil, err + } + return &copilotAdapter{adapter: base}, nil +} + +type copilotAdapter struct{ adapter } + +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}, PromptSources: []PromptSource{PromptArgument}, 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 (a *copilotAdapter) Capabilities() Capabilities { return cloneCapabilities(copilotCapabilities) } +func (a *copilotAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *copilotAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *copilotAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Copilot, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Copilot, mode, request, copilotCapabilities); err != nil { + return Invocation{}, err + } + if err := validateCopilotRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Reasoning != ReasoningDefault, "reasoning", "remove the configured reasoning level or leave Request.Reasoning empty", "reasoning"); err != nil { + return Invocation{}, err + } + checks := []struct { + requested bool + option string + hint string + names []string + }{ + {request.OutputFormat != OutputDefault, "output format", "remove the configured output options or leave Request.OutputFormat empty", []string{"output-format", "stream"}}, + {request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", []string{"approval-bypass"}}, + {len(request.AllowedTools) != 0, "allowed tools", "remove configured allowed tools or leave Request.AllowedTools empty", []string{"allowed-tools"}}, + {len(request.DeniedTools) != 0, "denied tools", "remove configured denied tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, + {request.DisableBuiltInMCPs, "built-in MCP servers", "remove --disable-builtin-mcps or leave Request.DisableBuiltInMCPs false", []string{"disable-builtin-mcps"}}, + {request.DisableContextFiles, "custom instructions", "remove --no-custom-instructions or leave Request.DisableContextFiles false", []string{"no-custom-instructions"}}, + } + for _, check := range checks { + if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { + return Invocation{}, err + } + } + 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", copilotReasoning(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 err := validatePrompt(request.Prompt); err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Copilot, err) + } + if request.Prompt.Source == PromptArgument { + args = append(args, "--prompt", request.Prompt.Text) + } + return Invocation{Argv: args}, nil +} +func validateCopilotRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument { + return unsupported(Copilot, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt with --prompt") + } + if len(request.Prompt.Files) != 0 { + return unsupported(Copilot, NonInteractive, "prompt files", "", "use configured --attachment options") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { + return unsupported(Copilot, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { + return unsupported(Copilot, NonInteractive, "approval mode", string(request.Approval), "request bypass or use explicit tool lists") + } + if request.Reasoning != ReasoningDefault && copilotReasoning(request.Reasoning) == "" { + return unsupported(Copilot, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") + } + if err := validateValues("allowed tools", request.AllowedTools); err != nil { + return err + } + if err := validateValues("denied tools", request.DeniedTools); err != nil { + return err + } + return nil +} +func copilotReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: + return string(level) + case ReasoningMaximum: + return "max" + default: + return "" + } +} diff --git a/agentcli/cursor.go b/agentcli/cursor.go new file mode 100644 index 0000000..7853d99 --- /dev/null +++ b/agentcli/cursor.go @@ -0,0 +1,103 @@ +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) { + base, err := newAdapter(Cursor, command, "agent", cursorOptionGrammar) + if err != nil { + return nil, err + } + return &cursorAdapter{adapter: base}, nil +} + +type cursorAdapter struct{ adapter } + +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}, PromptSources: []PromptSource{PromptStdin}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, + ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, +} + +func (a *cursorAdapter) Capabilities() Capabilities { return cloneCapabilities(cursorCapabilities) } +func (a *cursorAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *cursorAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *cursorAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Cursor, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Cursor, mode, request, cursorCapabilities); err != nil { + return Invocation{}, err + } + if err := validateCursorRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { + return Invocation{}, err + } + 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") + } + args, stdin, err := appendPrompt(args, request.Prompt, "", false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Cursor, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} +func validateCursorRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { + return unsupported(Cursor, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { + return unsupported(Cursor, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { + return unsupported(Cursor, NonInteractive, "approval mode", string(request.Approval), "request never, bypass, or use the default") + } + return nil +} diff --git a/agentcli/droid.go b/agentcli/droid.go new file mode 100644 index 0000000..7b9c299 --- /dev/null +++ b/agentcli/droid.go @@ -0,0 +1,156 @@ +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) { + base, err := newAdapter(Droid, command, "droid", droidOptionGrammar) + if err != nil { + return nil, err + } + return &droidAdapter{adapter: base}, nil +} + +type droidAdapter struct{ adapter } + +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}, PromptSources: []PromptSource{PromptArgument, PromptStdin}, 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 (a *droidAdapter) Capabilities() Capabilities { return cloneCapabilities(droidCapabilities) } +func (a *droidAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *droidAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *droidAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Droid, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Droid, mode, request, droidCapabilities); err != nil { + return Invocation{}, err + } + if err := validateDroidRequest(request); err != nil { + return Invocation{}, err + } + checks := []struct { + requested bool + option string + hint string + names []string + }{ + {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, + {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured reasoning effort or leave Request.Reasoning empty", []string{"reasoning"}}, + {request.Autonomy != AutonomyDefault, "autonomy", "remove the configured autonomy level or leave Request.Autonomy empty", []string{"autonomy"}}, + {request.Approval != ApprovalDefault, "approval mode", "remove --skip-permissions-unsafe or leave Request.Approval empty", []string{"approval-bypass"}}, + {len(request.AllowedTools) != 0, "allowed tools", "remove configured restricted tools or leave Request.AllowedTools empty", []string{"allowed-tools"}}, + {len(request.DeniedTools) != 0, "denied tools", "remove configured disabled tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, + {request.DisableSkills, "built-in skills", "remove --disable-builtin-skills or leave Request.DisableSkills false", []string{"disable-builtin-skills"}}, + {request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", []string{"output-format"}}, + } + for _, check := range checks { + if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { + return Invocation{}, err + } + } + 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", droidReasoning(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") + } + args, stdin, err := appendPrompt(args, request.Prompt, "", false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Droid, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} +func validateDroidRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument && request.Prompt.Source != PromptStdin { + return unsupported(Droid, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt as an argument or over stdin") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { + return unsupported(Droid, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { + return unsupported(Droid, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") + } + if request.Autonomy != AutonomyDefault && request.Autonomy != AutonomyLow && request.Autonomy != AutonomyMedium && request.Autonomy != AutonomyHigh { + return unsupported(Droid, NonInteractive, "autonomy", string(request.Autonomy), "request low, medium, or high") + } + if request.Reasoning != ReasoningDefault && droidReasoning(request.Reasoning) == "" { + return unsupported(Droid, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") + } + if err := validateValues("allowed tools", request.AllowedTools); err != nil { + return err + } + if err := validateValues("denied tools", request.DeniedTools); err != nil { + return err + } + return nil +} +func droidReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: + return string(level) + case ReasoningMaximum: + return "max" + default: + return "" + } +} diff --git a/agentcli/example_test.go b/agentcli/example_test.go new file mode 100644 index 0000000..3210ea9 --- /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{Source: agentcli.PromptStdin, 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..32b556f --- /dev/null +++ b/agentcli/gemini.go @@ -0,0 +1,126 @@ +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) { + base, err := newAdapter(Gemini, command, "gemini", geminiOptionGrammar) + if err != nil { + return nil, err + } + return &geminiAdapter{adapter: base}, nil +} + +type geminiAdapter struct{ adapter } + +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}, PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, + ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, +} + +func (a *geminiAdapter) Capabilities() Capabilities { return cloneCapabilities(geminiCapabilities) } +func (a *geminiAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *geminiAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *geminiAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Gemini, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Gemini, mode, request, geminiCapabilities); err != nil { + return Invocation{}, err + } + if err := validateGeminiRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", "output-format"); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", "approval-mode", "approval-bypass"); err != nil { + return Invocation{}, err + } + 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") + } + if err := validatePrompt(request.Prompt); err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Gemini, err) + } + var stdin *string + switch request.Prompt.Source { + case PromptArgument: + args = append(args, "--prompt", request.Prompt.Text) + case PromptStdin: + args = append(args, "--prompt", "") + stdin = new(request.Prompt.Text) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} +func validateGeminiRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument && request.Prompt.Source != PromptStdin { + return unsupported(Gemini, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt with --prompt or over stdin") + } + if len(request.Prompt.Files) != 0 { + return unsupported(Gemini, NonInteractive, "prompt files", "", "include file references in the prompt text") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { + return unsupported(Gemini, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { + return unsupported(Gemini, NonInteractive, "approval mode", string(request.Approval), "request never, bypass, or use the default") + } + if request.Sandbox != SandboxDefault { + return unsupported(Gemini, NonInteractive, "sandbox", string(request.Sandbox), "Gemini's boolean sandbox flag does not map to a portable sandbox mode") + } + return nil +} diff --git a/agentcli/kilo.go b/agentcli/kilo.go new file mode 100644 index 0000000..74bca58 --- /dev/null +++ b/agentcli/kilo.go @@ -0,0 +1,110 @@ +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) { + base, err := newAdapter(Kilo, command, "kilo", kiloOptionGrammar) + if err != nil { + return nil, err + } + return &kiloAdapter{adapter: base}, nil +} + +type kiloAdapter struct{ adapter } + +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}, + PromptSources: []PromptSource{PromptStdin}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + Model: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, +} + +func (a *kiloAdapter) Capabilities() Capabilities { return cloneCapabilities(kiloCapabilities) } +func (a *kiloAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *kiloAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *kiloAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Kilo, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Kilo, mode, request, kiloCapabilities); err != nil { + return Invocation{}, err + } + if err := validateKiloRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { + return Invocation{}, err + } + 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 := kiloReasoning(request.Reasoning); variant != "" { + args = append(args, "--variant", variant) + } + args, stdin, err := appendPrompt(args, request.Prompt, "", false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Kilo, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} +func validateKiloRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { + return unsupported(Kilo, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { + return unsupported(Kilo, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { + return unsupported(Kilo, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") + } + if request.Reasoning != ReasoningDefault && kiloReasoning(request.Reasoning) == "" { + return unsupported(Kilo, NonInteractive, "reasoning", string(request.Reasoning), "request low, high, xhigh, or maximum") + } + return nil +} +func kiloReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: + return string(level) + case ReasoningMaximum: + return "max" + default: + return "" + } +} diff --git a/agentcli/kiro.go b/agentcli/kiro.go new file mode 100644 index 0000000..f11ce3d --- /dev/null +++ b/agentcli/kiro.go @@ -0,0 +1,131 @@ +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) { + base, err := newAdapter(Kiro, command, "kiro-cli", kiroOptionGrammar) + if err != nil { + return nil, err + } + return &kiroAdapter{adapter: base}, nil +} + +type kiroAdapter struct{ adapter } + +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}, + PromptSources: []PromptSource{PromptArgument}, + Resume: true, + OutputFormats: []OutputFormat{OutputText}, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, + ApprovalModes: []ApprovalMode{ApprovalBypass}, + Tools: ToolCapabilities{AllowList: true}, +} + +func (a *kiroAdapter) Capabilities() Capabilities { return cloneCapabilities(kiroCapabilities) } +func (a *kiroAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } +func (a *kiroAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} +func (a *kiroAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(Kiro, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(Kiro, mode, request, kiroCapabilities); err != nil { + return Invocation{}, err + } + if err := validateKiroRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Reasoning != ReasoningDefault, "reasoning", "remove the configured effort or leave Request.Reasoning empty", "reasoning"); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Approval != ApprovalDefault, "approval mode", "remove --trust-all-tools or leave Request.Approval empty", "approval-bypass"); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(len(request.AllowedTools) != 0, "allowed tools", "remove configured trusted tools or leave Request.AllowedTools empty", "allowed-tools"); err != nil { + return Invocation{}, err + } + 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", kiroReasoning(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.Source == PromptArgument { + if err := validatePrompt(request.Prompt); err != nil { + return Invocation{}, err + } + args = append(args, "--") + args, _, err = appendPrompt(args, request.Prompt, "", false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", Kiro, err) + } + return Invocation{Argv: args}, nil + } + return Invocation{Argv: args}, nil +} +func validateKiroRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument { + return unsupported(Kiro, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt as an argument") + } + if len(request.Prompt.Files) != 0 { + return unsupported(Kiro, NonInteractive, "prompt files", "", "include file references in the prompt text") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { + return unsupported(Kiro, NonInteractive, "output format", string(request.OutputFormat), "request text") + } + if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { + return unsupported(Kiro, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") + } + if request.Reasoning != ReasoningDefault && kiroReasoning(request.Reasoning) == "" { + return unsupported(Kiro, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") + } + if err := validateValues("allowed tools", request.AllowedTools); err != nil { + return err + } + return nil +} +func kiroReasoning(level ReasoningLevel) string { + switch level { + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: + return string(level) + case ReasoningMaximum: + return "max" + default: + return "" + } +} diff --git a/agentcli/opencode.go b/agentcli/opencode.go new file mode 100644 index 0000000..cbd9fce --- /dev/null +++ b/agentcli/opencode.go @@ -0,0 +1,91 @@ +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) { + base, err := newAdapter(OpenCode, command, "opencode", openCodeOptionGrammar) + if err != nil { + return nil, err + } + return &openCodeAdapter{adapter: base}, nil +} + +type openCodeAdapter struct{ adapter } + +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}, + PromptSources: []PromptSource{PromptStdin}, + Resume: true, + OutputFormats: []OutputFormat{OutputText, OutputJSONL}, + Model: true, +} + +func (a *openCodeAdapter) Capabilities() Capabilities { return cloneCapabilities(openCodeCapabilities) } +func (a *openCodeAdapter) Start(request Request) (Invocation, error) { + return a.build("", request) +} +func (a *openCodeAdapter) Resume(sessionID string, request Request) (Invocation, error) { + sessionID, err := validateSessionID(sessionID) + if err != nil { + return Invocation{}, err + } + return a.build(sessionID, request) +} + +func (a *openCodeAdapter) build(sessionID string, request Request) (Invocation, error) { + mode, err := invocationMode(request.Mode) + if err != nil { + return Invocation{}, err + } + if mode != NonInteractive { + return Invocation{}, unsupported(OpenCode, mode, "mode", string(mode), "request noninteractive mode") + } + if err := validateSupportedRequest(OpenCode, mode, request, openCodeCapabilities); err != nil { + return Invocation{}, err + } + if err := validateOpenCodeRequest(request); err != nil { + return Invocation{}, err + } + if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { + return Invocation{}, err + } + 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) + } + args, stdin, err := appendPrompt(args, request.Prompt, "", false) + if err != nil { + return Invocation{}, fmt.Errorf("build %s invocation: %w", OpenCode, err) + } + return Invocation{Argv: args, Stdin: stdin}, nil +} + +func validateOpenCodeRequest(request Request) error { + if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { + return unsupported(OpenCode, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") + } + if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { + return unsupported(OpenCode, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") + } + return nil +} diff --git a/agentcli/pi.go b/agentcli/pi.go index 8544a29..6886e38 100644 --- a/agentcli/pi.go +++ b/agentcli/pi.go @@ -52,13 +52,17 @@ var piOptionGrammar = optionGrammar{ var piCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, + PromptSources: []PromptSource{PromptArgument}, + PromptFiles: true, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, JSONSchemaInline: true, JSONSchemaOutputPath: true, + JSONSchemaExtension: true, + JSONSchemaFallback: true, Model: true, Provider: true, - Reasoning: true, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, Tools: ToolCapabilities{AllowList: true, DenyList: true, DisableBuiltIns: true}, SkillPaths: true, DisableSkills: true, @@ -92,6 +96,9 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) return Invocation{}, err } args := a.base() + if err := validateSupportedRequest(Pi, mode, request, piCapabilities); err != nil { + return Invocation{}, err + } if err := validatePiRequest(mode, request); err != nil { return Invocation{}, err } @@ -207,13 +214,19 @@ func validatePiRequest(mode Mode, request Request) error { if mode == Interactive && request.Prompt.Source == PromptStdin { return unsupported(Pi, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") } + if mode == NonInteractive && request.Prompt.Source == PromptStdin { + return unsupported(Pi, mode, "stdin prompt", "", "send the prompt as an argument or file reference") + } if request.Sandbox != SandboxDefault { return unsupported(Pi, mode, "sandbox", string(request.Sandbox), "restrict Pi through its tool allowlist or an external sandbox") } + if request.Autonomy != AutonomyDefault { + return unsupported(Pi, mode, "autonomy", string(request.Autonomy), "use tool controls") + } if request.Approval != ApprovalDefault { return unsupported(Pi, mode, "approval mode", string(request.Approval), "Pi exposes project trust, not tool approval policy") } - if request.DisableUserConfig || len(request.ConfigOverrides) != 0 { + if request.DisableBuiltInMCPs || request.DisableUserConfig || len(request.ConfigOverrides) != 0 { return unsupported(Pi, mode, "Codex config controls", "", "use configured Pi options") } if request.OutputPath != "" || request.Schema.Path != "" { @@ -242,17 +255,17 @@ func validatePiRequest(mode Mode, request Request) error { return unsupported(Pi, mode, "output format", string(request.OutputFormat), "request text or jsonl") } if request.Reasoning != ReasoningDefault && piReasoning(request.Reasoning) == "" { - return unsupported(Pi, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or maximum") + return unsupported(Pi, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") } return nil } func piReasoning(level ReasoningLevel) string { switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh: + case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: return string(level) case ReasoningMaximum: - return "high" + return "max" default: return "" } From 5cd82259b3cd89a0c30870e1f015615e12817b30 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 10:40:08 -0400 Subject: [PATCH 5/9] Map Codex maximum reasoning to max Callers need maximum reasoning to remain distinct from xhigh when they build a Codex command. Rejecting maximum prevents them from selecting Codex's max effort level through the shared adapter. Advertise the level and map it to Codex's max configuration value. This keeps the public request type consistent across agents without changing the CLI-specific argument. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/README.md | 7 +++---- agentcli/agentcli_test.go | 33 ++++++++++++++++----------------- agentcli/codex.go | 6 ++++-- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/agentcli/README.md b/agentcli/README.md index e70bb74..b3a15f8 100644 --- a/agentcli/README.md +++ b/agentcli/README.md @@ -63,7 +63,7 @@ shape that RoboRev currently needs. | Agent | Modes | Prompt | Resume | Output | Reasoning | | --- | --- | --- | --- | --- | --- | -| Codex | interactive, noninteractive | argument, stdin | `resume ID`, `exec resume ID` | text, JSONL | low, medium, high, xhigh | +| Codex | interactive, noninteractive | argument, stdin | `resume ID`, `exec resume ID` | text, JSONL | low, medium, high, xhigh, maximum | | Claude Code | interactive, noninteractive | argument, stdin | `--resume ID` | text, JSON, JSONL | low, medium, high, xhigh, maximum | | Gemini | noninteractive | `--prompt`, stdin appended to `--prompt` | `--resume ID` | text, JSON, JSONL | none | | GitHub Copilot | noninteractive | `--prompt` | `--resume=ID` | text, JSONL | low, medium, high, xhigh, maximum | @@ -75,9 +75,8 @@ shape that RoboRev currently needs. | 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 map only `ReasoningMaximum` to it. Codex does not advertise -`ReasoningMaximum` because its CLI advertises `xhigh` but not `max`. Droid -accepts model-dependent reasoning values, and Kilo passes the value as a +`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. diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go index b02f1c9..30b6177 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -296,6 +296,7 @@ func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { agentcli.ReasoningMedium, agentcli.ReasoningHigh, agentcli.ReasoningXHigh, + agentcli.ReasoningMaximum, }, capabilities.ReasoningLevels) capabilities.Modes[0] = "changed" @@ -543,28 +544,26 @@ func TestReasoningXHighRemainsDistinctFromMaximum(t *testing.T) { assert := assert.New(t) require := require.New(t) - constructors := []func(agentcli.Command) (agentcli.Adapter, error){ - agentcli.NewClaude, - agentcli.NewPi, - agentcli.NewCopilot, - agentcli.NewKilo, - agentcli.NewKiro, - agentcli.NewDroid, + tests := []struct { + new func(agentcli.Command) (agentcli.Adapter, error) + xhigh, maximum string + }{ + {agentcli.NewCodex, `model_reasoning_effort="xhigh"`, `model_reasoning_effort="max"`}, + {agentcli.NewClaude, "xhigh", "max"}, + {agentcli.NewPi, "xhigh", "max"}, + {agentcli.NewCopilot, "xhigh", "max"}, + {agentcli.NewKilo, "xhigh", "max"}, + {agentcli.NewKiro, "xhigh", "max"}, + {agentcli.NewDroid, "xhigh", "max"}, } - for _, constructor := range constructors { - agent, err := constructor(agentcli.Command{}) + for _, test := range tests { + agent, err := test.new(agentcli.Command{}) require.NoError(err) 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, "xhigh", agent.Name()) - assert.Contains(maximum.Argv, "max", agent.Name()) + assert.Contains(xhigh.Argv, test.xhigh, agent.Name()) + assert.Contains(maximum.Argv, test.maximum, agent.Name()) } - - codex := newCodex(t, agentcli.Command{}) - _, err := codex.Start(agentcli.Request{Reasoning: agentcli.ReasoningMaximum}) - var unsupported *agentcli.UnsupportedOptionError - require.ErrorAs(err, &unsupported) - assert.Equal("reasoning", unsupported.Option) } diff --git a/agentcli/codex.go b/agentcli/codex.go index e846929..22e518d 100644 --- a/agentcli/codex.go +++ b/agentcli/codex.go @@ -48,7 +48,7 @@ var codexCapabilities = Capabilities{ JSONSchemaPath: true, JSONSchemaOutputPath: true, Model: true, - ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh}, + ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, SandboxModes: []SandboxMode{SandboxReadOnly, SandboxWorkspaceWrite, SandboxDangerFullAccess}, ApprovalModes: []ApprovalMode{ApprovalOnRequest, ApprovalNever, ApprovalBypass}, DisableSkills: true, @@ -225,7 +225,7 @@ func validateCodexRequest(mode Mode, request Request) error { return unsupported(Codex, mode, "output format", string(request.OutputFormat), "request text or jsonl") } if request.Reasoning != ReasoningDefault && codexReasoning(request.Reasoning) == "" { - return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, or xhigh") + return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") } if request.Approval == ApprovalBypass && request.Sandbox != SandboxDefault { return fmt.Errorf("agent %q cannot combine approval bypass with sandbox %q", Codex, request.Sandbox) @@ -243,6 +243,8 @@ func codexReasoning(level ReasoningLevel) string { switch level { case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: return string(level) + case ReasoningMaximum: + return "max" default: return "" } From 8f193966525de78220c7f583ce71c2deb0c80a83 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 12:32:40 -0400 Subject: [PATCH 6/9] Reduce agent CLI adapter duplication The first implementation repeated invocation and validation plumbing for every agent, making a small command-building package much larger and harder to audit than it needed to be. Use one private adapter for shared behavior while keeping each CLI's grammar, capabilities, and argument builder explicit. Copilot's unconditional tool flag remains because its installed noninteractive CLI requires it, and Pi now separates option parsing from positional prompts as its help documents. Do not reject a configured Codex bypass combined with a requested sandbox without CLI evidence that the combination defeats the request. Do not add prompt-source guards for request shapes that current consumers cannot construct. Both would add policy for hypothetical misuse rather than preserve an observed caller contract. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/agentcli.go | 112 +++++-- agentcli/agentcli_test.go | 602 +++++++++----------------------------- agentcli/claude.go | 113 +------ agentcli/codex.go | 109 +------ agentcli/copilot.go | 94 +----- agentcli/cursor.go | 47 +-- agentcli/droid.go | 88 +----- agentcli/gemini.go | 59 +--- agentcli/kilo.go | 62 +--- agentcli/kiro.go | 78 +---- agentcli/opencode.go | 48 +-- agentcli/pi.go | 120 +------- 12 files changed, 259 insertions(+), 1273 deletions(-) diff --git a/agentcli/agentcli.go b/agentcli/agentcli.go index e7d24b5..bda94e6 100644 --- a/agentcli/agentcli.go +++ b/agentcli/agentcli.go @@ -311,52 +311,107 @@ func (e *UnsupportedOptionError) Error() string { } type adapter struct { - name Name - executable string - options []string - configured map[string]bool + 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) (adapter, 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 adapter{}, fmt.Errorf("agent %q requires a configured executable", name) + return nil, fmt.Errorf("agent %q requires a configured executable", name) } configured, err := validateConfiguredOptions(name, command.Options, grammar) if err != nil { - return adapter{}, err + return nil, err } - return adapter{ - name: name, - executable: executable, - options: slices.Clone(command.Options), - configured: configured, + 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) 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) rejectsConfigured(requested bool, option, hint string, names ...string) error { - if !requested { - return nil +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{"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 _, name := range names { - if a.configured[name] { - return &InvalidCommandError{ - Agent: a.name, - Token: name, - Index: -1, - Reason: "conflicts with the same option requested for this invocation", - Hint: hint, + 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"} } } } @@ -410,6 +465,13 @@ 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} } diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go index 30b6177..f0768ec 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -8,562 +8,230 @@ import ( "go.kenn.io/kit/agentcli" ) -func newCodex(t *testing.T, command agentcli.Command) agentcli.Adapter { +func mustAgent(t *testing.T, name agentcli.Name, command agentcli.Command) agentcli.Adapter { t.Helper() - agent, err := agentcli.NewCodex(command) + agent, err := agentcli.New(name, command) require.NoError(t, err) return agent } -func newClaude(t *testing.T, command agentcli.Command) agentcli.Adapter { - t.Helper() - agent, err := agentcli.NewClaude(command) - require.NoError(t, err) - return agent -} - -func newPi(t *testing.T, command agentcli.Command) agentcli.Adapter { - t.Helper() - agent, err := agentcli.NewPi(command) - require.NoError(t, err) - return agent -} - -func TestSupportedAgentNamesConstructAdapters(t *testing.T) { +func TestSupportedAgents(t *testing.T) { t.Parallel() assert := assert.New(t) - require := require.New(t) - - expected := []agentcli.Name{ - agentcli.Codex, - agentcli.Claude, - agentcli.Gemini, - agentcli.Copilot, - agentcli.OpenCode, - agentcli.Cursor, - agentcli.Kiro, - agentcli.Kilo, - agentcli.Droid, - agentcli.Pi, - } - assert.Equal(expected, agentcli.Names()) - for _, name := range expected { - agent, err := agentcli.New(name, agentcli.Command{}) - require.NoError(err) - assert.Equal(name, agent.Name()) + 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(err) + require.Error(t, err) } -func TestInteractiveResumePreservesConfiguredCommand(t *testing.T) { +func TestInvocationContracts(t *testing.T) { t.Parallel() - + prompt := "review this change" tests := []struct { - name string - agent agentcli.Adapter - expected []string + name agentcli.Name + session string + request agentcli.Request + want []string }{ - { - name: "codex subcommand", - agent: newCodex(t, agentcli.Command{Executable: "codex-custom", Options: []string{"--profile", "team"}}), - expected: []string{"codex-custom", "--profile", "team", "resume", "session-1"}, - }, - { - name: "claude flag", - agent: newClaude(t, agentcli.Command{Executable: "claude-custom", Options: []string{"--setting-sources", "project"}}), - expected: []string{"claude-custom", "--setting-sources", "project", "--resume", "session-1"}, - }, - { - name: "pi flag", - agent: newPi(t, agentcli.Command{Executable: "pi-custom", Options: []string{"--offline"}}), - expected: []string{"pi-custom", "--offline", "--session", "session-1"}, - }, + {agentcli.Codex, "thread-id", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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(test.name, func(t *testing.T) { + t.Run(string(test.name), func(t *testing.T) { t.Parallel() assert := assert.New(t) require := require.New(t) - invocation, err := test.agent.Resume("session-1", agentcli.Request{}) + 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.expected, invocation.Argv) - assert.Nil(invocation.Stdin) + assert.Equal(test.want, got.Argv) + if test.request.Prompt.Source == agentcli.PromptStdin { + require.NotNil(got.Stdin) + assert.Equal(test.request.Prompt.Text, *got.Stdin) + } else { + assert.Nil(got.Stdin) + } }) } } -func TestCodexNonInteractiveResume(t *testing.T) { +func TestInteractiveResumePreservesConfiguredOptions(t *testing.T) { t.Parallel() - assert := assert.New(t) - require := require.New(t) - - prompt := "continue from the saved state" - invocation, err := newCodex(t, agentcli.Command{}).Resume("thread-id", agentcli.Request{ - Mode: agentcli.NonInteractive, - Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, 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"}, - }) - require.NoError(err) - assert.Equal([]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", "-", - }, invocation.Argv) - require.NotNil(invocation.Stdin) - assert.Equal(prompt, *invocation.Stdin) + 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 TestClaudeNonInteractiveStructuredOutput(t *testing.T) { +func TestUnsupportedRequestsReturnTypedErrors(t *testing.T) { t.Parallel() assert := assert.New(t) require := require.New(t) - - invocation, err := newClaude(t, agentcli.Command{}).Start(agentcli.Request{ - Mode: agentcli.NonInteractive, - Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "classify"}, - 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, - }) - require.NoError(err) - assert.Equal([]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", - }, invocation.Argv) - require.NotNil(invocation.Stdin) - assert.Equal("classify", *invocation.Stdin) -} - -func TestClaudeCanDisableAllBuiltInTools(t *testing.T) { - t.Parallel() - - invocation, err := newClaude(t, agentcli.Command{}).Start(agentcli.Request{ - Mode: agentcli.NonInteractive, - DisableBuiltInTools: true, - }) - require.NoError(t, err) - assert.Equal(t, []string{"claude", "--print", "--tools", ""}, invocation.Argv) -} - -func TestPiSchemaInvocation(t *testing.T) { - t.Parallel() - - invocation, err := newPi(t, agentcli.Command{}).Start(agentcli.Request{ - Mode: agentcli.NonInteractive, - Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, 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, - }) - require.NoError(t, err) - assert.Equal(t, []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", - }, invocation.Argv) - assert.Nil(t, invocation.Stdin) -} - -func TestUnsupportedOptionsReturnTypedErrors(t *testing.T) { - t.Parallel() - tests := []struct { - name string - agent agentcli.Adapter + name agentcli.Name request agentcli.Request option string }{ - { - name: "codex single JSON document", - agent: newCodex(t, agentcli.Command{}), - request: agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSON}, - option: "output format", - }, - { - name: "claude sandbox", - agent: newClaude(t, agentcli.Command{}), - request: agentcli.Request{Sandbox: agentcli.SandboxReadOnly}, - option: "sandbox", - }, - { - name: "pi approval policy", - agent: newPi(t, agentcli.Command{}), - request: agentcli.Request{Approval: agentcli.ApprovalNever}, - option: "approval mode", - }, - { - name: "interactive stdin prompt", - agent: newCodex(t, agentcli.Command{}), - request: agentcli.Request{Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "prompt"}}, - option: "stdin prompt", - }, + {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"}, + {agentcli.Codex, agentcli.Request{Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "prompt"}}, "stdin prompt"}, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - assert := assert.New(t) - require := require.New(t) - _, err := test.agent.Start(test.request) - var unsupported *agentcli.UnsupportedOptionError - require.ErrorAs(err, &unsupported) - assert.Equal(test.agent.Name(), unsupported.Agent) - assert.Equal(test.option, unsupported.Option) - assert.NotEmpty(unsupported.Hint) - }) + _, 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) } -} - -func TestResumeRejectsOptionShapedSessionID(t *testing.T) { - t.Parallel() - - _, err := newCodex(t, agentcli.Command{}).Resume("--last", agentcli.Request{}) - require.Error(t, err) - var unsupported *agentcli.UnsupportedOptionError - assert.NotErrorAs(t, err, &unsupported) + _, 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 := newCodex(t, agentcli.Command{}) - capabilities := codex.Capabilities() - assert.True(capabilities.Resume) - assert.True(capabilities.JSONSchemaPath) - assert.False(capabilities.JSONSchemaInline) - assert.Equal(agentcli.DisableHooksOnly, capabilities.DisableHooks) - assert.Equal([]agentcli.ReasoningLevel{ - agentcli.ReasoningLow, - agentcli.ReasoningMedium, - agentcli.ReasoningHigh, - agentcli.ReasoningXHigh, - agentcli.ReasoningMaximum, - }, capabilities.ReasoningLevels) - - capabilities.Modes[0] = "changed" - capabilities.PromptSources[0] = "changed" - capabilities.ReasoningLevels[0] = "changed" + 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.PromptSources[0], got.ReasoningLevels[0] = "changed", "changed", "changed" assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) assert.Equal(agentcli.PromptArgument, codex.Capabilities().PromptSources[0]) assert.Equal(agentcli.ReasoningLow, codex.Capabilities().ReasoningLevels[0]) - - droid, err := agentcli.NewDroid(agentcli.Command{}) - require.NoError(t, err) - droidCapabilities := droid.Capabilities() - droidCapabilities.AutonomyLevels[0] = "changed" - assert.Equal(agentcli.AutonomyLow, droid.Capabilities().AutonomyLevels[0]) } func TestConfiguredCommandValidation(t *testing.T) { t.Parallel() - tests := []struct { - name string - new func(agentcli.Command) (agentcli.Adapter, error) - command agentcli.Command + name agentcli.Name + options []string token string }{ - {name: "codex prompt", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"old prompt"}}, token: "old prompt"}, - {name: "codex subcommand", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"exec"}}, token: "exec"}, - {name: "codex missing profile", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--profile"}}, token: "--profile"}, - {name: "codex option cannot swallow subcommand-shaped flag", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--profile", "--help"}}, token: "--profile"}, - {name: "codex unknown option", new: agentcli.NewCodex, command: agentcli.Command{Options: []string{"--future-flag"}}, token: "--future-flag"}, - {name: "claude resume", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--resume", "old-session"}}, token: "--resume"}, - {name: "claude selector cannot become settings value", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--settings", "--resume"}}, token: "--settings"}, - {name: "claude command", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"agents"}}, token: "agents"}, - {name: "claude optional arity", new: agentcli.NewClaude, command: agentcli.Command{Options: []string{"--debug", "api"}}, token: "--debug"}, - {name: "pi session", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, - {name: "pi prompt boundary", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"--", "old prompt"}}, token: "--"}, - {name: "pi action", new: agentcli.NewPi, command: agentcli.Command{Options: []string{"install", "extension"}}, token: "install"}, - {name: "gemini resume", new: agentcli.NewGemini, command: agentcli.Command{Options: []string{"--resume", "old-session"}}, token: "--resume"}, - {name: "copilot missing model", new: agentcli.NewCopilot, command: agentcli.Command{Options: []string{"--model"}}, token: "--model"}, - {name: "opencode session", new: agentcli.NewOpenCode, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, - {name: "cursor prompt", new: agentcli.NewCursor, command: agentcli.Command{Options: []string{"old prompt"}}, token: "old prompt"}, - {name: "kilo session", new: agentcli.NewKilo, command: agentcli.Command{Options: []string{"--session", "old-session"}}, token: "--session"}, - {name: "kiro missing wrap", new: agentcli.NewKiro, command: agentcli.Command{Options: []string{"--wrap"}}, token: "--wrap"}, - {name: "droid session", new: agentcli.NewDroid, command: agentcli.Command{Options: []string{"--session-id", "old-session"}}, token: "--session-id"}, + {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 { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - assert := assert.New(t) - require := require.New(t) - _, err := test.new(test.command) - var invalid *agentcli.InvalidCommandError - require.ErrorAs(err, &invalid) - assert.Equal(test.token, invalid.Token) - assert.NotEmpty(invalid.Reason) - assert.NotEmpty(invalid.Hint) - }) + _, 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 TestConfiguredOptionsPreserveArityAndOrdering(t *testing.T) { +func TestConfiguredOptionsKeepTheirArityAndOrder(t *testing.T) { t.Parallel() - tests := []struct { - name string - new func(agentcli.Command) (agentcli.Adapter, error) - command agentcli.Command - mode agentcli.Mode - expected []string + name agentcli.Name + command agentcli.Command + mode agentcli.Mode + want []string }{ - { - name: "codex long inline and short separate values", - new: agentcli.NewCodex, - command: agentcli.Command{Executable: "codex-custom", Options: []string{ - "--profile=team", "-c", "feature.test=true", "--add-dir", "-shared", - }}, - expected: []string{"codex-custom", "--profile=team", "-c", "feature.test=true", "--add-dir", "-shared", "resume", "session-1"}, - }, - { - name: "claude aliases and repeated options", - new: agentcli.NewClaude, - command: agentcli.Command{Options: []string{ - "--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two", - }}, - expected: []string{"claude", "--setting-sources=project", "--plugin-dir", "one", "--plugin-dir", "-two", "--resume", "session-1"}, - }, - { - name: "pi short flag and value", - new: agentcli.NewPi, - command: agentcli.Command{Options: []string{"-ne", "--tui-mode", "fullscreen", "--offline"}}, - expected: []string{"pi", "-ne", "--tui-mode", "fullscreen", "--offline", "--session", "session-1"}, - }, - { - name: "kiro chat options follow subcommand", - new: agentcli.NewKiro, - command: agentcli.Command{Executable: "kiro-custom", Options: []string{"--wrap", "never"}}, - mode: agentcli.NonInteractive, - expected: []string{"kiro-custom", "chat", "--wrap", "never", "--no-interactive", "--resume-id", "session-1"}, - }, - { - name: "droid exec options follow subcommand", - new: agentcli.NewDroid, - command: agentcli.Command{Executable: "droid-custom", Options: []string{"--append-system-prompt", "review only"}}, - mode: agentcli.NonInteractive, - expected: []string{"droid-custom", "exec", "--append-system-prompt", "review only", "--session-id", "session-1"}, - }, + {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 { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - agent, err := test.new(test.command) - require.NoError(t, err) - invocation, err := agent.Resume("session-1", agentcli.Request{Mode: test.mode}) - require.NoError(t, err) - assert.Equal(t, test.expected, invocation.Argv) - }) + 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 TestConfiguredOptionConflictsWithRequest(t *testing.T) { +func TestConfiguredOptionsConflictWithRequest(t *testing.T) { t.Parallel() - tests := []struct { - name string - new func(agentcli.Command) (agentcli.Adapter, error) + name agentcli.Name options []string request agentcli.Request }{ - {name: "codex model", new: agentcli.NewCodex, options: []string{"--model", "configured"}, request: agentcli.Request{Model: "requested"}}, - {name: "claude reasoning", new: agentcli.NewClaude, options: []string{"--effort", "high"}, request: agentcli.Request{Reasoning: agentcli.ReasoningXHigh}}, - {name: "pi provider", new: agentcli.NewPi, options: []string{"--provider", "configured"}, request: agentcli.Request{Provider: "requested"}}, - {name: "gemini output", new: agentcli.NewGemini, options: []string{"--output-format", "json"}, request: agentcli.Request{Mode: agentcli.NonInteractive, OutputFormat: agentcli.OutputJSONL}}, - {name: "copilot MCPs", new: agentcli.NewCopilot, options: []string{"--disable-builtin-mcps"}, request: agentcli.Request{Mode: agentcli.NonInteractive, DisableBuiltInMCPs: true}}, - {name: "opencode model", new: agentcli.NewOpenCode, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, - {name: "cursor model", new: agentcli.NewCursor, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, - {name: "kilo model", new: agentcli.NewKilo, options: []string{"--model", "configured"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Model: "requested"}}, - {name: "kiro reasoning", new: agentcli.NewKiro, options: []string{"--effort", "high"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Reasoning: agentcli.ReasoningXHigh}}, - {name: "droid autonomy", new: agentcli.NewDroid, options: []string{"--auto", "low"}, request: agentcli.Request{Mode: agentcli.NonInteractive, Autonomy: agentcli.AutonomyMedium}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - agent, err := test.new(agentcli.Command{Options: test.options}) - require.NoError(t, err) - _, err = agent.Start(test.request) - var invalid *agentcli.InvalidCommandError - require.ErrorAs(t, err, &invalid) - assert.Contains(t, invalid.Reason, "conflicts") - }) - } -} - -func TestAdditionalRoboRevAgentInvocations(t *testing.T) { - t.Parallel() - - prompt := "review this change" - tests := []struct { - name string - new func(agentcli.Command) (agentcli.Adapter, error) - resume bool - request agentcli.Request - expected []string - }{ - { - name: "gemini", - new: agentcli.NewGemini, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "gemini-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, - expected: []string{"gemini", "--output-format", "stream-json", "--resume", "session-1", "--model", "gemini-test", "--approval-mode", "plan", "--prompt", ""}, - }, - { - name: "copilot", - new: agentcli.NewCopilot, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, Text: prompt}, Model: "copilot-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass, DeniedTools: []string{"write"}, DisableBuiltInMCPs: true, DisableContextFiles: true}, - expected: []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}, - }, - { - name: "opencode", - new: agentcli.NewOpenCode, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "provider/model", OutputFormat: agentcli.OutputJSONL}, - expected: []string{"opencode", "run", "--format", "json", "--session", "session-1", "--model", "provider/model"}, - }, - { - name: "cursor", - new: agentcli.NewCursor, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "cursor-test", OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalNever}, - expected: []string{"agent", "--print", "--output-format", "stream-json", "--resume", "session-1", "--model", "cursor-test", "--mode", "plan"}, - }, - { - name: "kilo", - new: agentcli.NewKilo, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "provider/model", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Approval: agentcli.ApprovalBypass}, - expected: []string{"kilo", "run", "--format", "json", "--session", "session-1", "--model", "provider/model", "--auto", "--variant", "xhigh"}, - }, - { - name: "kiro", - new: agentcli.NewKiro, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptArgument, Text: prompt}, Reasoning: agentcli.ReasoningXHigh, Approval: agentcli.ApprovalBypass}, - expected: []string{"kiro-cli", "chat", "--no-interactive", "--resume-id", "session-1", "--effort", "xhigh", "--trust-all-tools", "--", prompt}, - }, - { - name: "droid", - new: agentcli.NewDroid, - resume: true, - request: agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, Model: "droid-test", Reasoning: agentcli.ReasoningXHigh, OutputFormat: agentcli.OutputJSONL, Autonomy: agentcli.AutonomyMedium, DeniedTools: []string{"execute-cli"}, DisableSkills: true}, - expected: []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.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.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 { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - assert := assert.New(t) - require := require.New(t) - agent, err := test.new(agentcli.Command{}) - require.NoError(err) - var invocation agentcli.Invocation - if test.resume { - invocation, err = agent.Resume("session-1", test.request) - } else { - invocation, err = agent.Start(test.request) - } - require.NoError(err) - assert.Equal(test.expected, invocation.Argv) - if test.request.Prompt.Source == agentcli.PromptStdin { - require.NotNil(invocation.Stdin) - assert.Equal(prompt, *invocation.Stdin) - } else { - assert.Nil(invocation.Stdin) - } - }) + _, 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 TestReasoningXHighRemainsDistinctFromMaximum(t *testing.T) { +func TestXHighAndMaximumStayDistinct(t *testing.T) { t.Parallel() assert := assert.New(t) require := require.New(t) - tests := []struct { - new func(agentcli.Command) (agentcli.Adapter, error) + name agentcli.Name xhigh, maximum string }{ - {agentcli.NewCodex, `model_reasoning_effort="xhigh"`, `model_reasoning_effort="max"`}, - {agentcli.NewClaude, "xhigh", "max"}, - {agentcli.NewPi, "xhigh", "max"}, - {agentcli.NewCopilot, "xhigh", "max"}, - {agentcli.NewKilo, "xhigh", "max"}, - {agentcli.NewKiro, "xhigh", "max"}, - {agentcli.NewDroid, "xhigh", "max"}, + {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, err := test.new(agentcli.Command{}) - require.NoError(err) + 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, agent.Name()) - assert.Contains(maximum.Argv, test.maximum, agent.Name()) + 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 index a603b83..3d796e2 100644 --- a/agentcli/claude.go +++ b/agentcli/claude.go @@ -8,15 +8,7 @@ import ( // NewClaude returns a Claude Code adapter after validating its configured // options. A zero Command uses "claude". func NewClaude(command Command) (Adapter, error) { - base, err := newAdapter(Claude, command, "claude", claudeOptionGrammar) - if err != nil { - return nil, err - } - return &claudeAdapter{adapter: base}, nil -} - -type claudeAdapter struct { - adapter + return newAdapter(Claude, command, "claude", claudeOptionGrammar, claudeCapabilities, buildClaude) } var claudeOptionGrammar = optionGrammar{ @@ -78,37 +70,12 @@ var claudeCapabilities = Capabilities{ DisableSessionStorage: true, } -func (a *claudeAdapter) Capabilities() Capabilities { - return cloneCapabilities(claudeCapabilities) -} - -func (a *claudeAdapter) Start(request Request) (Invocation, error) { - return a.build("", request) -} - -func (a *claudeAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} - -func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } +func buildClaude(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode args := a.base() - if err := validateSupportedRequest(Claude, mode, request, claudeCapabilities); err != nil { - return Invocation{}, err - } if err := validateClaudeRequest(mode, request); err != nil { return Invocation{}, err } - if err := a.validateConfiguredRequest(request); err != nil { - return Invocation{}, err - } if mode == NonInteractive { args = append(args, "--print") } @@ -127,7 +94,7 @@ func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, er args = append(args, "--model", request.Model) } if request.Reasoning != ReasoningDefault { - args = append(args, "--effort", claudeReasoning(request.Reasoning)) + args = append(args, "--effort", reasoningValue(request.Reasoning)) } if sessionID != "" { args = append(args, "--resume", sessionID) @@ -164,65 +131,13 @@ func (a *claudeAdapter) build(sessionID string, request Request) (Invocation, er return Invocation{Argv: args, Stdin: stdin}, nil } -func (a *claudeAdapter) validateConfiguredRequest(request Request) error { - checks := []struct { - requested bool - option string - hint string - names []string - }{ - {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, - {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured effort or leave Request.Reasoning empty", []string{"effort"}}, - {request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", []string{"output-format"}}, - {request.Schema.Inline != "", "JSON schema", "remove the configured schema or leave Request.Schema empty", []string{"json-schema"}}, - {request.Approval != ApprovalDefault, "approval mode", "remove the configured permission option or leave Request.Approval empty", []string{"permission-mode", "approval-bypass"}}, - {len(request.AllowedTools) != 0 || request.DisableBuiltInTools, "allowed tools", "remove configured tool selection or leave request tool selection empty", []string{"allowed-tools", "tools"}}, - {len(request.DeniedTools) != 0, "denied tools", "remove configured denied tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, - {request.DisableSkills || request.DisableHooks, "customization controls", "remove configured customization controls or leave request disable controls false", []string{"disable-skills", "safe-mode", "bare"}}, - {request.DisableSessionStorage, "session persistence", "remove --no-session-persistence or leave Request.DisableSessionStorage false", []string{"no-session-persistence"}}, - } - for _, check := range checks { - if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { - return err - } - } - return nil -} - func validateClaudeRequest(mode Mode, request Request) error { - if err := validateValues("allowed tools", request.AllowedTools); err != nil { - return err - } - if err := validateValues("denied tools", request.DeniedTools); err != nil { - return err - } if mode == Interactive && request.Prompt.Source == PromptStdin { return unsupported(Claude, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") } - if request.Provider != "" { - return unsupported(Claude, mode, "provider", request.Provider, "configure the provider outside Claude's argv") - } - if request.Autonomy != AutonomyDefault { - return unsupported(Claude, mode, "autonomy", string(request.Autonomy), "use approval and tool controls") - } - if request.Sandbox != SandboxDefault { - return unsupported(Claude, mode, "sandbox", string(request.Sandbox), "Claude permission modes do not provide a filesystem sandbox") - } 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 len(request.SkillPaths) != 0 { - return unsupported(Claude, mode, "skill paths", "", "install skills through Claude configuration") - } - if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles || request.DisableBuiltInMCPs { - return unsupported(Claude, mode, "Pi customization controls", "", "these controls are specific to Pi") - } - if request.DisableUserConfig || len(request.ConfigOverrides) != 0 { - return unsupported(Claude, mode, "Codex config controls", "", "use configured Claude options such as --settings") - } - if request.Schema.Path != "" || request.Schema.OutputPath != "" || request.Schema.Extension != "" || request.Schema.Fallback != "" || request.OutputPath != "" { - return unsupported(Claude, mode, "schema file or output path", "", "Claude accepts an inline schema and writes structured output to stdout") - } if mode == Interactive { if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { return unsupported(Claude, mode, "output format", string(request.OutputFormat), "use noninteractive mode") @@ -231,25 +146,5 @@ func validateClaudeRequest(mode Mode, request Request) error { return unsupported(Claude, mode, "automation-only output controls", "", "use noninteractive mode") } } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { - return unsupported(Claude, mode, "output format", string(request.OutputFormat), "request text, json, or jsonl") - } - if request.Reasoning != ReasoningDefault && claudeReasoning(request.Reasoning) == "" { - return unsupported(Claude, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalOnRequest && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { - return unsupported(Claude, mode, "approval mode", string(request.Approval), "request on-request, never, or bypass") - } return nil } - -func claudeReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/codex.go b/agentcli/codex.go index 22e518d..9cbe635 100644 --- a/agentcli/codex.go +++ b/agentcli/codex.go @@ -7,15 +7,7 @@ import ( // NewCodex returns a Codex CLI adapter after validating its configured global // options. A zero Command uses "codex". func NewCodex(command Command) (Adapter, error) { - base, err := newAdapter(Codex, command, "codex", codexOptionGrammar) - if err != nil { - return nil, err - } - return &codexAdapter{adapter: base}, nil -} - -type codexAdapter struct { - adapter + return newAdapter(Codex, command, "codex", codexOptionGrammar, codexCapabilities, buildCodex) } var codexOptionGrammar = optionGrammar{ @@ -58,38 +50,12 @@ var codexCapabilities = Capabilities{ ConfigOverrides: true, } -func (a *codexAdapter) Capabilities() Capabilities { - return cloneCapabilities(codexCapabilities) -} - -func (a *codexAdapter) Start(request Request) (Invocation, error) { - return a.build("", request) -} - -func (a *codexAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} - -func (a *codexAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } +func buildCodex(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode args := a.base() - if err := validateSupportedRequest(Codex, mode, request, codexCapabilities); err != nil { - return Invocation{}, err - } if err := validateCodexRequest(mode, request); err != nil { return Invocation{}, err } - if err := a.validateConfiguredRequest(request); err != nil { - return Invocation{}, err - } - if mode == NonInteractive { args = append(args, "exec") } @@ -116,7 +82,7 @@ func (a *codexAdapter) build(sessionID string, request Request) (Invocation, err args = append(args, "--model", request.Model) } if request.Reasoning != ReasoningDefault { - args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", codexReasoning(request.Reasoning))) + 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") @@ -159,51 +125,10 @@ func (a *codexAdapter) build(sessionID string, request Request) (Invocation, err return Invocation{Argv: args, Stdin: stdin}, nil } -func (a *codexAdapter) validateConfiguredRequest(request Request) error { - checks := []struct { - requested bool - option string - hint string - names []string - }{ - {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, - {request.Sandbox != SandboxDefault, "sandbox", "remove the configured sandbox or leave Request.Sandbox empty", []string{"sandbox"}}, - {request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", []string{"approval", "approve-for-me", "approval-bypass"}}, - {request.DisableHooks, "hook controls", "remove the configured hook option or leave Request.DisableHooks false", []string{"disable", "hook-trust"}}, - } - for _, check := range checks { - if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { - return err - } - } - return nil -} - func validateCodexRequest(mode Mode, request Request) error { - if err := validateValues("config overrides", request.ConfigOverrides); err != nil { - return err - } if mode == Interactive && request.Prompt.Source == PromptStdin { return unsupported(Codex, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") } - if request.Provider != "" { - return unsupported(Codex, mode, "provider", request.Provider, "put the provider in configured Codex options") - } - if request.Autonomy != AutonomyDefault { - return unsupported(Codex, mode, "autonomy", string(request.Autonomy), "use sandbox and approval controls") - } - if len(request.AllowedTools) != 0 || len(request.DeniedTools) != 0 || request.DisableBuiltInTools { - return unsupported(Codex, mode, "tool policy", "", "Codex has no equivalent per-invocation tool-list flags") - } - if len(request.SkillPaths) != 0 { - return unsupported(Codex, mode, "skill paths", "", "install skills through Codex configuration") - } - if request.DisableExtensions || request.DisablePromptTemplates || request.DisableThemes || request.DisableContextFiles || request.DisableBuiltInMCPs { - return unsupported(Codex, mode, "Pi customization controls", "", "these controls are specific to Pi") - } - if request.Schema.Inline != "" || request.Schema.Extension != "" || request.Schema.Fallback != "" { - return unsupported(Codex, mode, "inline JSON schema", "", "write the schema to a file and set Schema.Path") - } if request.OutputPath != "" && request.Schema.OutputPath != "" && request.OutputPath != request.Schema.OutputPath { return fmt.Errorf("agent %q received conflicting output paths", Codex) } @@ -218,34 +143,8 @@ func validateCodexRequest(mode Mode, request Request) error { return unsupported(Codex, mode, "automation-only config controls", "", "use noninteractive mode") } } - if request.OutputFormat == OutputJSON { - return unsupported(Codex, mode, "output format", string(OutputJSON), "Codex emits an event stream; request jsonl") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { - return unsupported(Codex, mode, "output format", string(request.OutputFormat), "request text or jsonl") - } - if request.Reasoning != ReasoningDefault && codexReasoning(request.Reasoning) == "" { - return unsupported(Codex, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } if request.Approval == ApprovalBypass && request.Sandbox != SandboxDefault { return fmt.Errorf("agent %q cannot combine approval bypass with sandbox %q", Codex, request.Sandbox) } - if request.Approval != ApprovalDefault && request.Approval != ApprovalOnRequest && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { - return unsupported(Codex, mode, "approval mode", string(request.Approval), "request on-request, never, or bypass") - } - if request.Sandbox != SandboxDefault && request.Sandbox != SandboxReadOnly && request.Sandbox != SandboxWorkspaceWrite && request.Sandbox != SandboxDangerFullAccess { - return unsupported(Codex, mode, "sandbox", string(request.Sandbox), "request read-only, workspace-write, or danger-full-access") - } return nil } - -func codexReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/copilot.go b/agentcli/copilot.go index 1b2b776..c11a3fe 100644 --- a/agentcli/copilot.go +++ b/agentcli/copilot.go @@ -7,15 +7,9 @@ import ( // NewCopilot returns a GitHub Copilot CLI adapter after validating configured // options. A zero Command uses "copilot". func NewCopilot(command Command) (Adapter, error) { - base, err := newAdapter(Copilot, command, "copilot", copilotOptionGrammar) - if err != nil { - return nil, err - } - return &copilotAdapter{adapter: base}, nil + return newAdapter(Copilot, command, "copilot", copilotOptionGrammar, copilotCapabilities, buildCopilot) } -type copilotAdapter struct{ adapter } - 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"), @@ -48,53 +42,9 @@ var copilotCapabilities = Capabilities{ DisableBuiltInMCPs: true, DisableContextFiles: true, } -func (a *copilotAdapter) Capabilities() Capabilities { return cloneCapabilities(copilotCapabilities) } -func (a *copilotAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *copilotAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *copilotAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Copilot, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Copilot, mode, request, copilotCapabilities); err != nil { - return Invocation{}, err - } - if err := validateCopilotRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Reasoning != ReasoningDefault, "reasoning", "remove the configured reasoning level or leave Request.Reasoning empty", "reasoning"); err != nil { - return Invocation{}, err - } - checks := []struct { - requested bool - option string - hint string - names []string - }{ - {request.OutputFormat != OutputDefault, "output format", "remove the configured output options or leave Request.OutputFormat empty", []string{"output-format", "stream"}}, - {request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", []string{"approval-bypass"}}, - {len(request.AllowedTools) != 0, "allowed tools", "remove configured allowed tools or leave Request.AllowedTools empty", []string{"allowed-tools"}}, - {len(request.DeniedTools) != 0, "denied tools", "remove configured denied tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, - {request.DisableBuiltInMCPs, "built-in MCP servers", "remove --disable-builtin-mcps or leave Request.DisableBuiltInMCPs false", []string{"disable-builtin-mcps"}}, - {request.DisableContextFiles, "custom instructions", "remove --no-custom-instructions or leave Request.DisableContextFiles false", []string{"no-custom-instructions"}}, - } - for _, check := range checks { - if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { - return Invocation{}, err - } - } +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") @@ -106,7 +56,7 @@ func (a *copilotAdapter) build(sessionID string, request Request) (Invocation, e args = append(args, "--model", request.Model) } if request.Reasoning != ReasoningDefault { - args = append(args, "--reasoning-effort", copilotReasoning(request.Reasoning)) + args = append(args, "--reasoning-effort", reasoningValue(request.Reasoning)) } if request.Approval == ApprovalBypass { args = append(args, "--allow-all") @@ -131,37 +81,3 @@ func (a *copilotAdapter) build(sessionID string, request Request) (Invocation, e } return Invocation{Argv: args}, nil } -func validateCopilotRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument { - return unsupported(Copilot, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt with --prompt") - } - if len(request.Prompt.Files) != 0 { - return unsupported(Copilot, NonInteractive, "prompt files", "", "use configured --attachment options") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { - return unsupported(Copilot, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { - return unsupported(Copilot, NonInteractive, "approval mode", string(request.Approval), "request bypass or use explicit tool lists") - } - if request.Reasoning != ReasoningDefault && copilotReasoning(request.Reasoning) == "" { - return unsupported(Copilot, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } - if err := validateValues("allowed tools", request.AllowedTools); err != nil { - return err - } - if err := validateValues("denied tools", request.DeniedTools); err != nil { - return err - } - return nil -} -func copilotReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/cursor.go b/agentcli/cursor.go index 7853d99..9dc4817 100644 --- a/agentcli/cursor.go +++ b/agentcli/cursor.go @@ -5,15 +5,9 @@ import "fmt" // NewCursor returns a Cursor Agent adapter after validating configured // options. A zero Command uses "agent". func NewCursor(command Command) (Adapter, error) { - base, err := newAdapter(Cursor, command, "agent", cursorOptionGrammar) - if err != nil { - return nil, err - } - return &cursorAdapter{adapter: base}, nil + return newAdapter(Cursor, command, "agent", cursorOptionGrammar, cursorCapabilities, buildCursor) } -type cursorAdapter struct{ adapter } - var cursorOptionGrammar = optionGrammar{ "--api-key": value("api-key"), "-H": value("header"), "--header": value("header"), "--stream-partial-output": flag("stream-partial-output"), "--model": value("model"), @@ -37,32 +31,7 @@ var cursorCapabilities = Capabilities{ ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, } -func (a *cursorAdapter) Capabilities() Capabilities { return cloneCapabilities(cursorCapabilities) } -func (a *cursorAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *cursorAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *cursorAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Cursor, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Cursor, mode, request, cursorCapabilities); err != nil { - return Invocation{}, err - } - if err := validateCursorRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { - return Invocation{}, err - } +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) @@ -89,15 +58,3 @@ func (a *cursorAdapter) build(sessionID string, request Request) (Invocation, er } return Invocation{Argv: args, Stdin: stdin}, nil } -func validateCursorRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { - return unsupported(Cursor, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { - return unsupported(Cursor, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { - return unsupported(Cursor, NonInteractive, "approval mode", string(request.Approval), "request never, bypass, or use the default") - } - return nil -} diff --git a/agentcli/droid.go b/agentcli/droid.go index 7b9c299..c9c5776 100644 --- a/agentcli/droid.go +++ b/agentcli/droid.go @@ -5,15 +5,9 @@ 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) { - base, err := newAdapter(Droid, command, "droid", droidOptionGrammar) - if err != nil { - return nil, err - } - return &droidAdapter{adapter: base}, nil + return newAdapter(Droid, command, "droid", droidOptionGrammar, droidCapabilities, buildDroid) } -type droidAdapter struct{ adapter } - 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"), @@ -39,49 +33,7 @@ var droidCapabilities = Capabilities{ DisableSkills: true, } -func (a *droidAdapter) Capabilities() Capabilities { return cloneCapabilities(droidCapabilities) } -func (a *droidAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *droidAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *droidAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Droid, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Droid, mode, request, droidCapabilities); err != nil { - return Invocation{}, err - } - if err := validateDroidRequest(request); err != nil { - return Invocation{}, err - } - checks := []struct { - requested bool - option string - hint string - names []string - }{ - {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, - {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured reasoning effort or leave Request.Reasoning empty", []string{"reasoning"}}, - {request.Autonomy != AutonomyDefault, "autonomy", "remove the configured autonomy level or leave Request.Autonomy empty", []string{"autonomy"}}, - {request.Approval != ApprovalDefault, "approval mode", "remove --skip-permissions-unsafe or leave Request.Approval empty", []string{"approval-bypass"}}, - {len(request.AllowedTools) != 0, "allowed tools", "remove configured restricted tools or leave Request.AllowedTools empty", []string{"allowed-tools"}}, - {len(request.DeniedTools) != 0, "denied tools", "remove configured disabled tools or leave Request.DeniedTools empty", []string{"denied-tools"}}, - {request.DisableSkills, "built-in skills", "remove --disable-builtin-skills or leave Request.DisableSkills false", []string{"disable-builtin-skills"}}, - {request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", []string{"output-format"}}, - } - for _, check := range checks { - if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { - return Invocation{}, err - } - } +func buildDroid(a *adapter, sessionID string, request Request) (Invocation, error) { args := []string{a.executable, "exec"} args = append(args, a.options...) if sessionID != "" { @@ -91,7 +43,7 @@ func (a *droidAdapter) build(sessionID string, request Request) (Invocation, err args = append(args, "--model", request.Model) } if request.Reasoning != ReasoningDefault { - args = append(args, "--reasoning-effort", droidReasoning(request.Reasoning)) + args = append(args, "--reasoning-effort", reasoningValue(request.Reasoning)) } if request.Autonomy != AutonomyDefault { args = append(args, "--auto", string(request.Autonomy)) @@ -120,37 +72,3 @@ func (a *droidAdapter) build(sessionID string, request Request) (Invocation, err } return Invocation{Argv: args, Stdin: stdin}, nil } -func validateDroidRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument && request.Prompt.Source != PromptStdin { - return unsupported(Droid, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt as an argument or over stdin") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { - return unsupported(Droid, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { - return unsupported(Droid, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") - } - if request.Autonomy != AutonomyDefault && request.Autonomy != AutonomyLow && request.Autonomy != AutonomyMedium && request.Autonomy != AutonomyHigh { - return unsupported(Droid, NonInteractive, "autonomy", string(request.Autonomy), "request low, medium, or high") - } - if request.Reasoning != ReasoningDefault && droidReasoning(request.Reasoning) == "" { - return unsupported(Droid, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } - if err := validateValues("allowed tools", request.AllowedTools); err != nil { - return err - } - if err := validateValues("denied tools", request.DeniedTools); err != nil { - return err - } - return nil -} -func droidReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/gemini.go b/agentcli/gemini.go index 32b556f..a5a240c 100644 --- a/agentcli/gemini.go +++ b/agentcli/gemini.go @@ -5,15 +5,9 @@ import "fmt" // NewGemini returns a Gemini CLI adapter after validating configured options. // A zero Command uses "gemini". func NewGemini(command Command) (Adapter, error) { - base, err := newAdapter(Gemini, command, "gemini", geminiOptionGrammar) - if err != nil { - return nil, err - } - return &geminiAdapter{adapter: base}, nil + return newAdapter(Gemini, command, "gemini", geminiOptionGrammar, geminiCapabilities, buildGemini) } -type geminiAdapter struct{ adapter } - 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"), @@ -42,38 +36,7 @@ var geminiCapabilities = Capabilities{ ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, } -func (a *geminiAdapter) Capabilities() Capabilities { return cloneCapabilities(geminiCapabilities) } -func (a *geminiAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *geminiAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *geminiAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Gemini, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Gemini, mode, request, geminiCapabilities); err != nil { - return Invocation{}, err - } - if err := validateGeminiRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.OutputFormat != OutputDefault, "output format", "remove the configured output format or leave Request.OutputFormat empty", "output-format"); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Approval != ApprovalDefault, "approval mode", "remove the configured approval option or leave Request.Approval empty", "approval-mode", "approval-bypass"); err != nil { - return Invocation{}, err - } +func buildGemini(a *adapter, sessionID string, request Request) (Invocation, error) { args := a.base() if request.OutputFormat == OutputJSON { args = append(args, "--output-format", "json") @@ -106,21 +69,3 @@ func (a *geminiAdapter) build(sessionID string, request Request) (Invocation, er } return Invocation{Argv: args, Stdin: stdin}, nil } -func validateGeminiRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument && request.Prompt.Source != PromptStdin { - return unsupported(Gemini, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt with --prompt or over stdin") - } - if len(request.Prompt.Files) != 0 { - return unsupported(Gemini, NonInteractive, "prompt files", "", "include file references in the prompt text") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSON && request.OutputFormat != OutputJSONL { - return unsupported(Gemini, NonInteractive, "output format", string(request.OutputFormat), "request text, json, or jsonl") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalNever && request.Approval != ApprovalBypass { - return unsupported(Gemini, NonInteractive, "approval mode", string(request.Approval), "request never, bypass, or use the default") - } - if request.Sandbox != SandboxDefault { - return unsupported(Gemini, NonInteractive, "sandbox", string(request.Sandbox), "Gemini's boolean sandbox flag does not map to a portable sandbox mode") - } - return nil -} diff --git a/agentcli/kilo.go b/agentcli/kilo.go index 74bca58..350a0b1 100644 --- a/agentcli/kilo.go +++ b/agentcli/kilo.go @@ -5,15 +5,9 @@ import "fmt" // NewKilo returns a Kilo adapter after validating configured global options. // A zero Command uses "kilo". func NewKilo(command Command) (Adapter, error) { - base, err := newAdapter(Kilo, command, "kilo", kiloOptionGrammar) - if err != nil { - return nil, err - } - return &kiloAdapter{adapter: base}, nil + return newAdapter(Kilo, command, "kilo", kiloOptionGrammar, kiloCapabilities, buildKilo) } -type kiloAdapter struct{ adapter } - var kiloOptionGrammar = optionGrammar{ "--print-logs": flag("print-logs"), "--log-level": value("log-level"), "-m": value("model"), "--model": value("model"), "--agent": value("agent"), @@ -35,32 +29,7 @@ var kiloCapabilities = Capabilities{ ApprovalModes: []ApprovalMode{ApprovalBypass}, } -func (a *kiloAdapter) Capabilities() Capabilities { return cloneCapabilities(kiloCapabilities) } -func (a *kiloAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *kiloAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *kiloAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Kilo, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Kilo, mode, request, kiloCapabilities); err != nil { - return Invocation{}, err - } - if err := validateKiloRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { - return Invocation{}, err - } +func buildKilo(a *adapter, sessionID string, request Request) (Invocation, error) { args := append(a.base(), "run") if request.OutputFormat == OutputJSONL { args = append(args, "--format", "json") @@ -74,7 +43,7 @@ func (a *kiloAdapter) build(sessionID string, request Request) (Invocation, erro if request.Approval == ApprovalBypass { args = append(args, "--auto") } - if variant := kiloReasoning(request.Reasoning); variant != "" { + if variant := reasoningValue(request.Reasoning); variant != "" { args = append(args, "--variant", variant) } args, stdin, err := appendPrompt(args, request.Prompt, "", false) @@ -83,28 +52,3 @@ func (a *kiloAdapter) build(sessionID string, request Request) (Invocation, erro } return Invocation{Argv: args, Stdin: stdin}, nil } -func validateKiloRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { - return unsupported(Kilo, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { - return unsupported(Kilo, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { - return unsupported(Kilo, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") - } - if request.Reasoning != ReasoningDefault && kiloReasoning(request.Reasoning) == "" { - return unsupported(Kilo, NonInteractive, "reasoning", string(request.Reasoning), "request low, high, xhigh, or maximum") - } - return nil -} -func kiloReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/kiro.go b/agentcli/kiro.go index f11ce3d..b04f709 100644 --- a/agentcli/kiro.go +++ b/agentcli/kiro.go @@ -5,15 +5,9 @@ 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) { - base, err := newAdapter(Kiro, command, "kiro-cli", kiroOptionGrammar) - if err != nil { - return nil, err - } - return &kiroAdapter{adapter: base}, nil + return newAdapter(Kiro, command, "kiro-cli", kiroOptionGrammar, kiroCapabilities, buildKiro) } -type kiroAdapter struct{ adapter } - var kiroOptionGrammar = optionGrammar{ "--verbose": flag("verbose"), "-v": flag("verbose"), "--agent": value("agent"), "--require-mcp-startup": flag("require-mcp-startup"), "--wrap": value("wrap"), @@ -38,38 +32,7 @@ var kiroCapabilities = Capabilities{ Tools: ToolCapabilities{AllowList: true}, } -func (a *kiroAdapter) Capabilities() Capabilities { return cloneCapabilities(kiroCapabilities) } -func (a *kiroAdapter) Start(request Request) (Invocation, error) { return a.build("", request) } -func (a *kiroAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} -func (a *kiroAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(Kiro, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(Kiro, mode, request, kiroCapabilities); err != nil { - return Invocation{}, err - } - if err := validateKiroRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Reasoning != ReasoningDefault, "reasoning", "remove the configured effort or leave Request.Reasoning empty", "reasoning"); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Approval != ApprovalDefault, "approval mode", "remove --trust-all-tools or leave Request.Approval empty", "approval-bypass"); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(len(request.AllowedTools) != 0, "allowed tools", "remove configured trusted tools or leave Request.AllowedTools empty", "allowed-tools"); err != nil { - return Invocation{}, err - } +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") @@ -77,7 +40,7 @@ func (a *kiroAdapter) build(sessionID string, request Request) (Invocation, erro args = append(args, "--resume-id", sessionID) } if request.Reasoning != ReasoningDefault { - args = append(args, "--effort", kiroReasoning(request.Reasoning)) + args = append(args, "--effort", reasoningValue(request.Reasoning)) } if request.Approval == ApprovalBypass { args = append(args, "--trust-all-tools") @@ -90,42 +53,11 @@ func (a *kiroAdapter) build(sessionID string, request Request) (Invocation, erro return Invocation{}, err } args = append(args, "--") - args, _, err = appendPrompt(args, request.Prompt, "", false) + promptArgs, _, err := appendPrompt(args, request.Prompt, "", false) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Kiro, err) } - return Invocation{Argv: args}, nil + return Invocation{Argv: promptArgs}, nil } return Invocation{Argv: args}, nil } -func validateKiroRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptArgument { - return unsupported(Kiro, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt as an argument") - } - if len(request.Prompt.Files) != 0 { - return unsupported(Kiro, NonInteractive, "prompt files", "", "include file references in the prompt text") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { - return unsupported(Kiro, NonInteractive, "output format", string(request.OutputFormat), "request text") - } - if request.Approval != ApprovalDefault && request.Approval != ApprovalBypass { - return unsupported(Kiro, NonInteractive, "approval mode", string(request.Approval), "request bypass or use the default") - } - if request.Reasoning != ReasoningDefault && kiroReasoning(request.Reasoning) == "" { - return unsupported(Kiro, NonInteractive, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } - if err := validateValues("allowed tools", request.AllowedTools); err != nil { - return err - } - return nil -} -func kiroReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} diff --git a/agentcli/opencode.go b/agentcli/opencode.go index cbd9fce..60e86f8 100644 --- a/agentcli/opencode.go +++ b/agentcli/opencode.go @@ -5,15 +5,9 @@ import "fmt" // NewOpenCode returns an OpenCode adapter after validating configured global // options. A zero Command uses "opencode". func NewOpenCode(command Command) (Adapter, error) { - base, err := newAdapter(OpenCode, command, "opencode", openCodeOptionGrammar) - if err != nil { - return nil, err - } - return &openCodeAdapter{adapter: base}, nil + return newAdapter(OpenCode, command, "opencode", openCodeOptionGrammar, openCodeCapabilities, buildOpenCode) } -type openCodeAdapter struct{ adapter } - var openCodeOptionGrammar = optionGrammar{ "--print-logs": flag("print-logs"), "--log-level": value("log-level"), "--pure": flag("pure"), "-m": value("model"), "--model": value("model"), @@ -34,35 +28,7 @@ var openCodeCapabilities = Capabilities{ Model: true, } -func (a *openCodeAdapter) Capabilities() Capabilities { return cloneCapabilities(openCodeCapabilities) } -func (a *openCodeAdapter) Start(request Request) (Invocation, error) { - return a.build("", request) -} -func (a *openCodeAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} - -func (a *openCodeAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } - if mode != NonInteractive { - return Invocation{}, unsupported(OpenCode, mode, "mode", string(mode), "request noninteractive mode") - } - if err := validateSupportedRequest(OpenCode, mode, request, openCodeCapabilities); err != nil { - return Invocation{}, err - } - if err := validateOpenCodeRequest(request); err != nil { - return Invocation{}, err - } - if err := a.rejectsConfigured(request.Model != "", "model", "remove the configured model or leave Request.Model empty", "model"); err != nil { - return Invocation{}, err - } +func buildOpenCode(a *adapter, sessionID string, request Request) (Invocation, error) { args := append(a.base(), "run") if request.OutputFormat == OutputJSONL { args = append(args, "--format", "json") @@ -79,13 +45,3 @@ func (a *openCodeAdapter) build(sessionID string, request Request) (Invocation, } return Invocation{Argv: args, Stdin: stdin}, nil } - -func validateOpenCodeRequest(request Request) error { - if request.Prompt.Source != PromptNone && request.Prompt.Source != PromptStdin { - return unsupported(OpenCode, NonInteractive, "prompt transport", string(request.Prompt.Source), "send the prompt over stdin") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { - return unsupported(OpenCode, NonInteractive, "output format", string(request.OutputFormat), "request text or jsonl") - } - return nil -} diff --git a/agentcli/pi.go b/agentcli/pi.go index 6886e38..3e2582d 100644 --- a/agentcli/pi.go +++ b/agentcli/pi.go @@ -8,15 +8,7 @@ import ( // NewPi returns a Pi adapter after validating its configured options. A zero // Command uses "pi". func NewPi(command Command) (Adapter, error) { - base, err := newAdapter(Pi, command, "pi", piOptionGrammar) - if err != nil { - return nil, err - } - return &piAdapter{adapter: base}, nil -} - -type piAdapter struct { - adapter + return newAdapter(Pi, command, "pi", piOptionGrammar, piCapabilities, buildPi) } var piOptionGrammar = optionGrammar{ @@ -74,37 +66,12 @@ var piCapabilities = Capabilities{ DisableSessionStorage: true, } -func (a *piAdapter) Capabilities() Capabilities { - return cloneCapabilities(piCapabilities) -} - -func (a *piAdapter) Start(request Request) (Invocation, error) { - return a.build("", request) -} - -func (a *piAdapter) Resume(sessionID string, request Request) (Invocation, error) { - sessionID, err := validateSessionID(sessionID) - if err != nil { - return Invocation{}, err - } - return a.build(sessionID, request) -} - -func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) { - mode, err := invocationMode(request.Mode) - if err != nil { - return Invocation{}, err - } +func buildPi(a *adapter, sessionID string, request Request) (Invocation, error) { + mode := request.Mode args := a.base() - if err := validateSupportedRequest(Pi, mode, request, piCapabilities); err != nil { - return Invocation{}, err - } if err := validatePiRequest(mode, request); err != nil { return Invocation{}, err } - if err := a.validateConfiguredRequest(request); err != nil { - return Invocation{}, err - } if request.DisableSessionStorage { args = append(args, "--no-session") } @@ -157,7 +124,7 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) args = append(args, "--model", request.Model) } if request.Reasoning != ReasoningDefault { - args = append(args, "--thinking", piReasoning(request.Reasoning)) + args = append(args, "--thinking", reasoningValue(request.Reasoning)) } if len(request.AllowedTools) != 0 { args = append(args, "--tools", strings.Join(request.AllowedTools, ",")) @@ -165,6 +132,9 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) if len(request.DeniedTools) != 0 { args = append(args, "--exclude-tools", strings.Join(request.DeniedTools, ",")) } + if request.Prompt.Source == PromptArgument { + args = append(args, "--") + } args, stdin, err := appendPrompt(args, request.Prompt, "", true) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Pi, err) @@ -172,63 +142,7 @@ func (a *piAdapter) build(sessionID string, request Request) (Invocation, error) return Invocation{Argv: args, Stdin: stdin}, nil } -func (a *piAdapter) validateConfiguredRequest(request Request) error { - checks := []struct { - requested bool - option string - hint string - names []string - }{ - {request.Provider != "", "provider", "remove the configured provider or leave Request.Provider empty", []string{"provider"}}, - {request.Model != "", "model", "remove the configured model or leave Request.Model empty", []string{"model"}}, - {request.Reasoning != ReasoningDefault, "reasoning", "remove the configured thinking level or leave Request.Reasoning empty", []string{"thinking"}}, - {request.OutputFormat != OutputDefault, "output format", "remove the configured mode or leave Request.OutputFormat empty", []string{"mode"}}, - {request.Schema.Inline != "", "JSON schema", "remove configured schema options or leave Request.Schema empty", []string{"json-schema", "json-output", "json-fallback"}}, - {request.DisableBuiltInTools || len(request.AllowedTools) != 0, "allowed tools", "remove configured tool selection or leave request tool selection empty", []string{"no-tools", "no-builtin-tools", "tools"}}, - {len(request.DeniedTools) != 0, "denied tools", "remove configured excluded tools or leave Request.DeniedTools empty", []string{"exclude-tools"}}, - {len(request.SkillPaths) != 0 || request.DisableSkills, "skills", "remove configured skill options or leave request skill controls empty", []string{"skill", "no-skills"}}, - {request.DisableHooks || request.DisableExtensions, "extensions", "remove configured extension controls or leave request disable controls false", []string{"extension", "no-extensions"}}, - {request.DisablePromptTemplates, "prompt templates", "remove configured prompt-template controls or leave Request.DisablePromptTemplates false", []string{"prompt-template", "no-prompt-templates"}}, - {request.DisableThemes, "themes", "remove configured theme controls or leave Request.DisableThemes false", []string{"theme", "use-theme", "no-themes"}}, - {request.DisableContextFiles, "context files", "remove --no-context-files or leave Request.DisableContextFiles false", []string{"no-context-files"}}, - {request.DisableSessionStorage, "session persistence", "remove --no-session or leave Request.DisableSessionStorage false", []string{"no-session"}}, - } - for _, check := range checks { - if err := a.rejectsConfigured(check.requested, check.option, check.hint, check.names...); err != nil { - return err - } - } - return nil -} - func validatePiRequest(mode Mode, request Request) error { - if err := validateValues("allowed tools", request.AllowedTools); err != nil { - return err - } - if err := validateValues("denied tools", request.DeniedTools); err != nil { - return err - } - if err := validateValues("skill paths", request.SkillPaths); err != nil { - return err - } - if mode == Interactive && request.Prompt.Source == PromptStdin { - return unsupported(Pi, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") - } - if mode == NonInteractive && request.Prompt.Source == PromptStdin { - return unsupported(Pi, mode, "stdin prompt", "", "send the prompt as an argument or file reference") - } - if request.Sandbox != SandboxDefault { - return unsupported(Pi, mode, "sandbox", string(request.Sandbox), "restrict Pi through its tool allowlist or an external sandbox") - } - if request.Autonomy != AutonomyDefault { - return unsupported(Pi, mode, "autonomy", string(request.Autonomy), "use tool controls") - } - if request.Approval != ApprovalDefault { - return unsupported(Pi, mode, "approval mode", string(request.Approval), "Pi exposes project trust, not tool approval policy") - } - if request.DisableBuiltInMCPs || request.DisableUserConfig || len(request.ConfigOverrides) != 0 { - return unsupported(Pi, mode, "Codex config controls", "", "use configured Pi options") - } if request.OutputPath != "" || request.Schema.Path != "" { return unsupported(Pi, mode, "output path", "", "Pi output files require its JSON-schema extension") } @@ -248,25 +162,5 @@ func validatePiRequest(mode Mode, request Request) error { if mode == Interactive && request.OutputFormat != OutputDefault && request.OutputFormat != OutputText { return unsupported(Pi, mode, "output format", string(request.OutputFormat), "use noninteractive mode for jsonl") } - if request.OutputFormat == OutputJSON { - return unsupported(Pi, mode, "output format", string(OutputJSON), "Pi's native event output is jsonl") - } - if request.OutputFormat != OutputDefault && request.OutputFormat != OutputText && request.OutputFormat != OutputJSONL { - return unsupported(Pi, mode, "output format", string(request.OutputFormat), "request text or jsonl") - } - if request.Reasoning != ReasoningDefault && piReasoning(request.Reasoning) == "" { - return unsupported(Pi, mode, "reasoning", string(request.Reasoning), "request low, medium, high, xhigh, or maximum") - } return nil } - -func piReasoning(level ReasoningLevel) string { - switch level { - case ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh: - return string(level) - case ReasoningMaximum: - return "max" - default: - return "" - } -} From 4dafcc5b540c0c19c02fa9fb2f7581c0f838ef5e Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 13:07:39 -0400 Subject: [PATCH 7/9] Allow Windows process teardown in timeout test The Windows CI runner exceeded a one-second wall-clock assertion even though the test configures short probe timeouts. Process teardown and runner scheduling are part of that measurement, so the old limit was too tight for the behavior under test. Keep the bound well below the sleeping fixture's ten-second runtime. The test still detects a stalled probe without failing on normal Windows cleanup overhead. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- git/cmd/gitcmd_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) { From 180cc09fc4e7f3fae2cc9de49eeb8c1f269304fd Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 14:22:51 -0400 Subject: [PATCH 8/9] Reject omitted prompts and conflicting Pi extensions Kiro could return a successful invocation after dropping prompt text when the caller omitted its transport. Validate the prompt before choosing the argument branch so a malformed request fails instead of losing user input. Pi's JSON Schema support supplies an extension as part of the request. Treat a configured extension as the same setting so callers cannot produce an invocation with two competing extension values. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/agentcli.go | 2 +- agentcli/agentcli_test.go | 6 ++++++ agentcli/kiro.go | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/agentcli/agentcli.go b/agentcli/agentcli.go index bda94e6..b52486c 100644 --- a/agentcli/agentcli.go +++ b/agentcli/agentcli.go @@ -394,7 +394,7 @@ func (a adapter) validateConfiguredRequest(request Request) error { {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{"json-schema", "json-output", "json-fallback"}}, + {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"}}, diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go index f0768ec..1d28588 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -118,6 +118,11 @@ func TestUnsupportedRequestsReturnTypedErrors(t *testing.T) { require.Error(err) } +func TestKiroRejectsPromptWithoutSource(t *testing.T) { + _, err := mustAgent(t, agentcli.Kiro, agentcli.Command{}).Start(agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: "review this"}}) + require.Error(t, err) +} + func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { t.Parallel() assert := assert.New(t) @@ -191,6 +196,7 @@ func TestConfiguredOptionsConflictWithRequest(t *testing.T) { {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"}}, diff --git a/agentcli/kiro.go b/agentcli/kiro.go index b04f709..51f35f8 100644 --- a/agentcli/kiro.go +++ b/agentcli/kiro.go @@ -33,6 +33,9 @@ var kiroCapabilities = Capabilities{ } func buildKiro(a *adapter, sessionID string, request Request) (Invocation, error) { + if err := validatePrompt(request.Prompt); err != nil { + return Invocation{}, err + } args := []string{a.executable, "chat"} args = append(args, a.options...) args = append(args, "--no-interactive") @@ -49,9 +52,6 @@ func buildKiro(a *adapter, sessionID string, request Request) (Invocation, error args = append(args, "--trust-tools", joinComma(request.AllowedTools)) } if request.Prompt.Source == PromptArgument { - if err := validatePrompt(request.Prompt); err != nil { - return Invocation{}, err - } args = append(args, "--") promptArgs, _, err := appendPrompt(args, request.Prompt, "", false) if err != nil { From a68f8e5d8a646833da95c3f31383d4f1064b3340 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 7 Sep 2026 14:32:19 -0400 Subject: [PATCH 9/9] Make prompt delivery an adapter concern Callers should not need to know whether each agent expects prompt text in an argument or on standard input. Requiring a prompt source duplicated adapter knowledge and made a useful zero value invalid. Let an empty Prompt mean no prompt. When text is present, each adapter now uses the transport required by its CLI and invocation mode. Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- agentcli/README.md | 18 ++++----- agentcli/agentcli.go | 83 ++++++++++++--------------------------- agentcli/agentcli_test.go | 31 ++++++--------- agentcli/claude.go | 12 +++--- agentcli/codex.go | 15 ++++--- agentcli/copilot.go | 8 ++-- agentcli/cursor.go | 4 +- agentcli/droid.go | 4 +- agentcli/example_test.go | 2 +- agentcli/gemini.go | 12 ++---- agentcli/kilo.go | 3 +- agentcli/kiro.go | 8 +--- agentcli/opencode.go | 3 +- agentcli/pi.go | 7 ++-- 14 files changed, 83 insertions(+), 127 deletions(-) diff --git a/agentcli/README.md b/agentcli/README.md index b3a15f8..efddf6a 100644 --- a/agentcli/README.md +++ b/agentcli/README.md @@ -8,8 +8,9 @@ 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 every -prompt in `Request.Prompt`; use `Resume` for a saved session identity. +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 @@ -28,11 +29,10 @@ 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. The adapter reports whether it expects the prompt in argv or stdin: +RoboRev can select an adapter by name and request a noninteractive event stream: ```go -prompt := agentcli.Prompt{Source: agentcli.PromptStdin, Text: reviewPrompt} +prompt := agentcli.Prompt{Text: reviewPrompt} agent, err := agentcli.New(agentcli.Codex, agentcli.Command{Executable: configuredExecutable}) if err != nil { return err @@ -63,15 +63,15 @@ shape that RoboRev currently needs. | Agent | Modes | Prompt | Resume | Output | Reasoning | | --- | --- | --- | --- | --- | --- | -| Codex | interactive, noninteractive | argument, stdin | `resume ID`, `exec resume ID` | text, JSONL | low, medium, high, xhigh, maximum | -| Claude Code | interactive, noninteractive | argument, stdin | `--resume ID` | text, JSON, JSONL | low, medium, high, xhigh, maximum | -| Gemini | noninteractive | `--prompt`, stdin appended to `--prompt` | `--resume ID` | text, JSON, JSONL | none | +| 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 | argument, stdin | `exec --session-id ID` | text, JSON, 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 diff --git a/agentcli/agentcli.go b/agentcli/agentcli.go index b52486c..dd739a1 100644 --- a/agentcli/agentcli.go +++ b/agentcli/agentcli.go @@ -83,21 +83,12 @@ const ( OutputJSONL OutputFormat = "jsonl" ) -// PromptSource describes how a prompt reaches the agent process. -type PromptSource string - -const ( - PromptNone PromptSource = "" - PromptArgument PromptSource = "argument" - PromptStdin PromptSource = "stdin" -) - -// Prompt is an optional initial or resumed-turn prompt. Files are supported by -// agents whose command line has a native file-reference syntax. +// 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 { - Source PromptSource - Text string - Files []string + Text string + Files []string } // ReasoningLevel is the portable subset of agent reasoning controls. @@ -206,7 +197,6 @@ type ToolCapabilities struct { // Capabilities reports which Request fields an adapter can honor. type Capabilities struct { Modes []Mode - PromptSources []PromptSource PromptFiles bool Resume bool OutputFormats []OutputFormat @@ -436,22 +426,6 @@ func validateSessionID(sessionID string) (string, error) { return sessionID, nil } -func validatePrompt(prompt Prompt) error { - switch prompt.Source { - case PromptNone: - if prompt.Text != "" || len(prompt.Files) != 0 { - return fmt.Errorf("agent prompt source is required when prompt content is set") - } - case PromptArgument, PromptStdin: - default: - return fmt.Errorf("unknown agent prompt source %q", prompt.Source) - } - if prompt.Source == PromptStdin && len(prompt.Files) != 0 { - return fmt.Errorf("agent prompt files require argument delivery") - } - return nil -} - func validateValues(option string, values []string) error { for _, value := range values { if strings.TrimSpace(value) == "" { @@ -483,7 +457,6 @@ func validateSupportedRequest(name Name, mode Mode, request Request, capabilitie option string value string }{ - {request.Prompt.Source != PromptNone, slices.Contains(capabilities.PromptSources, request.Prompt.Source), "prompt transport", string(request.Prompt.Source)}, {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", ""}, @@ -520,40 +493,34 @@ func validateSupportedRequest(name Name, mode Mode, request Request, capabilitie return nil } -func appendPrompt(args []string, prompt Prompt, stdinMarker string, supportsFiles bool) ([]string, *string, error) { - if err := validatePrompt(prompt); err != nil { - return nil, nil, err - } +func appendArgumentPrompt(args []string, prompt Prompt, supportsFiles bool) ([]string, error) { if len(prompt.Files) != 0 && !supportsFiles { - return nil, nil, fmt.Errorf("agent does not support prompt file arguments") + return nil, fmt.Errorf("agent does not support prompt file arguments") } - switch prompt.Source { - case PromptNone: - return args, nil, nil - case PromptStdin: - if stdinMarker != "" { - args = append(args, stdinMarker) - } - return args, new(prompt.Text), nil - case PromptArgument: - for _, file := range prompt.Files { - if strings.TrimSpace(file) == "" { - return nil, nil, fmt.Errorf("agent prompt file path is empty") - } - args = append(args, "@"+file) + for _, file := range prompt.Files { + if strings.TrimSpace(file) == "" { + return nil, fmt.Errorf("agent prompt file path is empty") } - if prompt.Text != "" { - args = append(args, prompt.Text) - } - return args, nil, nil - default: - panic("prompt source validated above") + 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.PromptSources = slices.Clone(capabilities.PromptSources) capabilities.OutputFormats = slices.Clone(capabilities.OutputFormats) capabilities.ReasoningLevels = slices.Clone(capabilities.ReasoningLevels) capabilities.SandboxModes = slices.Clone(capabilities.SandboxModes) diff --git a/agentcli/agentcli_test.go b/agentcli/agentcli_test.go index 1d28588..7cdce23 100644 --- a/agentcli/agentcli_test.go +++ b/agentcli/agentcli_test.go @@ -39,16 +39,16 @@ func TestInvocationContracts(t *testing.T) { request agentcli.Request want []string }{ - {agentcli.Codex, "thread-id", agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptStdin, 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{Source: agentcli.PromptArgument, 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"}}, + {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) { @@ -65,7 +65,7 @@ func TestInvocationContracts(t *testing.T) { } require.NoError(err) assert.Equal(test.want, got.Argv) - if test.request.Prompt.Source == agentcli.PromptStdin { + 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 { @@ -105,7 +105,6 @@ func TestUnsupportedRequestsReturnTypedErrors(t *testing.T) { {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"}, - {agentcli.Codex, agentcli.Request{Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: "prompt"}}, "stdin prompt"}, } for _, test := range tests { _, err := mustAgent(t, test.name, agentcli.Command{}).Start(test.request) @@ -118,11 +117,6 @@ func TestUnsupportedRequestsReturnTypedErrors(t *testing.T) { require.Error(err) } -func TestKiroRejectsPromptWithoutSource(t *testing.T) { - _, err := mustAgent(t, agentcli.Kiro, agentcli.Command{}).Start(agentcli.Request{Mode: agentcli.NonInteractive, Prompt: agentcli.Prompt{Text: "review this"}}) - require.Error(t, err) -} - func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { t.Parallel() assert := assert.New(t) @@ -133,9 +127,8 @@ func TestCapabilitiesAreExplicitAndIndependent(t *testing.T) { 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.PromptSources[0], got.ReasoningLevels[0] = "changed", "changed", "changed" + got.Modes[0], got.ReasoningLevels[0] = "changed", "changed" assert.Equal(agentcli.Interactive, codex.Capabilities().Modes[0]) - assert.Equal(agentcli.PromptArgument, codex.Capabilities().PromptSources[0]) assert.Equal(agentcli.ReasoningLow, codex.Capabilities().ReasoningLevels[0]) } diff --git a/agentcli/claude.go b/agentcli/claude.go index 3d796e2..6894083 100644 --- a/agentcli/claude.go +++ b/agentcli/claude.go @@ -57,7 +57,6 @@ var claudeOptionGrammar = optionGrammar{ var claudeCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, - PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, JSONSchemaInline: true, @@ -124,7 +123,13 @@ func buildClaude(a *adapter, sessionID string, request Request) (Invocation, err if len(request.DeniedTools) != 0 { args = append(args, "--disallowedTools", strings.Join(request.DeniedTools, ",")) } - args, stdin, err := appendPrompt(args, request.Prompt, "", false) + 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) } @@ -132,9 +137,6 @@ func buildClaude(a *adapter, sessionID string, request Request) (Invocation, err } func validateClaudeRequest(mode Mode, request Request) error { - if mode == Interactive && request.Prompt.Source == PromptStdin { - return unsupported(Claude, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") - } if request.DisableBuiltInTools && len(request.AllowedTools) != 0 { return fmt.Errorf("agent %q cannot disable built-in tools and set an allowed tool list", Claude) } diff --git a/agentcli/codex.go b/agentcli/codex.go index 9cbe635..e66d441 100644 --- a/agentcli/codex.go +++ b/agentcli/codex.go @@ -34,7 +34,6 @@ var codexOptionGrammar = optionGrammar{ var codexCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, - PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, JSONSchemaPath: true, @@ -118,7 +117,16 @@ func buildCodex(a *adapter, sessionID string, request Request) (Invocation, erro if sessionID != "" { args = append(args, sessionID) } - args, stdin, err := appendPrompt(args, request.Prompt, "-", false) + 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) } @@ -126,9 +134,6 @@ func buildCodex(a *adapter, sessionID string, request Request) (Invocation, erro } func validateCodexRequest(mode Mode, request Request) error { - if mode == Interactive && request.Prompt.Source == PromptStdin { - return unsupported(Codex, mode, "stdin prompt", "", "use argument delivery for an interactive prompt") - } if request.OutputPath != "" && request.Schema.OutputPath != "" && request.OutputPath != request.Schema.OutputPath { return fmt.Errorf("agent %q received conflicting output paths", Codex) } diff --git a/agentcli/copilot.go b/agentcli/copilot.go index c11a3fe..d9502cf 100644 --- a/agentcli/copilot.go +++ b/agentcli/copilot.go @@ -33,7 +33,7 @@ var copilotOptionGrammar = optionGrammar{ } var copilotCapabilities = Capabilities{ - Modes: []Mode{NonInteractive}, PromptSources: []PromptSource{PromptArgument}, Resume: true, + Modes: []Mode{NonInteractive}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, Model: true, ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, @@ -73,10 +73,10 @@ func buildCopilot(a *adapter, sessionID string, request Request) (Invocation, er if request.DisableContextFiles { args = append(args, "--no-custom-instructions") } - if err := validatePrompt(request.Prompt); err != nil { - return Invocation{}, fmt.Errorf("build %s invocation: %w", Copilot, err) + if len(request.Prompt.Files) != 0 { + return Invocation{}, fmt.Errorf("build %s invocation: agent does not support prompt file arguments", Copilot) } - if request.Prompt.Source == PromptArgument { + 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 index 9dc4817..f0c40e8 100644 --- a/agentcli/cursor.go +++ b/agentcli/cursor.go @@ -26,7 +26,7 @@ var cursorOptionGrammar = optionGrammar{ } var cursorCapabilities = Capabilities{ - Modes: []Mode{NonInteractive}, PromptSources: []PromptSource{PromptStdin}, Resume: true, + Modes: []Mode{NonInteractive}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, } @@ -52,7 +52,7 @@ func buildCursor(a *adapter, sessionID string, request Request) (Invocation, err case ApprovalBypass: args = append(args, "--force") } - args, stdin, err := appendPrompt(args, request.Prompt, "", false) + stdin, err := stdinPrompt(request.Prompt) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Cursor, err) } diff --git a/agentcli/droid.go b/agentcli/droid.go index c9c5776..45d44fb 100644 --- a/agentcli/droid.go +++ b/agentcli/droid.go @@ -25,7 +25,7 @@ var droidOptionGrammar = optionGrammar{ } var droidCapabilities = Capabilities{ - Modes: []Mode{NonInteractive}, PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, + Modes: []Mode{NonInteractive}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, AutonomyLevels: []AutonomyLevel{AutonomyLow, AutonomyMedium, AutonomyHigh}, @@ -66,7 +66,7 @@ func buildDroid(a *adapter, sessionID string, request Request) (Invocation, erro case OutputJSONL: args = append(args, "--output-format", "stream-json") } - args, stdin, err := appendPrompt(args, request.Prompt, "", false) + stdin, err := stdinPrompt(request.Prompt) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Droid, err) } diff --git a/agentcli/example_test.go b/agentcli/example_test.go index 3210ea9..182e877 100644 --- a/agentcli/example_test.go +++ b/agentcli/example_test.go @@ -31,7 +31,7 @@ func ExampleAdapter_Start() { prompt := "review this change" invocation, err := agent.Start(agentcli.Request{ Mode: agentcli.NonInteractive, - Prompt: agentcli.Prompt{Source: agentcli.PromptStdin, Text: prompt}, + Prompt: agentcli.Prompt{Text: prompt}, OutputFormat: agentcli.OutputJSONL, }) if err != nil { diff --git a/agentcli/gemini.go b/agentcli/gemini.go index a5a240c..b3e61ff 100644 --- a/agentcli/gemini.go +++ b/agentcli/gemini.go @@ -31,7 +31,7 @@ var geminiOptionGrammar = optionGrammar{ } var geminiCapabilities = Capabilities{ - Modes: []Mode{NonInteractive}, PromptSources: []PromptSource{PromptArgument, PromptStdin}, Resume: true, + Modes: []Mode{NonInteractive}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSON, OutputJSONL}, Model: true, ApprovalModes: []ApprovalMode{ApprovalNever, ApprovalBypass}, } @@ -56,16 +56,12 @@ func buildGemini(a *adapter, sessionID string, request Request) (Invocation, err case ApprovalBypass: args = append(args, "--approval-mode", "yolo") } - if err := validatePrompt(request.Prompt); err != nil { + stdin, err := stdinPrompt(request.Prompt) + if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Gemini, err) } - var stdin *string - switch request.Prompt.Source { - case PromptArgument: - args = append(args, "--prompt", request.Prompt.Text) - case PromptStdin: + if stdin != nil { args = append(args, "--prompt", "") - stdin = new(request.Prompt.Text) } return Invocation{Argv: args, Stdin: stdin}, nil } diff --git a/agentcli/kilo.go b/agentcli/kilo.go index 350a0b1..675bff0 100644 --- a/agentcli/kilo.go +++ b/agentcli/kilo.go @@ -21,7 +21,6 @@ var kiloOptionGrammar = optionGrammar{ var kiloCapabilities = Capabilities{ Modes: []Mode{NonInteractive}, - PromptSources: []PromptSource{PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, Model: true, @@ -46,7 +45,7 @@ func buildKilo(a *adapter, sessionID string, request Request) (Invocation, error if variant := reasoningValue(request.Reasoning); variant != "" { args = append(args, "--variant", variant) } - args, stdin, err := appendPrompt(args, request.Prompt, "", false) + stdin, err := stdinPrompt(request.Prompt) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Kilo, err) } diff --git a/agentcli/kiro.go b/agentcli/kiro.go index 51f35f8..017b6e2 100644 --- a/agentcli/kiro.go +++ b/agentcli/kiro.go @@ -24,7 +24,6 @@ var kiroOptionGrammar = optionGrammar{ var kiroCapabilities = Capabilities{ Modes: []Mode{NonInteractive}, - PromptSources: []PromptSource{PromptArgument}, Resume: true, OutputFormats: []OutputFormat{OutputText}, ReasoningLevels: []ReasoningLevel{ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, ReasoningMaximum}, @@ -33,9 +32,6 @@ var kiroCapabilities = Capabilities{ } func buildKiro(a *adapter, sessionID string, request Request) (Invocation, error) { - if err := validatePrompt(request.Prompt); err != nil { - return Invocation{}, err - } args := []string{a.executable, "chat"} args = append(args, a.options...) args = append(args, "--no-interactive") @@ -51,9 +47,9 @@ func buildKiro(a *adapter, sessionID string, request Request) (Invocation, error if len(request.AllowedTools) != 0 { args = append(args, "--trust-tools", joinComma(request.AllowedTools)) } - if request.Prompt.Source == PromptArgument { + if request.Prompt.Text != "" || len(request.Prompt.Files) != 0 { args = append(args, "--") - promptArgs, _, err := appendPrompt(args, request.Prompt, "", false) + promptArgs, err := appendArgumentPrompt(args, request.Prompt, false) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Kiro, err) } diff --git a/agentcli/opencode.go b/agentcli/opencode.go index 60e86f8..90ec81e 100644 --- a/agentcli/opencode.go +++ b/agentcli/opencode.go @@ -22,7 +22,6 @@ var openCodeOptionGrammar = optionGrammar{ var openCodeCapabilities = Capabilities{ Modes: []Mode{NonInteractive}, - PromptSources: []PromptSource{PromptStdin}, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, Model: true, @@ -39,7 +38,7 @@ func buildOpenCode(a *adapter, sessionID string, request Request) (Invocation, e if request.Model != "" { args = append(args, "--model", request.Model) } - args, stdin, err := appendPrompt(args, request.Prompt, "", false) + stdin, err := stdinPrompt(request.Prompt) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", OpenCode, err) } diff --git a/agentcli/pi.go b/agentcli/pi.go index 3e2582d..8b1e239 100644 --- a/agentcli/pi.go +++ b/agentcli/pi.go @@ -44,7 +44,6 @@ var piOptionGrammar = optionGrammar{ var piCapabilities = Capabilities{ Modes: []Mode{Interactive, NonInteractive}, - PromptSources: []PromptSource{PromptArgument}, PromptFiles: true, Resume: true, OutputFormats: []OutputFormat{OutputText, OutputJSONL}, @@ -132,14 +131,14 @@ func buildPi(a *adapter, sessionID string, request Request) (Invocation, error) if len(request.DeniedTools) != 0 { args = append(args, "--exclude-tools", strings.Join(request.DeniedTools, ",")) } - if request.Prompt.Source == PromptArgument { + if request.Prompt.Text != "" || len(request.Prompt.Files) != 0 { args = append(args, "--") } - args, stdin, err := appendPrompt(args, request.Prompt, "", true) + args, err := appendArgumentPrompt(args, request.Prompt, true) if err != nil { return Invocation{}, fmt.Errorf("build %s invocation: %w", Pi, err) } - return Invocation{Argv: args, Stdin: stdin}, nil + return Invocation{Argv: args}, nil } func validatePiRequest(mode Mode, request Request) error {