diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..dc9e402 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,81 @@ +package agent + +import ( + "fmt" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Profile describes a built-in agent. The Default command is what ctask +// invokes when AgentSpec.Command is empty; Type is the canonical name. +type Profile struct { + Type string + Default string +} + +// BuiltinProfiles enumerates the v0.6 built-in agents. "custom" is NOT +// in this map — it is the escape hatch with no defaults. Keep this in +// sync with workspace.knownAgentTypes AND workspace.IsBuiltinAgentType +// (update all three together when a new built-in lands). +var BuiltinProfiles = map[string]Profile{ + "claude": {Type: "claude", Default: "claude"}, + "opencode": {Type: "opencode", Default: "opencode"}, +} + +// IsKnownType reports whether t is "claude", "opencode", or "custom". +func IsKnownType(t string) bool { + if t == "custom" { + return true + } + _, ok := BuiltinProfiles[t] + return ok +} + +// Resolved is the launch-ready agent value: a single executable name or +// path, optional arguments, and an env-var map merged AFTER ctask's own +// exported vars at launch time. +type Resolved struct { + Type string + Command string + Args []string + Env map[string]string +} + +// Resolve combines a workspace's AgentSpec with the user-level +// default_agent into a launch-ready Resolved. Resolution rules (per +// v0.6 spec §5): +// +// 1. If spec.Type is empty, fall through to defaultAgent. +// 2. If the resolved type is "custom", spec.Command is required. +// 3. Otherwise, the resolved command is spec.Command if set, +// else BuiltinProfiles[type].Default. +// 4. Args and Env are carried verbatim from the spec. +// +// Resolve does NOT call exec.LookPath. PATH validation is the launch +// path's job (shell.ExecAgent fails fast with a diagnostic) and +// `ctask agents check`'s job. Keeping Resolve I/O-free makes it +// trivially testable and reusable. +func Resolve(spec workspace.AgentSpec, defaultAgent string) (*Resolved, error) { + typ := spec.Type + if typ == "" { + typ = defaultAgent + } + if !IsKnownType(typ) { + return nil, fmt.Errorf("agent: unknown type %q (must be claude, opencode, or custom)", typ) + } + + cmd := spec.Command + if cmd == "" { + if typ == "custom" { + return nil, fmt.Errorf("agent: type \"custom\" requires command field") + } + cmd = BuiltinProfiles[typ].Default + } + + return &Resolved{ + Type: typ, + Command: cmd, + Args: spec.Args, + Env: spec.Env, + }, nil +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..7f44dce --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +func TestResolveBuiltinClaude(t *testing.T) { + spec := workspace.AgentSpec{Type: "claude"} + got, err := Resolve(spec, "claude") + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Type != "claude" || got.Command != "claude" { + t.Errorf("got %+v, want type=claude command=claude", got) + } +} + +func TestResolveBuiltinOpencode(t *testing.T) { + spec := workspace.AgentSpec{Type: "opencode"} + got, err := Resolve(spec, "claude") // default doesn't matter; spec wins + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Command != "opencode" { + t.Errorf("Command = %q, want opencode", got.Command) + } +} + +func TestResolveCustomRequiresCommand(t *testing.T) { + spec := workspace.AgentSpec{Type: "custom"} + _, err := Resolve(spec, "claude") + if err == nil || !strings.Contains(err.Error(), "requires command") { + t.Fatalf("got %v, want error mentioning 'requires command'", err) + } +} + +func TestResolveCustomWithCommand(t *testing.T) { + spec := workspace.AgentSpec{Type: "custom", Command: "my-agent"} + got, err := Resolve(spec, "claude") + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Command != "my-agent" { + t.Errorf("Command = %q, want my-agent", got.Command) + } +} + +func TestResolveFallbackToDefaultWhenTypeMissing(t *testing.T) { + spec := workspace.AgentSpec{} // legacy / new-without-type + got, err := Resolve(spec, "opencode") + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Type != "opencode" || got.Command != "opencode" { + t.Errorf("got %+v, want type=opencode command=opencode", got) + } +} + +func TestResolveCommandOverride(t *testing.T) { + spec := workspace.AgentSpec{Type: "claude", Command: "/opt/claude"} + got, err := Resolve(spec, "claude") + if err != nil { + t.Fatalf("err: %v", err) + } + if got.Command != "/opt/claude" { + t.Errorf("Command = %q, want /opt/claude", got.Command) + } +} + +func TestResolveArgsAndEnvCarriedThrough(t *testing.T) { + spec := workspace.AgentSpec{ + Type: "opencode", + Args: []string{"--model", "x"}, + Env: map[string]string{"K": "v"}, + } + got, err := Resolve(spec, "claude") + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got.Args) != 2 || got.Args[0] != "--model" || got.Args[1] != "x" { + t.Errorf("Args = %v, want [--model x]", got.Args) + } + if got.Env["K"] != "v" { + t.Errorf("Env[K] = %q, want v", got.Env["K"]) + } +} + +func TestResolveUnknownDefaultRejected(t *testing.T) { + spec := workspace.AgentSpec{} + _, err := Resolve(spec, "gemini") + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("got %v, want unknown-type error", err) + } +} + +func TestIsKnownType(t *testing.T) { + for _, name := range []string{"claude", "opencode", "custom"} { + if !IsKnownType(name) { + t.Errorf("IsKnownType(%q) = false, want true", name) + } + } + if IsKnownType("gemini") { + t.Error("IsKnownType(gemini) = true, want false") + } +}