From a61f900c86f729a21ad67b8f497747da8c205ea2 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 15 May 2026 11:11:16 -0400 Subject: [PATCH] feat(v0.6): --agent flag on ctask new selects agent type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolver gains SetCLIFlagAgent; DefaultAgent now layers CLIFlag above EnvVar so doctor/info attribution renders the correct precedence chain (CLIFlag overrides EnvVar overrides ConfigFile overrides Builtin). ctask new --agent writes agent.type into the new workspace's task.yaml. Resolution and validation run before workspace.Create, so --agent custom without a companion command refuses ("type custom requires command") with no half-created workspace left on disk. The deferred Phase 1 test TestCLIFlagOverridesEnvVar lands here. --agent on resume/last/attach is unchanged (one-shot agent.command override on the AgentSpec — Open Q 1). --- cmd/new.go | 31 ++++++++++------- cmd/new_agent_flag_test.go | 60 ++++++++++++++++++++++++++++++++ internal/config/resolver.go | 36 +++++++++++++++++-- internal/config/resolver_test.go | 39 +++++++++++++++++++++ 4 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 cmd/new_agent_flag_test.go diff --git a/cmd/new.go b/cmd/new.go index 8e355c7..1769a56 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -35,7 +35,7 @@ func init() { newCmd.Flags().BoolVar(&newProject, "project", false, "Create a project workspace (longer-lived; uses CTASK_PROJECT_ROOT if set, runs git init)") newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (deferred)") newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") - newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") + newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Agent type for the workspace (claude, opencode, custom)") newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") newCmd.Flags().BoolVar(&newDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(newCmd) @@ -54,12 +54,22 @@ func runNew(cmd *cobra.Command, args []string) error { return err } - // Task 3: minimal wiring — the --agent type-selector rework lands in - // Task 4. For now the resolved type drives both the workspace AgentSpec - // and the launch-time agent.Resolve. - agentType := newAgent - if agentType == "" { - agentType = config.ResolveAgent() + // --agent on `new` is a type selector (v0.6 spec §5): the resolved + // type is recorded in task.yaml and drives the launch. SetCLIFlagAgent + // layers the flag above CTASK_AGENT / config / builtin so doctor and + // info can render the precedence chain. Resolution and validation run + // BEFORE workspace.Create so an invalid agent (unknown type, or + // `--agent custom` with no command) refuses without leaving a + // half-created workspace on disk. + resolver := config.LoadResolver() + if cmd.Flags().Changed("agent") { + resolver.SetCLIFlagAgent(newAgent) + } + agentType := resolver.DefaultAgent().Value + spec := workspace.AgentSpec{Type: agentType} + resolved, err := agent.Resolve(spec, agentType) + if err != nil { + return err } title := "" @@ -96,7 +106,7 @@ func runNew(cmd *cobra.Command, args []string) error { Title: title, Category: category, Mode: "local", - AgentSpec: workspace.AgentSpec{Type: agentType}, + AgentSpec: spec, IsProject: newProject, SeedDir: config.ResolveSeedDir(), SkipCategoryDir: skipCategoryDir, @@ -131,11 +141,6 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } - resolved, err := agent.Resolve(workspace.AgentSpec{Type: agentType}, agentType) - if err != nil { - return err - } - // Re-set the workspace's root: workspace.Create returned ws but our // computed `root` is what should be exported via CTASK_ROOT (it may // differ from ws-derived defaults when --project + CTASK_PROJECT_ROOT diff --git a/cmd/new_agent_flag_test.go b/cmd/new_agent_flag_test.go new file mode 100644 index 0000000..c2e2fe0 --- /dev/null +++ b/cmd/new_agent_flag_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +func TestNewAgentFlagWritesTypeToTaskYaml(t *testing.T) { + tmpRoot := t.TempDir() + t.Setenv("CTASK_ROOT", tmpRoot) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + t.Setenv("CTASK_AGENT", "opencode") // env says opencode + + // Replace runWorkspaceEntry so the test does not try to launch. + prev := runWorkspaceEntry + runWorkspaceEntry = func(WorkspaceEntryOptions) error { return nil } + t.Cleanup(func() { runWorkspaceEntry = prev }) + + rootCmd.SetArgs([]string{"new", "agent-flag-test", "--agent", "claude"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + matches, _ := filepath.Glob(filepath.Join(tmpRoot, "general", "*_agent-flag-test")) + if len(matches) != 1 { + t.Fatalf("workspace dir not found, got %v", matches) + } + meta, err := workspace.ReadMeta(filepath.Join(matches[0], "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Agent.Type != "claude" { + t.Errorf("agent.type = %q, want claude (CLI flag wins over env)", meta.Agent.Type) + } +} + +func TestNewAgentCustomWithoutCommandRefused(t *testing.T) { + tmpRoot := t.TempDir() + t.Setenv("CTASK_ROOT", tmpRoot) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + + prev := runWorkspaceEntry + runWorkspaceEntry = func(WorkspaceEntryOptions) error { return nil } + t.Cleanup(func() { runWorkspaceEntry = prev }) + + rootCmd.SetArgs([]string{"new", "custom-no-cmd", "--agent", "custom"}) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "requires command") { + t.Fatalf("err = %v, want error mentioning requires command", err) + } + // Workspace should NOT have been created. + matches, _ := filepath.Glob(filepath.Join(tmpRoot, "general", "*_custom-no-cmd")) + if len(matches) != 0 { + t.Errorf("workspace dir created despite refusal: %v", matches) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index ec8adf7..cea726b 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -79,6 +79,24 @@ type Resolver struct { envAgent string envSessionMode string envEditor string + + // cliFlagAgent, when non-empty, is the value of the --agent CLI flag. + // Set by SetCLIFlagAgent after Cobra parses flags; consulted by + // DefaultAgent as the highest-priority layer (SettingSource.CLIFlag). + cliFlagAgent string +} + +// SetCLIFlagAgent records the value of the --agent CLI flag for +// resolution. Cmd-layer code calls this AFTER Cobra parses flags, so the +// flag value can participate in source attribution. Empty string means +// the flag was not passed; the env-var layer is consulted instead. +// +// Currently used only by `ctask new` (where --agent is a type selector, +// per v0.6 spec §5 + Open Q 1). Other commands' --agent flags act as +// one-shot agent.command overrides on the AgentSpec, NOT as resolver +// inputs — they do not call this setter. +func (r *Resolver) SetCLIFlagAgent(value string) { + r.cliFlagAgent = value } // configPathForTest, when non-empty, overrides ConfigFilePath() for @@ -223,14 +241,28 @@ func (r *Resolver) SeedDir() ResolvedSetting { defaultSeedDir("seed"), cfgVal, r.envSeedDir, expandPath) } -// DefaultAgent resolves the default agent command. +// DefaultAgent resolves the default agent command. Layering is +// Builtin → ConfigFile → EnvVar → CLIFlag: when SetCLIFlagAgent has +// recorded a non-empty --agent value, it wins and the previously +// resolved setting is chained as Override so doctor/info can render the +// full precedence path. func (r *Resolver) DefaultAgent() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.DefaultAgent } - return r.stringSetting("default_agent", "CTASK_AGENT", + base := r.stringSetting("default_agent", "CTASK_AGENT", "claude", cfgVal, r.envAgent, nil) + if r.cliFlagAgent != "" { + prev := base + return ResolvedSetting{ + Key: "default_agent", + Value: r.cliFlagAgent, + Source: CLIFlag, + Override: &prev, + } + } + return base } // DefaultCategory resolves the default category for new workspaces. diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index f3577d6..fb04640 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -288,3 +288,42 @@ func TestResolverEditorFromConfigAndEnv(t *testing.T) { t.Errorf("Editor override should chain to config (vim), got %+v", s.Override) } } + +func TestCLIFlagOverridesEnvVar(t *testing.T) { + // Phase 1 deferred this — Phase 2 activates SettingSource.CLIFlag for + // --agent. The test sets CTASK_AGENT in the env, then applies a CLI + // flag override and asserts the resolved value, source, and override + // chain. + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + t.Setenv("CTASK_AGENT", "opencode") + + r := LoadResolver() + r.SetCLIFlagAgent("claude") + + s := r.DefaultAgent() + if s.Value != "claude" { + t.Errorf("Value = %q, want claude", s.Value) + } + if s.Source != CLIFlag { + t.Errorf("Source = %v, want CLIFlag", s.Source) + } + if s.Override == nil { + t.Fatal("Override = nil, want non-nil pointing at the EnvVar layer") + } + if s.Override.Source != EnvVar || s.Override.Value != "opencode" { + t.Errorf("Override = {%v %q}, want {EnvVar opencode}", s.Override.Source, s.Override.Value) + } +} + +func TestCLIFlagDoesNotApplyWhenUnset(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + t.Setenv("CTASK_AGENT", "opencode") + + r := LoadResolver() + // No SetCLIFlagAgent call. + + s := r.DefaultAgent() + if s.Source != EnvVar || s.Value != "opencode" { + t.Errorf("got {%v %q}, want {EnvVar opencode}", s.Source, s.Value) + } +}