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 }