package cmd import ( "encoding/json" "fmt" "os" "sort" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/plugin" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Plugin command flags var ( pluginListJSON bool pluginShowJSON bool pluginRunForce bool pluginRunDryRun bool pluginHistoryJSON bool pluginHistoryLimit int ) var pluginCmd = &cobra.Command{ Use: "plugin", GroupID: GroupConfig, Short: "Plugin management", Long: `Manage plugins that run during Deacon patrol cycles. Plugins are periodic automation tasks defined by plugin.md files with TOML frontmatter. PLUGIN LOCATIONS: ~/gt/plugins/ Town-level plugins (universal, apply everywhere) /plugins/ Rig-level plugins (project-specific) GATE TYPES: cooldown Run if enough time has passed (e.g., 1h) cron Run on a schedule (e.g., "0 9 * * *") condition Run if a check command returns exit 0 event Run on events (e.g., startup) manual Never auto-run, trigger explicitly Examples: gt plugin list # List all discovered plugins gt plugin show # Show plugin details gt plugin list --json # JSON output`, RunE: requireSubcommand, } var pluginListCmd = &cobra.Command{ Use: "list", Short: "List all discovered plugins", Long: `List all plugins from town and rig plugin directories. Plugins are discovered from: - ~/gt/plugins/ (town-level) - /plugins/ for each registered rig When a plugin exists at both levels, the rig-level version takes precedence. Examples: gt plugin list # Human-readable output gt plugin list --json # JSON output for scripting`, RunE: runPluginList, } var pluginShowCmd = &cobra.Command{ Use: "show ", Short: "Show plugin details", Long: `Show detailed information about a plugin. Displays the plugin's configuration, gate settings, and instructions. Examples: gt plugin show rebuild-gt gt plugin show rebuild-gt --json`, Args: cobra.ExactArgs(1), RunE: runPluginShow, } var pluginRunCmd = &cobra.Command{ Use: "run ", Short: "Manually trigger plugin execution", Long: `Manually trigger a plugin to run. By default, checks if the gate would allow execution and informs you if it wouldn't. Use --force to bypass gate checks. Examples: gt plugin run rebuild-gt # Run if gate allows gt plugin run rebuild-gt --force # Bypass gate check gt plugin run rebuild-gt --dry-run # Show what would happen`, Args: cobra.ExactArgs(1), RunE: runPluginRun, } var pluginHistoryCmd = &cobra.Command{ Use: "history ", Short: "Show plugin execution history", Long: `Show recent execution history for a plugin. Queries ephemeral beads (wisps) that record plugin runs. Examples: gt plugin history rebuild-gt gt plugin history rebuild-gt --json gt plugin history rebuild-gt --limit 20`, Args: cobra.ExactArgs(1), RunE: runPluginHistory, } func init() { // List subcommand flags pluginListCmd.Flags().BoolVar(&pluginListJSON, "json", false, "Output as JSON") // Show subcommand flags pluginShowCmd.Flags().BoolVar(&pluginShowJSON, "json", false, "Output as JSON") // Run subcommand flags pluginRunCmd.Flags().BoolVar(&pluginRunForce, "force", false, "Bypass gate check") pluginRunCmd.Flags().BoolVar(&pluginRunDryRun, "dry-run", false, "Show what would happen without executing") // History subcommand flags pluginHistoryCmd.Flags().BoolVar(&pluginHistoryJSON, "json", false, "Output as JSON") pluginHistoryCmd.Flags().IntVar(&pluginHistoryLimit, "limit", 10, "Maximum number of runs to show") // Add subcommands pluginCmd.AddCommand(pluginListCmd) pluginCmd.AddCommand(pluginShowCmd) pluginCmd.AddCommand(pluginRunCmd) pluginCmd.AddCommand(pluginHistoryCmd) rootCmd.AddCommand(pluginCmd) } // getPluginScanner creates a scanner with town root and all rig names. func getPluginScanner() (*plugin.Scanner, string, error) { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err) } // Load rigs config to get rig names rigsConfigPath := constants.MayorRigsPath(townRoot) rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} } // Extract rig names rigNames := make([]string, 0, len(rigsConfig.Rigs)) for name := range rigsConfig.Rigs { rigNames = append(rigNames, name) } sort.Strings(rigNames) scanner := plugin.NewScanner(townRoot, rigNames) return scanner, townRoot, nil } func runPluginList(cmd *cobra.Command, args []string) error { scanner, townRoot, err := getPluginScanner() if err != nil { return err } plugins, err := scanner.DiscoverAll() if err != nil { return fmt.Errorf("discovering plugins: %w", err) } // Sort plugins by name sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) if pluginListJSON { return outputPluginListJSON(plugins) } return outputPluginListText(plugins, townRoot) } func outputPluginListJSON(plugins []*plugin.Plugin) error { summaries := make([]plugin.PluginSummary, len(plugins)) for i, p := range plugins { summaries[i] = p.Summary() } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(summaries) } func outputPluginListText(plugins []*plugin.Plugin, townRoot string) error { if len(plugins) == 0 { fmt.Printf("%s No plugins discovered\n", style.Dim.Render("○")) fmt.Printf("\n Plugin directories:\n") fmt.Printf(" %s/plugins/\n", townRoot) fmt.Printf("\n Create a plugin by adding a directory with plugin.md\n") return nil } fmt.Printf("%s Discovered %d plugin(s)\n\n", style.Success.Render("●"), len(plugins)) // Group by location townPlugins := make([]*plugin.Plugin, 0) rigPlugins := make(map[string][]*plugin.Plugin) for _, p := range plugins { if p.Location == plugin.LocationTown { townPlugins = append(townPlugins, p) } else { rigPlugins[p.RigName] = append(rigPlugins[p.RigName], p) } } // Print town-level plugins if len(townPlugins) > 0 { fmt.Printf(" %s\n", style.Bold.Render("Town-level plugins:")) for _, p := range townPlugins { printPluginSummary(p) } fmt.Println() } // Print rig-level plugins by rig rigNames := make([]string, 0, len(rigPlugins)) for name := range rigPlugins { rigNames = append(rigNames, name) } sort.Strings(rigNames) for _, rigName := range rigNames { fmt.Printf(" %s\n", style.Bold.Render(fmt.Sprintf("Rig %s:", rigName))) for _, p := range rigPlugins[rigName] { printPluginSummary(p) } fmt.Println() } return nil } func printPluginSummary(p *plugin.Plugin) { gateType := "manual" if p.Gate != nil && p.Gate.Type != "" { gateType = string(p.Gate.Type) } desc := p.Description if len(desc) > 50 { desc = desc[:47] + "..." } fmt.Printf(" %s %s\n", style.Bold.Render(p.Name), style.Dim.Render(fmt.Sprintf("[%s]", gateType))) if desc != "" { fmt.Printf(" %s\n", style.Dim.Render(desc)) } } func runPluginShow(cmd *cobra.Command, args []string) error { name := args[0] scanner, _, err := getPluginScanner() if err != nil { return err } p, err := scanner.GetPlugin(name) if err != nil { return err } if pluginShowJSON { return outputPluginShowJSON(p) } return outputPluginShowText(p) } func outputPluginShowJSON(p *plugin.Plugin) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(p) } func outputPluginShowText(p *plugin.Plugin) error { fmt.Printf("%s %s\n", style.Bold.Render("Plugin:"), p.Name) fmt.Printf("%s %s\n", style.Bold.Render("Path:"), p.Path) if p.Description != "" { fmt.Printf("%s %s\n", style.Bold.Render("Description:"), p.Description) } // Location locStr := string(p.Location) if p.RigName != "" { locStr = fmt.Sprintf("%s (%s)", p.Location, p.RigName) } fmt.Printf("%s %s\n", style.Bold.Render("Location:"), locStr) fmt.Printf("%s %d\n", style.Bold.Render("Version:"), p.Version) // Gate fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Gate:")) if p.Gate != nil { fmt.Printf(" Type: %s\n", p.Gate.Type) if p.Gate.Duration != "" { fmt.Printf(" Duration: %s\n", p.Gate.Duration) } if p.Gate.Schedule != "" { fmt.Printf(" Schedule: %s\n", p.Gate.Schedule) } if p.Gate.Check != "" { fmt.Printf(" Check: %s\n", p.Gate.Check) } if p.Gate.On != "" { fmt.Printf(" On: %s\n", p.Gate.On) } } else { fmt.Printf(" Type: manual (no gate section)\n") } // Tracking if p.Tracking != nil { fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Tracking:")) if len(p.Tracking.Labels) > 0 { fmt.Printf(" Labels: %s\n", strings.Join(p.Tracking.Labels, ", ")) } fmt.Printf(" Digest: %v\n", p.Tracking.Digest) } // Execution if p.Execution != nil { fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Execution:")) if p.Execution.Timeout != "" { fmt.Printf(" Timeout: %s\n", p.Execution.Timeout) } fmt.Printf(" Notify on failure: %v\n", p.Execution.NotifyOnFailure) if p.Execution.Severity != "" { fmt.Printf(" Severity: %s\n", p.Execution.Severity) } } // Instructions preview if p.Instructions != "" { fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Instructions:")) lines := strings.Split(p.Instructions, "\n") preview := lines if len(lines) > 10 { preview = lines[:10] } for _, line := range preview { fmt.Printf(" %s\n", line) } if len(lines) > 10 { fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("... (%d more lines)", len(lines)-10))) } } return nil } func runPluginRun(cmd *cobra.Command, args []string) error { name := args[0] scanner, townRoot, err := getPluginScanner() if err != nil { return err } p, err := scanner.GetPlugin(name) if err != nil { return err } // Check gate status for cooldown gates gateOpen := true gateReason := "" if p.Gate != nil && p.Gate.Type == plugin.GateCooldown && !pluginRunForce { recorder := plugin.NewRecorder(townRoot) duration := p.Gate.Duration if duration == "" { duration = "1h" // default } count, err := recorder.CountSuccessfulRunsSince(p.Name, duration) if err != nil { // Log warning but continue fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err) } else if count > 0 { gateOpen = false gateReason = fmt.Sprintf("ran %d time(s) within %s cooldown", count, duration) } } if pluginRunDryRun { fmt.Printf("%s Dry run for plugin: %s\n", style.Bold.Render("Plugin:"), p.Name) fmt.Printf("%s %s\n", style.Bold.Render("Location:"), p.Path) if p.Gate != nil { fmt.Printf("%s %s\n", style.Bold.Render("Gate type:"), p.Gate.Type) } if !gateOpen { fmt.Printf("%s %s (use --force to override)\n", style.Warning.Render("Gate closed:"), gateReason) } else { fmt.Printf("%s Would execute plugin instructions\n", style.Success.Render("Gate open:")) } return nil } if !gateOpen && !pluginRunForce { fmt.Printf("%s Gate closed: %s\n", style.Warning.Render("⚠"), gateReason) fmt.Printf(" Use --force to bypass gate check\n") return nil } // Execute the plugin // For manual runs, we print the instructions for the agent/user to execute // Automatic execution via dogs is handled by gt-n08ix.2 fmt.Printf("%s Running plugin: %s\n", style.Success.Render("●"), p.Name) if pluginRunForce && !gateOpen { fmt.Printf(" %s\n", style.Dim.Render("(gate bypassed with --force)")) } fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Instructions:")) fmt.Println(p.Instructions) // Record the run as "skipped" - we only printed instructions, didn't execute // Actual execution happens via Deacon patrol or an agent following the instructions recorder := plugin.NewRecorder(townRoot) beadID, err := recorder.RecordRun(plugin.PluginRunRecord{ PluginName: p.Name, RigName: p.RigName, Result: plugin.ResultSkipped, // Instructions printed, not executed Body: "Manual run via gt plugin run (instructions printed, not executed)", }) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err) } else { fmt.Printf("\n%s Recorded run: %s\n", style.Dim.Render("●"), beadID) } return nil } func runPluginHistory(cmd *cobra.Command, args []string) error { name := args[0] _, townRoot, err := getPluginScanner() if err != nil { return err } recorder := plugin.NewRecorder(townRoot) runs, err := recorder.GetRunsSince(name, "") if err != nil { return fmt.Errorf("querying history: %w", err) } if runs == nil { runs = []*plugin.PluginRunBead{} } // Apply limit if pluginHistoryLimit > 0 && len(runs) > pluginHistoryLimit { runs = runs[:pluginHistoryLimit] } if pluginHistoryJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(runs) } if len(runs) == 0 { fmt.Printf("%s No execution history for plugin: %s\n", style.Dim.Render("○"), name) return nil } fmt.Printf("%s Execution history for %s (%d runs)\n\n", style.Success.Render("●"), name, len(runs)) for _, run := range runs { resultStyle := style.Success resultIcon := "✓" if run.Result == plugin.ResultFailure { resultStyle = style.Error resultIcon = "✗" } else if run.Result == plugin.ResultSkipped { resultStyle = style.Dim resultIcon = "○" } fmt.Printf(" %s %s %s\n", resultStyle.Render(resultIcon), run.CreatedAt.Format("2006-01-02 15:04"), style.Dim.Render(run.ID)) } return nil }