From 0f96d202c76b7ee06a3cca8497e03c105e3d7ca5 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 15 May 2026 11:28:14 -0400 Subject: [PATCH] feat(v0.6): ctask agents check + doctor integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/agents.go | 158 +++++++++++++++++++++++ cmd/agents_check_test.go | 256 +++++++++++++++++++++++++++++++++++++ cmd/agents_doctor_test.go | 49 +++++++ cmd/completion_test.go | 11 ++ cmd/doctor.go | 25 ++++ cmd/new_agent_flag_test.go | 2 + 6 files changed, 501 insertions(+) create mode 100644 cmd/agents.go create mode 100644 cmd/agents_check_test.go create mode 100644 cmd/agents_doctor_test.go diff --git a/cmd/agents.go b/cmd/agents.go new file mode 100644 index 0000000..3734b67 --- /dev/null +++ b/cmd/agents.go @@ -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 +} diff --git a/cmd/agents_check_test.go b/cmd/agents_check_test.go new file mode 100644 index 0000000..6a5147d --- /dev/null +++ b/cmd/agents_check_test.go @@ -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()) + } +} diff --git a/cmd/agents_doctor_test.go b/cmd/agents_doctor_test.go new file mode 100644 index 0000000..3c0c37f --- /dev/null +++ b/cmd/agents_doctor_test.go @@ -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) + } +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go index 540629b..f9bdf1a 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -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 { diff --git a/cmd/doctor.go b/cmd/doctor.go index eee0c13..653f00b 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -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 diff --git a/cmd/new_agent_flag_test.go b/cmd/new_agent_flag_test.go index c2e2fe0..5d54b7d 100644 --- a/cmd/new_agent_flag_test.go +++ b/cmd/new_agent_flag_test.go @@ -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()