From 24d0cc6ebf1b71b289f8887a23e37b471d182884 Mon Sep 17 00:00:00 2001 From: Francisco Rodrigues Date: Mon, 6 Jul 2026 12:11:52 -0300 Subject: [PATCH] feat: forward extra args to the agent in ch run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ch run replaces the current shell with a registered agent, but it only ever launched that agent with the arguments statically configured for it. Handing the agent a one-off argument at invocation time — say, picking a model for a single session — required editing config first. There was no way to pass arguments through at the call site. ch run -- now forwards everything after -- to the agent verbatim, each token as its own argv element with no shell re-parsing. The extra args are appended after the agent's configured args, so they also land after any -- already embedded in the agent's command string (for example ai-jail -- claude): the arguments reach the real agent rather than the wrapper in front of it. In practice, ch run claude -- --model opus runs claude with --model opus tacked onto whatever the config already supplies. Enforcing the -- convention replaces cobra.ExactArgs(1) with a validator keyed on ArgsLenAtDash(): exactly one positional, the agent name, may appear before --, and any number may follow. A stray positional such as ch run claude foo is rejected outright rather than silently swallowed, and a bare flag without -- still errors as unknown — keeping the boundary between codeherd's own flags and the agent's flags unambiguous. The new usage is documented in the command help and the README, alongside the design spec and implementation plan under docs/superpowers. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 + cmd/run.go | 21 +- cmd/run_internal_test.go | 171 ++++++++ .../2026-07-05-run-agent-passthrough-args.md | 364 ++++++++++++++++++ ...07-05-run-agent-passthrough-args-design.md | 131 +++++++ 5 files changed, 686 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-05-run-agent-passthrough-args.md create mode 100644 docs/superpowers/specs/2026-07-05-run-agent-passthrough-args-design.md diff --git a/README.md b/README.md index c664d6e..9684eae 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Each project points to a git repository and a default branch. Clone paths mirror Agents are CLI tools configured once and selected at session start. Any command-line tool works -- Claude Code, Aider, Codex, or a custom script. Each agent defines a command, optional arguments, and optional environment variables. +Run an agent in the current shell with `ch run `. Arguments after `--` are forwarded to the agent's command verbatim, appended after its configured args -- for example `ch run claude -- --model opus`. + ### Profiles Profiles let one machine carry several independent codeherd configs — e.g. a @@ -350,6 +352,7 @@ ch | `ch attach session ` | Attach to a session | | `ch show session ` | Show session details | | `ch delete session ` | Stop a session | +| `ch run [-- ]` | Run a registered agent in the current shell; args after `--` are forwarded to it | | `ch version` | Print the installed version | ## Development diff --git a/cmd/run.go b/cmd/run.go index bbb8a62..a61c929 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -15,11 +15,23 @@ type RunAgentCmd struct{} func (c *RunAgentCmd) Cobra() *cobra.Command { return &cobra.Command{ - Use: "run ", + Use: "run [-- ]", Short: "Run a registered agent in the current shell", - Long: "Resolves a registered agent from config and replaces the current process with it. The agent inherits the current environment with its configured env vars overlaid.", - Args: cobra.ExactArgs(1), - RunE: c.Run, + Long: "Resolves a registered agent from config and replaces the current process with it. " + + "The agent inherits the current environment with its configured env vars overlaid.\n\n" + + "Arguments after -- are forwarded to the agent command verbatim, appended after the " + + "agent's configured args. Example: ch run claude -- --model opus", + Args: func(cmd *cobra.Command, args []string) error { + if dash := cmd.ArgsLenAtDash(); dash == -1 { + if len(args) != 1 { + return fmt.Errorf("accepts exactly one agent name; pass extra args after --") + } + } else if dash != 1 { + return fmt.Errorf("accepts exactly one agent name before --; pass extra args after --") + } + return nil + }, + RunE: c.Run, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp @@ -56,6 +68,7 @@ func (c *RunAgentCmd) Run(cmd *cobra.Command, args []string) error { execArgs := append([]string{binaryName}, cmdPrefixArgs...) execArgs = append(execArgs, agent.Args...) + execArgs = append(execArgs, args[1:]...) mergedEnv := mergeEnv(os.Environ(), agent.Env) if err := syscallExec(binary, execArgs, mergedEnv); err != nil { diff --git a/cmd/run_internal_test.go b/cmd/run_internal_test.go index 43b0b3a..ae214bb 100644 --- a/cmd/run_internal_test.go +++ b/cmd/run_internal_test.go @@ -157,6 +157,177 @@ func TestRunAgentCmd_cmdWithSpaces_parsesCorrectly(t *testing.T) { } } +func TestRunAgentCmd_passThroughArgs_appendedAfterAgentArgs(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "mybin", Args: []string{"--flag", "val"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--", "--model", "opus"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"mybin", "--flag", "val", "--model", "opus"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_passThroughArgs_landAfterEmbeddedDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "ai-jail -- claude", Args: []string{"--project", "foo"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--", "-p", "trust"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"ai-jail", "--", "claude", "--project", "foo", "-p", "trust"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_emptyPassThrough_unchanged(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "mybin", Args: []string{"--flag", "val"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"mybin", "--flag", "val"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_rejectsBarePositionalWithoutDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "foo"}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error for a second bare positional without --") + } +} + +func TestRunAgentCmd_rejectsZeroArgs(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error when no agent name is given") + } +} + +func TestRunAgentCmd_rejectsTwoPositionalsBeforeDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "extra", "--", "x"}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error for two positionals before --") + } +} + func TestRunAgentCmd_execError_returnsWrappedError(t *testing.T) { setTestConfig(t, &config.Config{ Agents: map[string]config.AgentConfig{ diff --git a/docs/superpowers/plans/2026-07-05-run-agent-passthrough-args.md b/docs/superpowers/plans/2026-07-05-run-agent-passthrough-args.md new file mode 100644 index 0000000..f608402 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-run-agent-passthrough-args.md @@ -0,0 +1,364 @@ +# `ch run -- ` Pass-Through Arguments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let `ch run -- ` forward extra arguments verbatim to the resolved agent command. + +**Architecture:** `ch run` resolves a registered agent and `syscall.Exec`s it, building `execArgs = [binary, cmdPrefixArgs…, agent.Args…]`. This change appends the tokens after `--` to the end of that slice and replaces the `cobra.ExactArgs(1)` validator with a strict validator that uses cobra's `ArgsLenAtDash()` to enforce the `--` convention. Everything lives in `cmd/run.go`; docs are updated in the command help and README. + +**Tech Stack:** Go, cobra v1.10.2 / pflag v1.0.10. + +## Global Constraints + +- New code must keep aggregate test coverage ≥ 80% (`make check`). +- Scope is `ch run` only — do not touch `internal/config`, `internal/session`, or `internal/tui`. +- Pass-through args are forwarded verbatim: no quoting, splitting, or shell interpretation. Each token is one `argv` element. +- Pass-through args are appended *after* `agent.Args` (so they land after any `--` embedded in `agent.Cmd`, e.g. `ai-jail -- claude … `). +- Strict validation: exactly one positional (the agent name) before any `--`; reject a second bare positional without `--`. + +--- + +### Task 1: Pass-through args + strict validator in `ch run` + +**Files:** +- Modify: `cmd/run.go` (the `Cobra()` method and `Run` method) +- Test: `cmd/run_internal_test.go` + +**Interfaces:** +- Consumes: existing `RunAgentCmd`, package globals `lookPath`, `syscallExec`, `cfg`, and test helper `setTestConfig(t, *config.Config)`. +- Produces: no new exported symbols. Observable behavior — `ch run -- ` appends `` to the exec `argv`; strict arg validation rejects extra bare positionals. + +**Cobra `--` semantics (verified against source):** when pflag hits a bare `--`, it sets `argsLenAtDash = len(positionals so far)`, consumes the `--` (it is not in `args`), and appends every following token to `args` without flag-parsing them. `cmd.ArgsLenAtDash()` returns that count, or `-1` if no `--` was present. So for `ch run claude -- --model opus`, RunE receives `args = ["claude","--model","opus"]` and `ArgsLenAtDash() == 1`. + +- [ ] **Step 1: Write the failing tests** + +Add these tests to `cmd/run_internal_test.go` (imports `io`, `slices`, `strings`, `config` are already present): + +```go +func TestRunAgentCmd_passThroughArgs_appendedAfterAgentArgs(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "mybin", Args: []string{"--flag", "val"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--", "--model", "opus"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"mybin", "--flag", "val", "--model", "opus"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_passThroughArgs_landAfterEmbeddedDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "ai-jail -- claude", Args: []string{"--project", "foo"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--", "-p", "trust"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"ai-jail", "--", "claude", "--project", "foo", "-p", "trust"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_emptyPassThrough_unchanged(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{ + "my-agent": {Cmd: "mybin", Args: []string{"--flag", "val"}}, + }, + }) + + var execArgs []string + origLookPath := lookPath + t.Cleanup(func() { lookPath = origLookPath }) + lookPath = func(file string) (string, error) { return "/usr/bin/" + file, nil } + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(_ string, args []string, _ []string) error { + execArgs = args + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "--"}) + + if err := cobraCmd.Execute(); err != nil { + t.Fatalf("Execute() = %v, want nil", err) + } + wantArgs := []string{"mybin", "--flag", "val"} + if !slices.Equal(execArgs, wantArgs) { + t.Errorf("args = %v, want %v", execArgs, wantArgs) + } +} + +func TestRunAgentCmd_rejectsBarePositionalWithoutDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "foo"}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error for a second bare positional without --") + } +} + +func TestRunAgentCmd_rejectsZeroArgs(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error when no agent name is given") + } +} + +func TestRunAgentCmd_rejectsTwoPositionalsBeforeDash(t *testing.T) { + setTestConfig(t, &config.Config{ + Agents: map[string]config.AgentConfig{"my-agent": {Cmd: "mybin"}}, + }) + + origExec := syscallExec + t.Cleanup(func() { syscallExec = origExec }) + syscallExec = func(string, []string, []string) error { + t.Fatal("syscallExec should not be called when validation fails") + return nil + } + + c := &RunAgentCmd{} + cobraCmd := c.Cobra() + cobraCmd.SetOut(io.Discard) + cobraCmd.SetErr(io.Discard) + cobraCmd.SetArgs([]string{"my-agent", "extra", "--", "x"}) + + if err := cobraCmd.Execute(); err == nil { + t.Fatal("expected error for two positionals before --") + } +} +``` + +- [ ] **Step 2: Run the tests to verify the new behavior fails** + +Run: `go test ./cmd/ -run 'TestRunAgentCmd_(passThroughArgs|emptyPassThrough|rejects)' -v` + +Expected: FAIL on `TestRunAgentCmd_passThroughArgs_appendedAfterAgentArgs` and `TestRunAgentCmd_passThroughArgs_landAfterEmbeddedDash`. Under the current `cobra.ExactArgs(1)`, running `my-agent -- --model opus` leaves a 3-element post-dash `args` slice, so `ExactArgs(1)` makes `Execute()` return an error and the tests hit their `t.Fatalf("Execute() = ...")`. That is the failure proving the feature is missing. + +Note: the `rejects*` tests and `emptyPassThrough` may already pass under `ExactArgs(1)` (it also rejects zero/extra args, and the empty-pass-through case exec's identically today). They are regression guards that must keep passing after the change — they are not expected to fail here. Do not proceed until you see the two `passThroughArgs` tests failing. + +- [ ] **Step 3: Replace the validator in `cmd/run.go`** + +In the `Cobra()` method, change the `Use` string and replace `Args: cobra.ExactArgs(1)` with the strict validator. Update `Long` to document pass-through. + +Replace this block: + +```go + return &cobra.Command{ + Use: "run ", + Short: "Run a registered agent in the current shell", + Long: "Resolves a registered agent from config and replaces the current process with it. The agent inherits the current environment with its configured env vars overlaid.", + Args: cobra.ExactArgs(1), + RunE: c.Run, +``` + +with: + +```go + return &cobra.Command{ + Use: "run [-- ]", + Short: "Run a registered agent in the current shell", + Long: "Resolves a registered agent from config and replaces the current process with it. " + + "The agent inherits the current environment with its configured env vars overlaid.\n\n" + + "Arguments after -- are forwarded to the agent command verbatim, appended after the " + + "agent's configured args. Example: ch run claude -- --model opus", + Args: func(cmd *cobra.Command, args []string) error { + if dash := cmd.ArgsLenAtDash(); dash == -1 { + if len(args) != 1 { + return fmt.Errorf("accepts exactly one agent name; pass extra args after --") + } + } else if dash != 1 { + return fmt.Errorf("accepts exactly one agent name before --; pass extra args after --") + } + return nil + }, + RunE: c.Run, +``` + +- [ ] **Step 4: Append pass-through args in `Run`** + +In the `Run` method of `cmd/run.go`, find: + +```go + execArgs := append([]string{binaryName}, cmdPrefixArgs...) + execArgs = append(execArgs, agent.Args...) + mergedEnv := mergeEnv(os.Environ(), agent.Env) +``` + +Change it to append the tokens after the agent name (`args[1:]`): + +```go + execArgs := append([]string{binaryName}, cmdPrefixArgs...) + execArgs = append(execArgs, agent.Args...) + execArgs = append(execArgs, args[1:]...) + mergedEnv := mergeEnv(os.Environ(), agent.Env) +``` + +(`args[0]` is `agentName`, already read at the top of `Run`; `args[1:]` is the pass-through slice, empty when no `--` args were given.) + +- [ ] **Step 5: Run the new tests to verify they pass** + +Run: `go test ./cmd/ -run 'TestRunAgentCmd_(passThroughArgs|emptyPassThrough|rejects)' -v` +Expected: PASS (all six new tests). + +- [ ] **Step 6: Run the full cmd package tests to check nothing regressed** + +Run: `go test ./cmd/ -run TestRunAgentCmd -v` +Expected: PASS, including the pre-existing `TestRunAgentCmd_runKnownAgent_callsExec`, `TestRunAgentCmd_cmdWithSpaces_parsesCorrectly`, etc. (those call `c.Run` directly with a single-element `args`, so `args[1:]` is empty and their expectations are unchanged). + +- [ ] **Step 7: Commit** + +```bash +git add cmd/run.go cmd/run_internal_test.go +git commit -m "feat: forward args after -- to the agent in ch run + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: Document pass-through in the README + +**Files:** +- Modify: `README.md` (the `### Agents` section and the `## Commands` table) + +**Interfaces:** +- Consumes: nothing (docs only). +- Produces: user-facing documentation of `ch run -- `. + +- [ ] **Step 1: Add a note to the Agents section** + +In `README.md`, find the Agents paragraph: + +```markdown +### Agents + +Agents are CLI tools configured once and selected at session start. Any command-line tool works -- Claude Code, Aider, Codex, or a custom script. Each agent defines a command, optional arguments, and optional environment variables. +``` + +Add a second paragraph directly after it: + +```markdown +Run an agent in the current shell with `ch run `. Arguments after `--` are forwarded to the agent's command verbatim, appended after its configured args -- for example `ch run claude -- --model opus`. +``` + +- [ ] **Step 2: Add a Commands table row** + +In `README.md`, in the `## Commands` table, add a row immediately after the `ch delete session` row and before the `ch version` row: + +```markdown +| `ch run [-- ]` | Run a registered agent in the current shell; args after `--` are forwarded to it | +``` + +- [ ] **Step 3: Verify the README renders as intended** + +Run: `git diff README.md` +Expected: the new Agents paragraph and the new table row appear, with no unintended reflow of surrounding lines. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: document ch run pass-through args in README + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: Full verification gate + +**Files:** none (verification only). + +- [ ] **Step 1: Run the project's full check** + +Run: `make check` +Expected: coverage ≥ 80%, integration tests pass, lint clean, build succeeds. + +- [ ] **Step 2: Manual smoke check of the help text** + +Run: `make build && ./ch run --help` +Expected: usage line shows `ch run [-- ]` and the long description mentions forwarding args after `--` (e.g. the `ch run claude -- --model opus` example). + +- [ ] **Step 3: Manual smoke check of validation (optional, requires a configured agent)** + +Run: `./ch run some-agent extra` (no `--`) +Expected: an error mentioning that extra args must be passed after `--`; the agent is not exec'd. diff --git a/docs/superpowers/specs/2026-07-05-run-agent-passthrough-args-design.md b/docs/superpowers/specs/2026-07-05-run-agent-passthrough-args-design.md new file mode 100644 index 0000000..9d5df0d --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-run-agent-passthrough-args-design.md @@ -0,0 +1,131 @@ +# `ch run -- ` pass-through arguments + +## Goal + +Let users pass extra arguments to a registered agent when running it in the +foreground: + +``` +ch run -- +``` + +`` are forwarded to the agent's command verbatim (no shell +re-parsing), appended after the agent's configured arguments. + +## Scope + +`ch run` only. The tmux session flow (`create session --agent`) and the TUI +agent picker are explicitly out of scope for this change. + +## Behavior + +`ch run` resolves a registered agent and replaces the current process with it +via `syscall.Exec`. Today it builds: + +``` +execArgs = [binaryName, cmdPrefixArgs…, agent.Args…] +``` + +where `cmdPrefixArgs` come from splitting `agent.Cmd` (e.g. `ai-jail -- claude`) +and `agent.Args` are the agent's configured args. This change appends the +pass-through args to the end: + +``` +execArgs = [binaryName, cmdPrefixArgs…, agent.Args…, extraArgs…] +``` + +Appending at the very end is deliberate: it places the pass-through args after +any `--` already embedded in `agent.Cmd`, so for a jailing wrapper like +`ai-jail -- claude`, the extra args reach the real agent (`claude`) rather than +the wrapper. + +Each token is passed as a distinct `argv` element exactly as received — no +quoting, splitting, or shell interpretation. + +## Argument parsing (`--` convention) + +Verified against cobra v1.10.2 / pflag v1.0.10. When pflag encounters a bare +`--`, it records `ArgsLenAtDash()` = the number of positional args seen before +the dash, consumes the `--` (it does not appear in `args`), and appends every +following token to `args` verbatim without flag-parsing them. + +Resulting cases: + +| Command | `args` | `ArgsLenAtDash()` | +|---|---|---| +| `ch run claude -- --model opus` | `[claude, --model, opus]` | `1` | +| `ch run claude --` | `[claude]` | `1` | +| `ch run claude foo` | `[claude, foo]` | `-1` | +| `ch run claude --model x` | (parse error: unknown flag) | — | + +Because tokens after `--` are not flag-parsed, `--model opus` passes through +cleanly. Without `--`, cobra treats `--model` as a flag on `ch run` and errors +with `unknown flag` — this is intended; users must use `--`. + +## Validation (strict) + +`cobra.ExactArgs(1)` is replaced with a custom validator that enforces exactly +one positional (the agent name) and requires the `--` convention for extras: + +```go +Args: func(cmd *cobra.Command, args []string) error { + dash := cmd.ArgsLenAtDash() + if dash == -1 { + if len(args) != 1 { + return fmt.Errorf("accepts 1 arg (the agent name); pass extra args after --") + } + return nil + } + if dash != 1 { + return fmt.Errorf("expected exactly one agent name before --") + } + return nil +} +``` + +Rejected (strict): `ch run` (none), `ch run claude foo` (dash=-1, len=2), +`ch run -- x` (dash=0), `ch run claude foo -- bar` (dash=2). +Accepted: `ch run claude`, `ch run claude --`, `ch run claude -- `. + +## Code changes + +All in `cmd/run.go`: + +- `Use: "run [-- ]"`; document pass-through in `Long`. +- Replace `Args: cobra.ExactArgs(1)` with the strict validator above. +- In `Run`: `agentName := args[0]`; `extraArgs := args[1:]`; append `extraArgs` + to `execArgs` after the existing `agent.Args` append. + +No changes to `internal/config`, `internal/session`, or `internal/tui`. + +## Documentation (required) + +The `ch run -- ` usage must be documented in two places: + +1. **Command help** (`cmd/run.go`): the `Use` string is + `run [-- ]`, and the `Long` description explicitly explains + that arguments after `--` are forwarded to the agent command verbatim, + appended after the agent's configured args. This surfaces in + `ch run --help` / `ch help run`. + +2. **README** (`README.md`): + - Add a `ch run [-- ]` row to the Commands table + (`## Commands`) describing that it runs a registered agent in the current + shell and forwards args after `--` to it. + - Add a sentence to the Agents section (`### Agents`) noting that + `ch run -- ` passes extra arguments straight through to the + agent, e.g. `ch run claude -- --model opus`. + +## Testing + +`cmd/run_internal_test.go`: + +- Extra args are appended after `agent.Args` in `execArgs`. +- Extra args land after an embedded `--` in `agent.Cmd` + (`ai-jail -- claude … `). +- Empty pass-through (`ch run claude` and `ch run claude --`) yields the same + `execArgs` as today. +- Validator rejects a second bare positional (`ch run claude foo`). +- Validator rejects zero args and a positional before a stray `--` position. + +Coverage must stay ≥ 80% (`make check`).