diff --git a/CHANGELOG.md b/CHANGELOG.md index 969ccdd..a165501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ generated notes plus the signed checksums — see ### Added +- `--agent ` flag on `ctm new`, `ctm yolo`, `ctm yolo!`, and `ctm safe`. + Without the flag, sessions use `session.DefaultAgent`. With it, the chosen + agent (validated against `agent.Registered()`) overrides the default for + that spawn — e.g. `ctm new mytask --agent codex` to opt back into codex + when hermes is the default. Unknown agent values produce an error listing + the registered names. - Hermes agent support: `ctm new --agent hermes` and `ctm yolo --agent hermes` spawn a Hermes Agent (https://hermes-agent.dev) session with full resume + session-discovery parity to codex. `internal/agent/hermes/` mirrors diff --git a/cmd/attach.go b/cmd/attach.go index 83e99cf..5ec12aa 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -67,14 +67,16 @@ func runAttach(cmd *cobra.Command, args []string) error { sess, err := store.Get(name) if err != nil { // Session doesn't exist — create new - return createAndAttach(name, ".", cfg.DefaultMode, store, tc, out) + return createAndAttach(name, ".", cfg.DefaultMode, "", store, tc, out) } return preflight(sess, cfg, store, tc, out) } -// createAndAttach creates a new session and attaches to it. -func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Client, out *output.Printer) error { +// createAndAttach creates a new session and attaches to it. agentName, when +// non-empty, overrides session.DefaultAgent for this spawn (must already be +// validated against the registry by the caller — see resolveAgent in yolo.go). +func createAndAttach(name, workdir, _ string, agentName string, store *session.Store, tc *tmux.Client, out *output.Printer) error { abs, err := filepath.Abs(workdir) if err != nil { return fmt.Errorf("resolving workdir: %w", err) @@ -85,6 +87,7 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli sess, err := session.Yolo(session.SpawnOpts{ Name: name, Workdir: abs, + Agent: agentName, Tmux: tc, Store: store, }) diff --git a/cmd/new.go b/cmd/new.go index 37aa309..cfbc065 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -15,6 +15,7 @@ import ( ) func init() { + addAgentFlag(newCmd) rootCmd.AddCommand(newCmd) } @@ -71,6 +72,11 @@ func runNew(cmd *cobra.Command, args []string) error { return err } + agentName, err := agentFromCmd(cmd) + if err != nil { + return err + } + // Kill existing session with the same name if present if _, err := store.Get(name); err == nil { out.Warn("session %q already exists — replacing", name) @@ -80,5 +86,5 @@ func runNew(cmd *cobra.Command, args []string) error { } } - return createAndAttach(name, workdir, cfg.DefaultMode, store, tc, out) + return createAndAttach(name, workdir, cfg.DefaultMode, agentName, store, tc, out) } diff --git a/cmd/yolo.go b/cmd/yolo.go index 51ceb7f..861d169 100644 --- a/cmd/yolo.go +++ b/cmd/yolo.go @@ -12,12 +12,49 @@ package cmd import ( "fmt" "os" + "strings" + "github.com/spf13/cobra" + + "github.com/RandomCodeSpace/ctm/internal/agent" "github.com/RandomCodeSpace/ctm/internal/output" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/tmux" ) +const agentFlagUsage = "Agent to spawn (codex, hermes). Empty uses the configured default." + +// addAgentFlag registers the --agent flag on a command. Single +// definition shared by ctm new / yolo / yolo! / safe so the flag name, +// default, and usage description don't drift between commands. +func addAgentFlag(c *cobra.Command) { + c.Flags().String("agent", "", agentFlagUsage) +} + +// agentFromCmd reads the --agent flag and validates it via resolveAgent. +// Shared by every RunE that accepts --agent so the read + validate +// pattern isn't duplicated across commands. +func agentFromCmd(cmd *cobra.Command) (string, error) { + name, _ := cmd.Flags().GetString("agent") + return resolveAgent(name) +} + +// resolveAgent validates an agent name against the registry. Returns +// ("", nil) for empty input (caller falls back to session.DefaultAgent +// via spawn.go's empty-Agent handling), and an error listing +// registered agents when name is non-empty but unknown so the user +// sees what choices they have. +func resolveAgent(name string) (string, error) { + if name == "" { + return "", nil + } + if _, ok := agent.For(name); ok { + return name, nil + } + return "", fmt.Errorf("unknown agent %q; available: %s", + name, strings.Join(agent.Registered(), ", ")) +} + // shouldResumeExisting reports whether a stored session should be resumed via // preflight rather than torn down and recreated. A session is resumable iff // its recorded mode matches the requested mode — tmux liveness is irrelevant diff --git a/cmd/yolo_helpers_test.go b/cmd/yolo_helpers_test.go index cb7896e..7c0a537 100644 --- a/cmd/yolo_helpers_test.go +++ b/cmd/yolo_helpers_test.go @@ -7,6 +7,10 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + + _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex for resolveAgent tests + _ "github.com/RandomCodeSpace/ctm/internal/agent/hermes" // register hermes for resolveAgent tests "github.com/RandomCodeSpace/ctm/internal/output" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/tmux" @@ -334,6 +338,99 @@ func TestResolveModeTargetRejectsInvalidName(t *testing.T) { } } +// --- resolveAgent (--agent flag validation) ---------------------------------- + +func TestResolveAgent_Empty(t *testing.T) { + got, err := resolveAgent("") + if err != nil { + t.Fatalf("resolveAgent(\"\") err = %v, want nil", err) + } + if got != "" { + t.Errorf("resolveAgent(\"\") = %q, want \"\" (caller falls back to DefaultAgent)", got) + } +} + +func TestResolveAgent_Registered(t *testing.T) { + for _, name := range []string{"codex", "hermes"} { + got, err := resolveAgent(name) + if err != nil { + t.Errorf("resolveAgent(%q) err = %v, want nil", name, err) + } + if got != name { + t.Errorf("resolveAgent(%q) = %q, want %q", name, got, name) + } + } +} + +func TestResolveAgent_Unregistered(t *testing.T) { + _, err := resolveAgent("totally-not-an-agent-xyz") + if err == nil { + t.Fatal("resolveAgent on unregistered name returned nil error") + } + msg := err.Error() + if !strings.Contains(msg, "unknown agent") { + t.Errorf("error %q should mention \"unknown agent\"", msg) + } + // Error must list available agents so the user can correct their flag. + if !strings.Contains(msg, "codex") || !strings.Contains(msg, "hermes") { + t.Errorf("error %q should list registered agents (codex, hermes)", msg) + } +} + +func TestAddAgentFlag(t *testing.T) { + c := &cobra.Command{Use: "fake"} + addAgentFlag(c) + f := c.Flags().Lookup("agent") + if f == nil { + t.Fatal("addAgentFlag did not register --agent on the command") + } + if f.DefValue != "" { + t.Errorf("--agent default = %q, want \"\" (empty → DefaultAgent)", f.DefValue) + } + if !strings.Contains(f.Usage, "codex") || !strings.Contains(f.Usage, "hermes") { + t.Errorf("--agent usage %q should mention codex + hermes", f.Usage) + } +} + +func TestAgentFromCmd_FlagUnset(t *testing.T) { + c := &cobra.Command{Use: "fake"} + addAgentFlag(c) + got, err := agentFromCmd(c) + if err != nil { + t.Fatalf("agentFromCmd err = %v, want nil", err) + } + if got != "" { + t.Errorf("agentFromCmd with unset flag = %q, want \"\"", got) + } +} + +func TestAgentFromCmd_FlagSetValid(t *testing.T) { + c := &cobra.Command{Use: "fake"} + addAgentFlag(c) + if err := c.Flags().Set("agent", "hermes"); err != nil { + t.Fatalf("Flags.Set: %v", err) + } + got, err := agentFromCmd(c) + if err != nil { + t.Fatalf("agentFromCmd err = %v, want nil", err) + } + if got != "hermes" { + t.Errorf("agentFromCmd = %q, want hermes", got) + } +} + +func TestAgentFromCmd_FlagSetInvalid(t *testing.T) { + c := &cobra.Command{Use: "fake"} + addAgentFlag(c) + if err := c.Flags().Set("agent", "not-a-real-agent"); err != nil { + t.Fatalf("Flags.Set: %v", err) + } + _, err := agentFromCmd(c) + if err == nil { + t.Fatal("agentFromCmd accepted unknown agent name; want error") + } +} + // --- helpers ----------------------------------------------------------------- type bufWriter struct{ s string } diff --git a/cmd/yolo_runners.go b/cmd/yolo_runners.go index b38be37..8d3b1dc 100644 --- a/cmd/yolo_runners.go +++ b/cmd/yolo_runners.go @@ -27,6 +27,9 @@ import ( ) func init() { + for _, c := range []*cobra.Command{yoloCmd, yoloBangCmd, safeCmd} { + addAgentFlag(c) + } rootCmd.AddCommand(yoloCmd) rootCmd.AddCommand(yoloBangCmd) rootCmd.AddCommand(safeCmd) @@ -106,6 +109,11 @@ func runYolo(cmd *cobra.Command, args []string) error { return err } + agentName, err := agentFromCmd(cmd) + if err != nil { + return err + } + if cfg.GitCheckpointBeforeYolo { out.Debug(Verbose, "git checkpoint for %s", workdir) gitCheckpoint(workdir, out) @@ -130,7 +138,7 @@ func runYolo(cmd *cobra.Command, args []string) error { } out.Debug(Verbose, "creating yolo session: %s", name) - return createAndAttach(name, workdir, "yolo", store, tc, out) + return createAndAttach(name, workdir, "yolo", agentName, store, tc, out) } func runYoloBang(cmd *cobra.Command, args []string) error { @@ -149,6 +157,11 @@ func runYoloBang(cmd *cobra.Command, args []string) error { return err } + agentName, err := agentFromCmd(cmd) + if err != nil { + return err + } + if cfg.GitCheckpointBeforeYolo { gitCheckpoint(workdir, out) } @@ -161,7 +174,7 @@ func runYoloBang(cmd *cobra.Command, args []string) error { // a best-effort reset. tearDownForRecreate(name, store, tc, out, false) - return createAndAttach(name, workdir, "yolo", store, tc, out) + return createAndAttach(name, workdir, "yolo", agentName, store, tc, out) } func runSafe(cmd *cobra.Command, args []string) error { @@ -180,6 +193,11 @@ func runSafe(cmd *cobra.Command, args []string) error { return err } + agentName, err := agentFromCmd(cmd) + if err != nil { + return err + } + printBanner(out, "safe") fireLaunchEvents(store, name, workdir, "safe") @@ -197,7 +215,7 @@ func runSafe(cmd *cobra.Command, args []string) error { tearDownForRecreate(name, store, tc, out, false) } - return createAndAttach(name, workdir, "safe", store, tc, out) + return createAndAttach(name, workdir, "safe", agentName, store, tc, out) } // gitCheckpoint creates a git checkpoint commit in workdir before yolo mode.