feat(v0.6): ctask agents check + doctor integration

ctask agents check [workspace] validates that a workspace's agent
configuration can be launched without launching it: agent type known,
command resolvable on PATH, launch_dir valid, AGENTS.md present,
CLAUDE.md shim present (WARN only, claude type only). agent.env keys
are displayed informationally, with a WARN line when any key shadows a
ctask-exported CTASK_* var. Returns non-zero when any check FAILs.

ctask doctor includes the same sweep when a workspace context is
resolvable — the most-recently-active workspace via
workspace.MostRecentActive. When no active workspace exists, doctor
shows "Agent check: skipped (no workspace context)" without bumping
the failure counter. runAgentsCheckOnWorkspace is shared between the
standalone command and the doctor integration.

TestCompletionSubcommandViaExecute is made order-independent: cobra's
default completion command captures the root output writer on the first
Execute() in the process, and the new agents-check tests now run an
Execute() earlier in the suite.
This commit is contained in:
2026-05-15 11:28:14 -04:00
parent 0c6ed0c0cf
commit 0f96d202c7
6 changed files with 501 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
package cmd
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/agent"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/workspace"
)
var agentsCmd = &cobra.Command{
Use: "agents",
Short: "Inspect and validate agent configuration",
SilenceUsage: true,
}
var agentsCheckCmd = &cobra.Command{
Use: "check [workspace]",
Short: "Validate the agent configuration for a workspace",
Long: `Validate that the configured agent for a workspace can be launched.
Does NOT launch the agent. Checks: agent type known, command resolvable
on PATH, launch_dir valid, AGENTS.md present, CLAUDE.md shim present
(WARN, claude type only). Displays agent.env keys informationally.
If [workspace] is omitted, checks the most-recently-active workspace.`,
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: runAgentsCheck,
}
func init() {
agentsCheckCmd.ValidArgsFunction = completeWorkspaces(completionAny)
agentsCmd.AddCommand(agentsCheckCmd)
rootCmd.AddCommand(agentsCmd)
}
func runAgentsCheck(cmd *cobra.Command, args []string) error {
roots := config.SearchRoots()
var ws *workspace.QueryResult
if len(args) == 1 {
ws = resolveOne(roots, args[0], true)
} else {
best, err := workspace.MostRecentActive(roots)
if err != nil || best == nil {
return fmt.Errorf("agents check: no workspace specified and no active workspace found")
}
ws = best
}
return runAgentsCheckOnWorkspace(cmd.OutOrStdout(), ws)
}
// runAgentsCheckOnWorkspace performs the checks and prints results.
// Returns a non-nil error iff any check is FAIL (so doctor can surface
// the failure count). Extracted from runAgentsCheck so doctor can reuse.
func runAgentsCheckOnWorkspace(out io.Writer, ws *workspace.QueryResult) error {
fmt.Fprintf(out, "── Agent Check: %s ─────────────────\n", ws.Meta.Slug)
failed := 0
spec := ws.Meta.Agent
// 1. Agent type known. (ValidateAgentSpec already enforced this on read,
// but we re-display for symmetry. If type is empty, fall through to
// default_agent and label as such.)
typ := spec.Type
typeLabel := typ
if typ == "" {
typ = config.LoadResolver().DefaultAgent().Value
typeLabel = typ + " (from default_agent)"
}
fmt.Fprintf(out, "Agent type: %s\n", typeLabel)
if !agent.IsKnownType(typ) {
fmt.Fprintf(out, " [FAIL] unknown agent type %q\n", typ)
failed++
} else {
fmt.Fprintln(out, " [PASS]")
}
// 2. Command resolvable.
resolved, rerr := agent.Resolve(spec, typ)
if rerr != nil {
fmt.Fprintf(out, "Command: [FAIL] %v\n", rerr)
failed++
} else {
path, lerr := exec.LookPath(resolved.Command)
fmt.Fprintf(out, "Command: %s", resolved.Command)
if lerr != nil {
fmt.Fprintf(out, "\n [FAIL] not found on PATH: %v\n", lerr)
failed++
} else {
fmt.Fprintf(out, " (%s)\n", path)
fmt.Fprintln(out, " [PASS]")
}
}
// 3. launch_dir valid.
launchAbs, _, lderr := workspace.ResolveLaunch(ws.Path, ws.Meta.LaunchDir)
fmt.Fprintf(out, "Launch dir: %s\n", launchAbs)
if lderr != nil {
fmt.Fprintf(out, " [FAIL] %v\n", lderr)
failed++
} else {
fmt.Fprintln(out, " [PASS]")
}
// 4. AGENTS.md present.
if _, err := os.Stat(filepath.Join(ws.Path, "AGENTS.md")); err == nil {
fmt.Fprintln(out, "AGENTS.md: found\n [PASS]")
} else {
fmt.Fprintln(out, "AGENTS.md: [FAIL] missing")
failed++
}
// 5. CLAUDE.md shim — WARN only, claude type only.
if typ == "claude" {
if _, err := os.Stat(filepath.Join(ws.Path, "CLAUDE.md")); err == nil {
fmt.Fprintln(out, "CLAUDE.md: found\n [PASS]")
} else {
fmt.Fprintln(out, "CLAUDE.md: [WARN] missing (shim is optional)")
}
}
// 6. agent.env keys (informational + CTASK_* shadow WARN).
if len(spec.Env) > 0 {
keys := make([]string, 0, len(spec.Env))
for k := range spec.Env {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Fprintf(out, "Agent env: %d keys configured (%s)\n", len(keys), strings.Join(keys, ", "))
// agent.env merges AFTER ctask's own exported vars, so a key
// matching CTASK_* shadows what ctask exports. The user is allowed
// to do this (spec §5); we surface the surprise. WARN does not
// bump the failed counter.
var shadowed []string
for _, k := range keys {
if strings.HasPrefix(k, "CTASK_") {
shadowed = append(shadowed, k)
}
}
if len(shadowed) > 0 {
fmt.Fprintf(out, " [WARN] agent.env overrides ctask-exported vars: %s\n",
strings.Join(shadowed, ", "))
}
}
if failed > 0 {
return fmt.Errorf("agents check: %d failures", failed)
}
return nil
}
+256
View File
@@ -0,0 +1,256 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// captureRootCmd redirects rootCmd's output to a buffer for the duration
// of t and restores the process defaults (and clears SetArgs) on cleanup.
// rootCmd is a package global; tests that drive it via Execute() must
// restore it so later tests are not affected.
func captureRootCmd(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
t.Cleanup(func() {
rootCmd.SetOut(os.Stdout)
rootCmd.SetErr(os.Stderr)
rootCmd.SetArgs(nil)
})
return &buf
}
func TestAgentsCheckPassAllChecks(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no command on PATH for fixture")
}
res, err := workspace.Create(workspace.CreateOpts{
Root: tmpRoot,
Title: "agents-check-pass",
Category: "general",
AgentSpec: workspace.AgentSpec{Type: "custom", Command: "go"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
out := buf.String()
for _, want := range []string{"[PASS]", "Agent type:", "Command:", "AGENTS.md:"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n%s", want, out)
}
}
}
func TestAgentsCheckCommandNotFound(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot,
Title: "no-cmd",
Category: "general",
AgentSpec: workspace.AgentSpec{Type: "custom", Command: "definitely-not-on-path-zzz"},
})
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(buf.String(), "[FAIL]") {
t.Errorf("output missing [FAIL]:\n%s", buf.String())
}
}
func TestAgentsCheckMissingAgentsMD(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "no-agents-md", Category: "general",
AgentSpec: workspace.AgentSpec{Type: "custom", Command: "go"},
})
// Delete the AGENTS.md that workspace.Create just wrote.
_ = os.Remove(filepath.Join(res.Path, "AGENTS.md"))
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(buf.String(), "AGENTS.md: [FAIL]") {
t.Errorf("output missing AGENTS.md FAIL:\n%s", buf.String())
}
}
func TestAgentsCheckMissingShimWarnsForClaude(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
// Use claude as the type but a known-on-PATH command override so the
// command check passes regardless of whether claude is installed.
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "claude-no-shim", Category: "general",
AgentSpec: workspace.AgentSpec{Type: "claude", Command: "go"},
})
_ = os.Remove(filepath.Join(res.Path, "CLAUDE.md"))
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error (WARN should not bump failure count): %v", err)
}
out := buf.String()
if !strings.Contains(out, "CLAUDE.md: [WARN]") {
t.Errorf("output missing CLAUDE.md WARN line:\n%s", out)
}
if strings.Contains(out, "CLAUDE.md: [FAIL]") {
t.Errorf("CLAUDE.md missing must be WARN, not FAIL:\n%s", out)
}
}
func TestAgentsCheckShimNotRequiredForOpencode(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "opencode-no-shim-needed", Category: "general",
// type: custom is a stand-in for opencode so the test does not
// need opencode on PATH. The shim check is gated on type=="claude",
// so any non-claude type must NOT emit a CLAUDE.md line.
AgentSpec: workspace.AgentSpec{Type: "custom", Command: "go"},
})
if _, err := os.Stat(filepath.Join(res.Path, "CLAUDE.md")); !os.IsNotExist(err) {
t.Fatalf("precondition: CLAUDE.md should not exist for non-claude, got %v", err)
}
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
_ = rootCmd.Execute()
if strings.Contains(buf.String(), "CLAUDE.md:") {
t.Errorf("non-claude workspace must not produce a CLAUDE.md row:\n%s", buf.String())
}
}
func TestAgentsCheckCustomNoCommand(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
// Create a normal workspace, then build a QueryResult with
// agent.type=custom and no command directly — ReadMeta would reject
// such a task.yaml, so we exercise the agents-check diagnostic itself.
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "custom-no-cmd-direct", Category: "general",
AgentSpec: workspace.AgentSpec{Type: "claude"},
})
qr := &workspace.QueryResult{
Path: res.Path,
Root: tmpRoot,
Meta: &workspace.TaskMeta{
Slug: res.Meta.Slug,
Agent: workspace.AgentSpec{Type: "custom"}, // no command
},
}
var buf bytes.Buffer
err := runAgentsCheckOnWorkspace(&buf, qr)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(buf.String(), "requires command") {
t.Errorf("output must mention requires command:\n%s", buf.String())
}
}
func TestAgentsCheckShowsEnvKeysInfo(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "env-keys-info", Category: "general",
AgentSpec: workspace.AgentSpec{
Type: "custom",
Command: "go",
Env: map[string]string{
"OPENAI_API_KEY": "x",
"OPENAI_BASE_URL": "y",
},
},
})
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
_ = rootCmd.Execute()
out := buf.String()
if !strings.Contains(out, "Agent env: 2 keys configured") {
t.Errorf("output missing env-keys count:\n%s", out)
}
if !strings.Contains(out, "OPENAI_API_KEY, OPENAI_BASE_URL") {
t.Errorf("output missing sorted key list:\n%s", out)
}
}
func TestAgentsCheckWarnsOnCtaskEnvShadow(t *testing.T) {
tmpRoot := t.TempDir()
t.Setenv("CTASK_ROOT", tmpRoot)
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
res, _ := workspace.Create(workspace.CreateOpts{
Root: tmpRoot, Title: "ctask-env-shadow", Category: "general",
AgentSpec: workspace.AgentSpec{
Type: "custom",
Command: "go",
Env: map[string]string{"CTASK_WORKSPACE": "shadowed"},
},
})
buf := captureRootCmd(t)
rootCmd.SetArgs([]string{"agents", "check", filepath.Base(res.Path)})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error (shadow is WARN, not FAIL): %v", err)
}
if !strings.Contains(buf.String(), "[WARN] agent.env overrides ctask-exported vars") {
t.Errorf("output missing CTASK_* shadow WARN:\n%s", buf.String())
}
}
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/warrenronsiek/ctask/internal/config"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// This file swaps process-global os.Stdout and env vars via
// runDoctorCapture. Do not call t.Parallel() in this file.
func TestDoctorIncludesAgentCheck(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no PATH command")
}
tmpRoot := t.TempDir()
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
if _, err := workspace.Create(workspace.CreateOpts{
Root: tmpRoot,
Title: "doctor-agent-test",
Category: "general",
AgentSpec: workspace.AgentSpec{Type: "custom", Command: "go"},
}); err != nil {
t.Fatalf("Create: %v", err)
}
// doctor returns an error if any check failed; assert on the printed
// block, not the exit code.
out, _ := runDoctorCapture(t, tmpRoot, "", "", false, false)
if !strings.Contains(out, "── Agent Check:") {
t.Errorf("doctor output missing Agent Check block:\n%s", out)
}
}
func TestDoctorSkipsAgentCheckWhenNoWorkspace(t *testing.T) {
tmpRoot := t.TempDir()
config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
// Do NOT create any workspaces.
out, _ := runDoctorCapture(t, tmpRoot, "", "", false, false)
if !strings.Contains(out, "Agent check: skipped") {
t.Errorf("doctor output missing skip line:\n%s", out)
}
}
+11
View File
@@ -197,6 +197,17 @@ func TestCompletionSubcommandViaExecute(t *testing.T) {
defer rootCmd.SetOut(os.Stdout)
defer rootCmd.SetErr(os.Stderr)
// Cobra's default `completion` command captures the root's output
// writer once, when it is first created on the first Execute() anywhere
// in the process (see InitDefaultCompletionCmd). Drop any previously
// created instance so the Execute() below re-creates it bound to buf —
// keeps this test independent of which other test ran Execute() first.
for _, c := range rootCmd.Commands() {
if c.Name() == "completion" {
rootCmd.RemoveCommand(c)
}
}
rootCmd.SetArgs([]string{"completion", "bash"})
defer rootCmd.SetArgs(nil)
if err := rootCmd.Execute(); err != nil {
+25
View File
@@ -213,6 +213,11 @@ func runDoctor(cmd *cobra.Command, args []string) error {
resolver := config.LoadResolver()
checkSettings(resolver, &passed, &failed)
// Check 11: v0.6 — agent check when a workspace context is resolvable.
// Uses the most-recently-active workspace (mirrors `ctask last`); skips
// with an INFO line when no active workspace exists anywhere.
checkAgentForDoctor(&passed, &failed)
// Summary
fmt.Println()
fmt.Printf("%d checks passed, %d failed\n", passed, failed)
@@ -364,6 +369,26 @@ func formatSettingSource(s config.ResolvedSetting) string {
return base
}
// checkAgentForDoctor runs the same validation as `ctask agents check`
// against the most-recently-active workspace. When no active workspace
// exists, prints a skip line. FAIL outcomes increment the failed counter
// (so doctor's overall exit code reflects agent breakage); WARN does not.
func checkAgentForDoctor(passed, failed *int) {
_ = passed // reserved for future symmetry with the other checks
fmt.Println()
roots := config.SearchRoots()
best, err := workspace.MostRecentActive(roots)
if err != nil || best == nil {
fmt.Println("Agent check: skipped (no workspace context)")
return
}
if checkErr := runAgentsCheckOnWorkspace(os.Stdout, best); checkErr != nil {
// runAgentsCheckOnWorkspace already printed the [FAIL] lines; just
// increment the counter so the summary reflects the breakage.
*failed++
}
}
// checkTmux reports the three-state tmux check (v0.5.3):
// - CTASK_SESSION_MODE != "persistent" -> INFO (direct mode, tmux optional)
// - persistent + tmux on PATH + version OK -> two INFO lines
+2
View File
@@ -19,6 +19,7 @@ func TestNewAgentFlagWritesTypeToTaskYaml(t *testing.T) {
prev := runWorkspaceEntry
runWorkspaceEntry = func(WorkspaceEntryOptions) error { return nil }
t.Cleanup(func() { runWorkspaceEntry = prev })
captureRootCmd(t) // restores rootCmd SetArgs/SetOut on cleanup
rootCmd.SetArgs([]string{"new", "agent-flag-test", "--agent", "claude"})
if err := rootCmd.Execute(); err != nil {
@@ -46,6 +47,7 @@ func TestNewAgentCustomWithoutCommandRefused(t *testing.T) {
prev := runWorkspaceEntry
runWorkspaceEntry = func(WorkspaceEntryOptions) error { return nil }
t.Cleanup(func() { runWorkspaceEntry = prev })
captureRootCmd(t) // restores rootCmd SetArgs/SetOut on cleanup
rootCmd.SetArgs([]string{"new", "custom-no-cmd", "--agent", "custom"})
err := rootCmd.Execute()