// Command agentctl manages Matrix agents: list, start, stop, remove. // // Usage: // // agentctl list # all agents with their status // agentctl start # start all enabled agents // agentctl start assistant-bot # start a specific agent // agentctl stop # stop all running agents // agentctl stop assistant-bot # stop a specific agent // agentctl remove assistant-bot # disable agent (keeps data) package main import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "github.com/spf13/cobra" "github.com/enmanuel/agents/internal/config" ) const ( runDir = "run" // PID + log files agentsGlob = "agents/*/config.yaml" ) // ── entry point ─────────────────────────────────────────────────────────── func main() { var binPath string root := &cobra.Command{ Use: "agentctl", Short: "Manage Matrix agents", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return os.MkdirAll(runDir, 0o755) }, } root.PersistentFlags().StringVar(&binPath, "bin", "", "Launcher binary path. Defaults to ./bin/launcher, falls back to 'go run ./cmd/launcher'") root.AddCommand( listCmd(), startCmd(&binPath), stopCmd(), removeCmd(), ) if err := root.Execute(); err != nil { os.Exit(1) } } // ── list ────────────────────────────────────────────────────────────────── func listCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List all agents and their current status", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { agents, err := scanAgents() if err != nil { return err } if len(agents) == 0 { fmt.Println("No agents found under agents/*/config.yaml") return nil } fmt.Printf("%-20s %-12s %-8s %s\n", "ID", "STATUS", "VERSION", "DESCRIPTION") fmt.Println(strings.Repeat("─", 72)) for _, a := range agents { fmt.Printf("%-20s %-12s %-8s %s\n", a.ID, statusLabel(a), a.Version, truncate(a.Desc, 36), ) } return nil }, } } // ── start ───────────────────────────────────────────────────────────────── func startCmd(binPath *string) *cobra.Command { return &cobra.Command{ Use: "start [agent-id...]", Short: "Start one or all enabled agents", RunE: func(cmd *cobra.Command, args []string) error { agents, err := scanAgents() if err != nil { return err } targets := filterTargets(agents, args) if len(targets) == 0 { return fmt.Errorf("no matching agents found") } bin := resolvedBin(*binPath) started := 0 for _, a := range targets { if !a.Enabled { fmt.Printf("skip %-20s (disabled in config)\n", a.ID) continue } if isRunning(a.ID) { fmt.Printf("skip %-20s (already running, PID %d)\n", a.ID, readPID(a.ID)) continue } if err := startAgent(a, bin); err != nil { fmt.Fprintf(os.Stderr, "fail %-20s %v\n", a.ID, err) continue } fmt.Printf("start %-20s PID %d log → %s\n", a.ID, readPID(a.ID), logPath(a.ID)) started++ } if started == 0 { fmt.Println("Nothing started.") } return nil }, } } // ── stop ────────────────────────────────────────────────────────────────── func stopCmd() *cobra.Command { return &cobra.Command{ Use: "stop [agent-id...]", Short: "Stop one or all running agents", RunE: func(cmd *cobra.Command, args []string) error { agents, err := scanAgents() if err != nil { return err } targets := filterTargets(agents, args) if len(targets) == 0 { return fmt.Errorf("no matching agents found") } stopped := 0 for _, a := range targets { pid := readPID(a.ID) if pid == 0 || !isRunning(a.ID) { fmt.Printf("skip %-20s (not running)\n", a.ID) continue } if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { fmt.Fprintf(os.Stderr, "fail %-20s kill: %v\n", a.ID, err) continue } removePIDFile(a.ID) fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", a.ID, pid) stopped++ } if stopped == 0 { fmt.Println("Nothing stopped.") } return nil }, } } // ── remove ──────────────────────────────────────────────────────────────── func removeCmd() *cobra.Command { return &cobra.Command{ Use: "remove ", Short: "Disable an agent (sets enabled: false). Does not delete data.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := args[0] agents, err := scanAgents() if err != nil { return err } var target *agentInfo for i := range agents { if agents[i].ID == id { target = &agents[i] break } } if target == nil { return fmt.Errorf("agent %q not found", id) } // Stop if running if isRunning(id) { pid := readPID(id) _ = syscall.Kill(pid, syscall.SIGTERM) removePIDFile(id) fmt.Printf("stop %-20s sent SIGTERM to PID %d\n", id, pid) } // Disable in config (preserves comments) if err := setEnabled(target.ConfigPath, false); err != nil { return fmt.Errorf("update config: %w", err) } fmt.Printf("ok %-20s marked as disabled in %s\n", id, target.ConfigPath) fmt.Printf(" Data preserved at agents/%s/data/\n", id) return nil }, } } // ── agent scanning ──────────────────────────────────────────────────────── type agentInfo struct { ID string Version string Enabled bool Desc string ConfigPath string } func scanAgents() ([]agentInfo, error) { matches, err := filepath.Glob(agentsGlob) if err != nil { return nil, err } var agents []agentInfo for _, path := range matches { // Use LoadMeta so list works even when env vars aren't set. cfg, err := config.LoadMeta(path) if err != nil { fmt.Fprintf(os.Stderr, "warn skipping %s: %v\n", path, err) continue } agents = append(agents, agentInfo{ ID: cfg.Agent.ID, Version: cfg.Agent.Version, Enabled: cfg.Agent.Enabled, Desc: cfg.Agent.Description, ConfigPath: path, }) } return agents, nil } func filterTargets(agents []agentInfo, ids []string) []agentInfo { if len(ids) == 0 { return agents // no filter → all } set := make(map[string]bool, len(ids)) for _, id := range ids { set[id] = true } var out []agentInfo for _, a := range agents { if set[a.ID] { out = append(out, a) } } return out } // ── process management ──────────────────────────────────────────────────── func startAgent(a agentInfo, bin string) error { logFile, err := os.OpenFile(logPath(a.ID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open log: %w", err) } var cmd *exec.Cmd if strings.HasPrefix(bin, "go run") { // dev mode: go run ./cmd/launcher -c cmd = exec.Command("go", "run", "./cmd/launcher", "-c", a.ConfigPath) } else { cmd = exec.Command(bin, "-c", a.ConfigPath) } cmd.Stdout = logFile cmd.Stderr = logFile // Detach from the parent process group so it keeps running after agentctl exits cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { logFile.Close() return fmt.Errorf("exec: %w", err) } // Write PID file — the subprocess owns its lifecycle now if err := os.WriteFile(pidPath(a.ID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { return fmt.Errorf("write PID: %w", err) } // Detach: don't wait for the process go func() { _ = cmd.Wait() }() return nil } func isRunning(id string) bool { pid := readPID(id) if pid == 0 { return false } err := syscall.Kill(pid, 0) // signal 0 checks existence without killing return err == nil } func readPID(id string) int { raw, err := os.ReadFile(pidPath(id)) if err != nil { return 0 } pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) return pid } func removePIDFile(id string) { _ = os.Remove(pidPath(id)) } func pidPath(id string) string { return filepath.Join(runDir, id+".pid") } func logPath(id string) string { return filepath.Join(runDir, id+".log") } // ── config editing ──────────────────────────────────────────────────────── // setEnabled flips `enabled: true/false` in the agent section of the YAML. // Uses text replacement to preserve all comments. func setEnabled(configPath string, enabled bool) error { raw, err := os.ReadFile(configPath) if err != nil { return err } current := "enabled: true" replacement := "enabled: false" if enabled { current = "enabled: false" replacement = "enabled: true" } updated := strings.Replace(string(raw), current, replacement, 1) if updated == string(raw) { return nil // already in the desired state } return os.WriteFile(configPath, []byte(updated), 0o644) } // ── display helpers ─────────────────────────────────────────────────────── func statusLabel(a agentInfo) string { switch { case !a.Enabled: return "disabled" case isRunning(a.ID): return "● running" default: return "○ stopped" } } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max-1] + "…" } // resolvedBin returns the launcher binary path to use. // Priority: --bin flag > ./bin/launcher (if exists) > go run fallback. func resolvedBin(flagVal string) string { if flagVal != "" { return flagVal } if _, err := os.Stat("bin/launcher"); err == nil { return "bin/launcher" } return "go run ./cmd/launcher" }