feat(v0.6): internal/agent package — Resolve + BuiltinProfiles

Pure resolution logic combining a workspace's AgentSpec with the
user-level default_agent into a Resolved value carrying Command, Args,
and Env. No I/O — PATH lookup stays in shell.ExecAgent and ctask agents
check, so Resolve is trivially testable and reusable.

BuiltinProfiles enumerates claude and opencode; "custom" is the escape
hatch and requires command. Keep BuiltinProfiles in sync with
workspace.knownAgentTypes and workspace.IsBuiltinAgentType (Task 1).
This commit is contained in:
2026-05-15 10:58:55 -04:00
parent 8120c399df
commit 24f213449e
2 changed files with 189 additions and 0 deletions
+81
View File
@@ -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
}
+108
View File
@@ -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")
}
}