Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>`. 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
Expand Down Expand Up @@ -350,6 +352,7 @@ ch
| `ch attach session <project> <branch>` | Attach to a session |
| `ch show session <project> <branch>` | Show session details |
| `ch delete session <project> <branch>` | Stop a session |
| `ch run <agent> [-- <args>]` | Run a registered agent in the current shell; args after `--` are forwarded to it |
| `ch version` | Print the installed version |

## Development
Expand Down
21 changes: 17 additions & 4 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@ type RunAgentCmd struct{}

func (c *RunAgentCmd) Cobra() *cobra.Command {
return &cobra.Command{
Use: "run <agent>",
Use: "run <agent> [-- <args>]",
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
Expand Down Expand Up @@ -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 {
Expand Down
171 changes: 171 additions & 0 deletions cmd/run_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading