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
}