- internal/plugin/types.go: Plugin type definitions with TOML frontmatter schema - internal/plugin/scanner.go: Discover plugins from town and rig directories - internal/plugin/recording.go: Record plugin runs as ephemeral beads - internal/cmd/plugin.go: `gt plugin list` and `gt plugin show` commands Plugin locations: ~/gt/plugins/ (town-level), <rig>/plugins/ (rig-level). Rig-level plugins override town-level by name. Closes: gt-h8k4z, gt-rsejc, gt-n08ix.3 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
327 lines
8.2 KiB
Go
327 lines
8.2 KiB
Go
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
|
|
)
|
|
|
|
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)
|
|
<rig>/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 <name> # 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)
|
|
- <rig>/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 <name>",
|
|
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,
|
|
}
|
|
|
|
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")
|
|
|
|
// Add subcommands
|
|
pluginCmd.AddCommand(pluginListCmd)
|
|
pluginCmd.AddCommand(pluginShowCmd)
|
|
|
|
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
|
|
}
|