// 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" "strings" "syscall" "github.com/spf13/cobra" "github.com/enmanuel/agents/shell/process" ) const ( runDir = "run" agentsGlob = "agents/*/config.yaml" ) // ── entry point ─────────────────────────────────────────────────────────── func main() { var binPath string mgr := process.NewManager(runDir, agentsGlob, "") 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(mgr), startCmd(mgr, &binPath), stopCmd(mgr), reloadCmd(mgr), removeCmd(mgr), avatarCmd(), displaynameCmd(), ) if err := root.Execute(); err != nil { os.Exit(1) } } // ── list ────────────────────────────────────────────────────────────────── func listCmd(mgr *process.Manager) *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 { statuses, err := mgr.StatusAll() if err != nil { return err } if len(statuses) == 0 { fmt.Println("No agents found under agents/*/config.yaml") return nil } fmt.Printf("%-20s %-14s %-8s %-4s %s\n", "ID", "STATUS", "VERSION", "INST", "DESCRIPTION") fmt.Println(strings.Repeat("─", 78)) for _, s := range statuses { fmt.Printf("%-20s %-14s %-8s %-4d %s\n", s.ID, statusLabel(s), s.Version, s.Instances, truncate(s.Desc, 32), ) } return nil }, } } // ── start ───────────────────────────────────────────────────────────────── func startCmd(mgr *process.Manager, 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 { statuses, err := mgr.StatusAll() if err != nil { return err } targets := filterTargets(statuses, args) if len(targets) == 0 { return fmt.Errorf("no matching agents found") } started := 0 for _, s := range targets { if !s.Enabled { fmt.Printf("skip %-20s (disabled in config)\n", s.ID) continue } if err := mgr.Start(s.AgentInfo); err != nil { fmt.Fprintf(os.Stderr, "fail %-20s %v\n", s.ID, err) continue } fmt.Printf("start %-20s PID %d (instances: %d) log → %s\n", s.ID, mgr.ReadPID(s.ID), mgr.InstanceCount(s.ID), mgr.LogPath(s.ID)) started++ } if started == 0 { fmt.Println("Nothing started.") } return nil }, } } // ── stop ────────────────────────────────────────────────────────────────── func stopCmd(mgr *process.Manager) *cobra.Command { return &cobra.Command{ Use: "stop [agent-id...]", Short: "Stop one or all running agents", RunE: func(cmd *cobra.Command, args []string) error { statuses, err := mgr.StatusAll() if err != nil { return err } targets := filterTargets(statuses, args) if len(targets) == 0 { return fmt.Errorf("no matching agents found") } stopped := 0 for _, s := range targets { if !s.Running { fmt.Printf("skip %-20s (not running)\n", s.ID) continue } pid := s.PID if err := mgr.Stop(s.ID); err != nil { fmt.Fprintf(os.Stderr, "fail %-20s %v\n", s.ID, err) continue } fmt.Printf("stop %-20s stopped PID %d\n", s.ID, pid) stopped++ } if stopped == 0 { fmt.Println("Nothing stopped.") } return nil }, } } // ── reload ──────────────────────────────────────────────────────────────── func reloadCmd(mgr *process.Manager) *cobra.Command { return &cobra.Command{ Use: "reload [agent-id]", Short: "Hot-reload an agent (or all agents) without stopping the launcher", Long: `Sends SIGHUP to the running launcher, which triggers a hot-reload. If an agent-id is given, only that agent is reloaded. If no agent-id is given, all agents are reloaded. The launcher must be running. Use 'agentctl start' first if needed.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { pid := mgr.UnifiedPID() if pid <= 0 { return fmt.Errorf("launcher is not running") } target := "" if len(args) == 1 { target = args[0] } if target != "" { if err := os.WriteFile("run/reload.txt", []byte(target), 0o644); err != nil { return fmt.Errorf("write reload target: %w", err) } fmt.Printf("reload %-20s sending SIGHUP to PID %d\n", target, pid) } else { // Remove any stale reload.txt so SIGHUP reloads all agents. _ = os.Remove("run/reload.txt") fmt.Printf("reload %-20s sending SIGHUP to PID %d\n", "(all)", pid) } if err := syscall.Kill(pid, syscall.SIGHUP); err != nil { return fmt.Errorf("kill -HUP %d: %w", pid, err) } return nil }, } } // ── remove ──────────────────────────────────────────────────────────────── func removeCmd(mgr *process.Manager) *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] statuses, err := mgr.StatusAll() if err != nil { return err } var target *process.AgentStatus for i := range statuses { if statuses[i].ID == id { target = &statuses[i] break } } if target == nil { return fmt.Errorf("agent %q not found", id) } if target.Running { if err := mgr.Stop(id); err != nil { fmt.Fprintf(os.Stderr, "warn stop failed: %v\n", err) } else { fmt.Printf("stop %-20s stopped PID %d\n", id, target.PID) } } 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 }, } } // ── helpers ─────────────────────────────────────────────────────────────── func filterTargets(statuses []process.AgentStatus, ids []string) []process.AgentStatus { if len(ids) == 0 { return statuses } set := make(map[string]bool, len(ids)) for _, id := range ids { set[id] = true } var out []process.AgentStatus for _, s := range statuses { if set[s.ID] { out = append(out, s) } } return out } func statusLabel(s process.AgentStatus) string { switch { case !s.Enabled: return "disabled" case s.Running: if s.Instances > 1 { return fmt.Sprintf("● running(%d)", s.Instances) } return "● running" default: return "○ stopped" } } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max-1] + "…" } // setEnabled flips `enabled: true/false` in the agent section of the YAML. 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 } return os.WriteFile(configPath, []byte(updated), 0o644) }