From 1e3bf292f97a2fe75277c02e87f46b6ae231844c Mon Sep 17 00:00:00 2001 From: george Date: Mon, 12 Jan 2026 16:38:11 -0800 Subject: [PATCH] feat(plugin): add plugin discovery, management, and run tracking - 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), /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 --- internal/cmd/plugin.go | 326 ++++++++++++++++++++++++++++++ internal/plugin/recording.go | 201 ++++++++++++++++++ internal/plugin/recording_test.go | 50 +++++ internal/plugin/scanner.go | 228 +++++++++++++++++++++ internal/plugin/scanner_test.go | 278 +++++++++++++++++++++++++ internal/plugin/types.go | 165 +++++++++++++++ 6 files changed, 1248 insertions(+) create mode 100644 internal/cmd/plugin.go create mode 100644 internal/plugin/recording.go create mode 100644 internal/plugin/recording_test.go create mode 100644 internal/plugin/scanner.go create mode 100644 internal/plugin/scanner_test.go create mode 100644 internal/plugin/types.go diff --git a/internal/cmd/plugin.go b/internal/cmd/plugin.go new file mode 100644 index 00000000..1ec6dab6 --- /dev/null +++ b/internal/cmd/plugin.go @@ -0,0 +1,326 @@ +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) + /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, +} + +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 +} diff --git a/internal/plugin/recording.go b/internal/plugin/recording.go new file mode 100644 index 00000000..4ca5edb7 --- /dev/null +++ b/internal/plugin/recording.go @@ -0,0 +1,201 @@ +package plugin + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "time" +) + +// RunResult represents the outcome of a plugin execution. +type RunResult string + +const ( + ResultSuccess RunResult = "success" + ResultFailure RunResult = "failure" + ResultSkipped RunResult = "skipped" +) + +// PluginRunRecord represents data for creating a plugin run bead. +type PluginRunRecord struct { + PluginName string + RigName string + Result RunResult + Body string +} + +// PluginRunBead represents a recorded plugin run from the ledger. +type PluginRunBead struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + Labels []string `json:"labels"` + Result RunResult `json:"-"` // Parsed from labels +} + +// Recorder handles plugin run recording and querying. +type Recorder struct { + townRoot string +} + +// NewRecorder creates a new plugin run recorder. +func NewRecorder(townRoot string) *Recorder { + return &Recorder{townRoot: townRoot} +} + +// RecordRun creates an ephemeral bead for a plugin run. +// This is pure data writing - the caller decides what result to record. +func (r *Recorder) RecordRun(record PluginRunRecord) (string, error) { + title := fmt.Sprintf("Plugin run: %s", record.PluginName) + + // Build labels + labels := []string{ + "type:plugin-run", + fmt.Sprintf("plugin:%s", record.PluginName), + fmt.Sprintf("result:%s", record.Result), + } + if record.RigName != "" { + labels = append(labels, fmt.Sprintf("rig:%s", record.RigName)) + } + + // Build bd create command + args := []string{ + "create", + "--ephemeral", + "--json", + "--title=" + title, + } + for _, label := range labels { + args = append(args, "-l", label) + } + if record.Body != "" { + args = append(args, "--description="+record.Body) + } + + cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool + cmd.Dir = r.townRoot + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("creating plugin run bead: %s: %w", stderr.String(), err) + } + + // Parse created bead ID from JSON output + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + return "", fmt.Errorf("parsing bd create output: %w", err) + } + + return result.ID, nil +} + +// GetLastRun returns the most recent run for a plugin. +// Returns nil if no runs found. +func (r *Recorder) GetLastRun(pluginName string) (*PluginRunBead, error) { + runs, err := r.queryRuns(pluginName, 1, "") + if err != nil { + return nil, err + } + if len(runs) == 0 { + return nil, nil + } + return runs[0], nil +} + +// GetRunsSince returns all runs for a plugin since the given duration. +// Duration format: "1h", "24h", "7d", etc. +func (r *Recorder) GetRunsSince(pluginName string, since string) ([]*PluginRunBead, error) { + return r.queryRuns(pluginName, 0, since) +} + +// queryRuns queries plugin run beads from the ledger. +func (r *Recorder) queryRuns(pluginName string, limit int, since string) ([]*PluginRunBead, error) { + args := []string{ + "list", + "--ephemeral", + "--json", + "-l", "type:plugin-run", + "-l", fmt.Sprintf("plugin:%s", pluginName), + } + if limit > 0 { + args = append(args, fmt.Sprintf("--limit=%d", limit)) + } + if since != "" { + args = append(args, "--since="+since) + } + + cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool + cmd.Dir = r.townRoot + cmd.Env = os.Environ() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Empty result is OK (no runs found) + if stderr.Len() == 0 || stdout.String() == "[]\n" { + return nil, nil + } + return nil, fmt.Errorf("querying plugin runs: %s: %w", stderr.String(), err) + } + + // Parse JSON output + var beads []struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + Labels []string `json:"labels"` + } + if err := json.Unmarshal(stdout.Bytes(), &beads); err != nil { + // Empty array is valid + if stdout.String() == "[]\n" || stdout.Len() == 0 { + return nil, nil + } + return nil, fmt.Errorf("parsing bd list output: %w", err) + } + + // Convert to PluginRunBead with parsed result + runs := make([]*PluginRunBead, 0, len(beads)) + for _, b := range beads { + run := &PluginRunBead{ + ID: b.ID, + Title: b.Title, + Labels: b.Labels, + } + + // Parse created_at + if t, err := time.Parse(time.RFC3339, b.CreatedAt); err == nil { + run.CreatedAt = t + } + + // Extract result from labels + for _, label := range b.Labels { + if len(label) > 7 && label[:7] == "result:" { + run.Result = RunResult(label[7:]) + break + } + } + + runs = append(runs, run) + } + + return runs, nil +} + +// CountRunsSince returns the count of runs for a plugin since the given duration. +// This is useful for cooldown gate evaluation. +func (r *Recorder) CountRunsSince(pluginName string, since string) (int, error) { + runs, err := r.GetRunsSince(pluginName, since) + if err != nil { + return 0, err + } + return len(runs), nil +} diff --git a/internal/plugin/recording_test.go b/internal/plugin/recording_test.go new file mode 100644 index 00000000..414855d4 --- /dev/null +++ b/internal/plugin/recording_test.go @@ -0,0 +1,50 @@ +package plugin + +import ( + "testing" +) + +func TestPluginRunRecord(t *testing.T) { + record := PluginRunRecord{ + PluginName: "test-plugin", + RigName: "gastown", + Result: ResultSuccess, + Body: "Test run completed successfully", + } + + if record.PluginName != "test-plugin" { + t.Errorf("expected plugin name 'test-plugin', got %q", record.PluginName) + } + if record.RigName != "gastown" { + t.Errorf("expected rig name 'gastown', got %q", record.RigName) + } + if record.Result != ResultSuccess { + t.Errorf("expected result 'success', got %q", record.Result) + } +} + +func TestRunResultConstants(t *testing.T) { + if ResultSuccess != "success" { + t.Errorf("expected ResultSuccess to be 'success', got %q", ResultSuccess) + } + if ResultFailure != "failure" { + t.Errorf("expected ResultFailure to be 'failure', got %q", ResultFailure) + } + if ResultSkipped != "skipped" { + t.Errorf("expected ResultSkipped to be 'skipped', got %q", ResultSkipped) + } +} + +func TestNewRecorder(t *testing.T) { + recorder := NewRecorder("/tmp/test-town") + if recorder == nil { + t.Fatal("NewRecorder returned nil") + } + if recorder.townRoot != "/tmp/test-town" { + t.Errorf("expected townRoot '/tmp/test-town', got %q", recorder.townRoot) + } +} + +// Integration tests for RecordRun, GetLastRun, GetRunsSince require +// a working beads installation and are skipped in unit tests. +// These functions shell out to `bd` commands. diff --git a/internal/plugin/scanner.go b/internal/plugin/scanner.go new file mode 100644 index 00000000..1e19efae --- /dev/null +++ b/internal/plugin/scanner.go @@ -0,0 +1,228 @@ +package plugin + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// Scanner discovers plugins in town and rig directories. +type Scanner struct { + townRoot string + rigNames []string +} + +// NewScanner creates a new plugin scanner. +func NewScanner(townRoot string, rigNames []string) *Scanner { + return &Scanner{ + townRoot: townRoot, + rigNames: rigNames, + } +} + +// DiscoverAll scans all plugin locations and returns discovered plugins. +// Town-level plugins are scanned first, then rig-level plugins. +// Plugins are deduplicated by name (rig-level overrides town-level). +func (s *Scanner) DiscoverAll() ([]*Plugin, error) { + pluginMap := make(map[string]*Plugin) + + // Scan town-level plugins first + townPlugins, err := s.scanTownPlugins() + if err != nil { + return nil, fmt.Errorf("scanning town plugins: %w", err) + } + for _, p := range townPlugins { + pluginMap[p.Name] = p + } + + // Scan rig-level plugins (override town-level by name) + for _, rigName := range s.rigNames { + rigPlugins, err := s.scanRigPlugins(rigName) + if err != nil { + // Log warning but continue with other rigs + fmt.Fprintf(os.Stderr, "Warning: scanning plugins for rig %q: %v\n", rigName, err) + continue + } + for _, p := range rigPlugins { + pluginMap[p.Name] = p + } + } + + // Convert map to slice + plugins := make([]*Plugin, 0, len(pluginMap)) + for _, p := range pluginMap { + plugins = append(plugins, p) + } + + return plugins, nil +} + +// scanTownPlugins scans the town-level plugins directory. +func (s *Scanner) scanTownPlugins() ([]*Plugin, error) { + pluginsDir := filepath.Join(s.townRoot, "plugins") + return s.scanDirectory(pluginsDir, LocationTown, "") +} + +// scanRigPlugins scans a rig's plugins directory. +func (s *Scanner) scanRigPlugins(rigName string) ([]*Plugin, error) { + pluginsDir := filepath.Join(s.townRoot, rigName, "plugins") + return s.scanDirectory(pluginsDir, LocationRig, rigName) +} + +// scanDirectory scans a plugins directory for plugin definitions. +func (s *Scanner) scanDirectory(dir string, location Location, rigName string) ([]*Plugin, error) { + // Check if directory exists + info, err := os.Stat(dir) + if os.IsNotExist(err) { + return nil, nil // No plugins directory is fine + } + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, nil + } + + // List plugin directories + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var plugins []*Plugin + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + + pluginDir := filepath.Join(dir, entry.Name()) + plugin, err := s.loadPlugin(pluginDir, location, rigName) + if err != nil { + // Log warning but continue with other plugins + fmt.Fprintf(os.Stderr, "Warning: loading plugin %q: %v\n", entry.Name(), err) + continue + } + if plugin != nil { + plugins = append(plugins, plugin) + } + } + + return plugins, nil +} + +// loadPlugin loads a plugin from its directory. +func (s *Scanner) loadPlugin(pluginDir string, location Location, rigName string) (*Plugin, error) { + // Look for plugin.md + pluginFile := filepath.Join(pluginDir, "plugin.md") + if _, err := os.Stat(pluginFile); os.IsNotExist(err) { + return nil, nil // No plugin.md, skip + } + + // Read and parse plugin.md + content, err := os.ReadFile(pluginFile) //nolint:gosec // G304: path is from trusted plugin directory + if err != nil { + return nil, fmt.Errorf("reading plugin.md: %w", err) + } + + return parsePluginMD(content, pluginDir, location, rigName) +} + +// parsePluginMD parses a plugin.md file with TOML frontmatter. +// Format: +// +// +++ +// name = "plugin-name" +// ... +// +++ +// # Instructions +// ... +func parsePluginMD(content []byte, pluginDir string, location Location, rigName string) (*Plugin, error) { + str := string(content) + + // Find TOML frontmatter delimiters + const delimiter = "+++" + start := strings.Index(str, delimiter) + if start == -1 { + return nil, fmt.Errorf("missing TOML frontmatter (no opening +++)") + } + + // Find closing delimiter + end := strings.Index(str[start+len(delimiter):], delimiter) + if end == -1 { + return nil, fmt.Errorf("missing TOML frontmatter (no closing +++)") + } + end += start + len(delimiter) + + // Extract frontmatter and body + frontmatter := str[start+len(delimiter) : end] + body := strings.TrimSpace(str[end+len(delimiter):]) + + // Parse TOML frontmatter + var fm PluginFrontmatter + if _, err := toml.Decode(frontmatter, &fm); err != nil { + return nil, fmt.Errorf("parsing TOML frontmatter: %w", err) + } + + // Validate required fields + if fm.Name == "" { + return nil, fmt.Errorf("missing required field: name") + } + + plugin := &Plugin{ + Name: fm.Name, + Description: fm.Description, + Version: fm.Version, + Location: location, + Path: pluginDir, + RigName: rigName, + Gate: fm.Gate, + Tracking: fm.Tracking, + Execution: fm.Execution, + Instructions: body, + } + + return plugin, nil +} + +// GetPlugin returns a specific plugin by name. +// Searches rig-level plugins first (more specific), then town-level. +func (s *Scanner) GetPlugin(name string) (*Plugin, error) { + // Search rig-level plugins first + for _, rigName := range s.rigNames { + pluginDir := filepath.Join(s.townRoot, rigName, "plugins", name) + plugin, err := s.loadPlugin(pluginDir, LocationRig, rigName) + if err != nil { + continue + } + if plugin != nil { + return plugin, nil + } + } + + // Search town-level plugins + pluginDir := filepath.Join(s.townRoot, "plugins", name) + plugin, err := s.loadPlugin(pluginDir, LocationTown, "") + if err != nil { + return nil, err + } + if plugin == nil { + return nil, fmt.Errorf("plugin not found: %s", name) + } + + return plugin, nil +} + +// ListPluginDirs returns the directories where plugins are stored. +func (s *Scanner) ListPluginDirs() []string { + dirs := []string{filepath.Join(s.townRoot, "plugins")} + for _, rigName := range s.rigNames { + dirs = append(dirs, filepath.Join(s.townRoot, rigName, "plugins")) + } + return dirs +} diff --git a/internal/plugin/scanner_test.go b/internal/plugin/scanner_test.go new file mode 100644 index 00000000..f8996aa4 --- /dev/null +++ b/internal/plugin/scanner_test.go @@ -0,0 +1,278 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParsePluginMD(t *testing.T) { + content := []byte(`+++ +name = "test-plugin" +description = "A test plugin" +version = 1 + +[gate] +type = "cooldown" +duration = "1h" + +[tracking] +labels = ["test:label"] +digest = true + +[execution] +timeout = "5m" +notify_on_failure = true ++++ + +# Test Plugin + +These are the instructions. +`) + + plugin, err := parsePluginMD(content, "/test/path", LocationTown, "") + if err != nil { + t.Fatalf("parsePluginMD failed: %v", err) + } + + if plugin.Name != "test-plugin" { + t.Errorf("expected name 'test-plugin', got %q", plugin.Name) + } + if plugin.Description != "A test plugin" { + t.Errorf("expected description 'A test plugin', got %q", plugin.Description) + } + if plugin.Version != 1 { + t.Errorf("expected version 1, got %d", plugin.Version) + } + if plugin.Location != LocationTown { + t.Errorf("expected location 'town', got %q", plugin.Location) + } + if plugin.Gate == nil { + t.Fatal("expected gate to be non-nil") + } + if plugin.Gate.Type != GateCooldown { + t.Errorf("expected gate type 'cooldown', got %q", plugin.Gate.Type) + } + if plugin.Gate.Duration != "1h" { + t.Errorf("expected gate duration '1h', got %q", plugin.Gate.Duration) + } + if plugin.Tracking == nil { + t.Fatal("expected tracking to be non-nil") + } + if len(plugin.Tracking.Labels) != 1 || plugin.Tracking.Labels[0] != "test:label" { + t.Errorf("expected labels ['test:label'], got %v", plugin.Tracking.Labels) + } + if !plugin.Tracking.Digest { + t.Error("expected digest to be true") + } + if plugin.Execution == nil { + t.Fatal("expected execution to be non-nil") + } + if plugin.Execution.Timeout != "5m" { + t.Errorf("expected timeout '5m', got %q", plugin.Execution.Timeout) + } + if !plugin.Execution.NotifyOnFailure { + t.Error("expected notify_on_failure to be true") + } + if plugin.Instructions == "" { + t.Error("expected instructions to be non-empty") + } +} + +func TestParsePluginMD_MissingName(t *testing.T) { + content := []byte(`+++ +description = "No name" ++++ + +# No Name Plugin +`) + + _, err := parsePluginMD(content, "/test/path", LocationTown, "") + if err == nil { + t.Error("expected error for missing name") + } +} + +func TestParsePluginMD_MissingFrontmatter(t *testing.T) { + content := []byte(`# No Frontmatter + +Just instructions. +`) + + _, err := parsePluginMD(content, "/test/path", LocationTown, "") + if err == nil { + t.Error("expected error for missing frontmatter") + } +} + +func TestParsePluginMD_ManualGate(t *testing.T) { + // Plugin with no gate section should have nil Gate + content := []byte(`+++ +name = "manual-plugin" +description = "A manual plugin" +version = 1 ++++ + +# Manual Plugin +`) + + plugin, err := parsePluginMD(content, "/test/path", LocationTown, "") + if err != nil { + t.Fatalf("parsePluginMD failed: %v", err) + } + + if plugin.Gate != nil { + t.Error("expected gate to be nil for manual plugin") + } + + // Summary should report gate type as manual + summary := plugin.Summary() + if summary.GateType != GateManual { + t.Errorf("expected gate type 'manual', got %q", summary.GateType) + } +} + +func TestScanner_DiscoverAll(t *testing.T) { + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "plugin-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create town plugins directory + townPluginsDir := filepath.Join(tmpDir, "plugins") + if err := os.MkdirAll(townPluginsDir, 0755); err != nil { + t.Fatalf("failed to create town plugins dir: %v", err) + } + + // Create a town plugin + townPlugin := filepath.Join(townPluginsDir, "town-plugin") + if err := os.MkdirAll(townPlugin, 0755); err != nil { + t.Fatalf("failed to create town plugin dir: %v", err) + } + townPluginContent := []byte(`+++ +name = "town-plugin" +description = "Town level plugin" +version = 1 ++++ + +# Town Plugin +`) + if err := os.WriteFile(filepath.Join(townPlugin, "plugin.md"), townPluginContent, 0644); err != nil { + t.Fatalf("failed to write town plugin: %v", err) + } + + // Create rig plugins directory + rigPluginsDir := filepath.Join(tmpDir, "testrig", "plugins") + if err := os.MkdirAll(rigPluginsDir, 0755); err != nil { + t.Fatalf("failed to create rig plugins dir: %v", err) + } + + // Create a rig plugin + rigPlugin := filepath.Join(rigPluginsDir, "rig-plugin") + if err := os.MkdirAll(rigPlugin, 0755); err != nil { + t.Fatalf("failed to create rig plugin dir: %v", err) + } + rigPluginContent := []byte(`+++ +name = "rig-plugin" +description = "Rig level plugin" +version = 1 ++++ + +# Rig Plugin +`) + if err := os.WriteFile(filepath.Join(rigPlugin, "plugin.md"), rigPluginContent, 0644); err != nil { + t.Fatalf("failed to write rig plugin: %v", err) + } + + // Create scanner + scanner := NewScanner(tmpDir, []string{"testrig"}) + + // Discover all plugins + plugins, err := scanner.DiscoverAll() + if err != nil { + t.Fatalf("DiscoverAll failed: %v", err) + } + + if len(plugins) != 2 { + t.Errorf("expected 2 plugins, got %d", len(plugins)) + } + + // Check that we have both plugins + names := make(map[string]bool) + for _, p := range plugins { + names[p.Name] = true + } + + if !names["town-plugin"] { + t.Error("expected to find 'town-plugin'") + } + if !names["rig-plugin"] { + t.Error("expected to find 'rig-plugin'") + } +} + +func TestScanner_RigOverridesTown(t *testing.T) { + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "plugin-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create town plugins directory with a plugin + townPluginsDir := filepath.Join(tmpDir, "plugins", "shared-plugin") + if err := os.MkdirAll(townPluginsDir, 0755); err != nil { + t.Fatalf("failed to create town plugins dir: %v", err) + } + townPluginContent := []byte(`+++ +name = "shared-plugin" +description = "Town version" +version = 1 ++++ + +# Town Version +`) + if err := os.WriteFile(filepath.Join(townPluginsDir, "plugin.md"), townPluginContent, 0644); err != nil { + t.Fatalf("failed to write town plugin: %v", err) + } + + // Create rig plugins directory with same-named plugin + rigPluginsDir := filepath.Join(tmpDir, "testrig", "plugins", "shared-plugin") + if err := os.MkdirAll(rigPluginsDir, 0755); err != nil { + t.Fatalf("failed to create rig plugins dir: %v", err) + } + rigPluginContent := []byte(`+++ +name = "shared-plugin" +description = "Rig version" +version = 1 ++++ + +# Rig Version +`) + if err := os.WriteFile(filepath.Join(rigPluginsDir, "plugin.md"), rigPluginContent, 0644); err != nil { + t.Fatalf("failed to write rig plugin: %v", err) + } + + // Create scanner + scanner := NewScanner(tmpDir, []string{"testrig"}) + + // Discover all plugins + plugins, err := scanner.DiscoverAll() + if err != nil { + t.Fatalf("DiscoverAll failed: %v", err) + } + + // Should only have one plugin (rig overrides town) + if len(plugins) != 1 { + t.Errorf("expected 1 plugin (rig override), got %d", len(plugins)) + } + + if plugins[0].Description != "Rig version" { + t.Errorf("expected rig version description, got %q", plugins[0].Description) + } + if plugins[0].Location != LocationRig { + t.Errorf("expected location 'rig', got %q", plugins[0].Location) + } +} diff --git a/internal/plugin/types.go b/internal/plugin/types.go new file mode 100644 index 00000000..785639b6 --- /dev/null +++ b/internal/plugin/types.go @@ -0,0 +1,165 @@ +// Package plugin provides plugin discovery and management for Gas Town. +// +// Plugins are periodic automation tasks that run during Deacon patrol cycles. +// Each plugin is defined by a plugin.md file with TOML frontmatter. +// +// Plugin locations: +// - Town-level: ~/gt/plugins/ (universal, apply everywhere) +// - Rig-level: /plugins/ (project-specific) +package plugin + +import ( + "time" +) + +// Plugin represents a discovered plugin definition. +type Plugin struct { + // Name is the unique plugin identifier (from frontmatter). + Name string `json:"name"` + + // Description is a human-readable description. + Description string `json:"description"` + + // Version is the schema version (for future evolution). + Version int `json:"version"` + + // Location indicates where the plugin was discovered. + Location Location `json:"location"` + + // Path is the absolute path to the plugin directory. + Path string `json:"path"` + + // RigName is set for rig-level plugins (empty for town-level). + RigName string `json:"rig_name,omitempty"` + + // Gate defines when the plugin should run. + Gate *Gate `json:"gate,omitempty"` + + // Tracking defines labels and digest settings. + Tracking *Tracking `json:"tracking,omitempty"` + + // Execution defines timeout and notification settings. + Execution *Execution `json:"execution,omitempty"` + + // Instructions is the markdown body (after frontmatter). + Instructions string `json:"instructions,omitempty"` +} + +// Location indicates where a plugin was discovered. +type Location string + +const ( + // LocationTown indicates a town-level plugin (~/gt/plugins/). + LocationTown Location = "town" + + // LocationRig indicates a rig-level plugin (/plugins/). + LocationRig Location = "rig" +) + +// Gate defines when a plugin should run. +type Gate struct { + // Type is the gate type: cooldown, cron, condition, event, or manual. + Type GateType `json:"type" toml:"type"` + + // Duration is for cooldown gates (e.g., "1h", "24h"). + Duration string `json:"duration,omitempty" toml:"duration,omitempty"` + + // Schedule is for cron gates (e.g., "0 9 * * *"). + Schedule string `json:"schedule,omitempty" toml:"schedule,omitempty"` + + // Check is for condition gates (command that returns exit 0 to run). + Check string `json:"check,omitempty" toml:"check,omitempty"` + + // On is for event gates (e.g., "startup"). + On string `json:"on,omitempty" toml:"on,omitempty"` +} + +// GateType is the type of gate that controls plugin execution. +type GateType string + +const ( + // GateCooldown runs if enough time has passed since last run. + GateCooldown GateType = "cooldown" + + // GateCron runs on a cron schedule. + GateCron GateType = "cron" + + // GateCondition runs if a check command returns exit 0. + GateCondition GateType = "condition" + + // GateEvent runs on specific events (startup, etc). + GateEvent GateType = "event" + + // GateManual never auto-runs, must be triggered explicitly. + GateManual GateType = "manual" +) + +// Tracking defines how plugin runs are tracked. +type Tracking struct { + // Labels are applied to execution wisps. + Labels []string `json:"labels,omitempty" toml:"labels,omitempty"` + + // Digest indicates whether to include in daily digest. + Digest bool `json:"digest" toml:"digest"` +} + +// Execution defines plugin execution settings. +type Execution struct { + // Timeout is the maximum execution time (e.g., "5m"). + Timeout string `json:"timeout,omitempty" toml:"timeout,omitempty"` + + // NotifyOnFailure escalates on failure. + NotifyOnFailure bool `json:"notify_on_failure" toml:"notify_on_failure"` + + // Severity is the escalation severity on failure. + Severity string `json:"severity,omitempty" toml:"severity,omitempty"` +} + +// PluginFrontmatter represents the TOML frontmatter in plugin.md files. +type PluginFrontmatter struct { + Name string `toml:"name"` + Description string `toml:"description"` + Version int `toml:"version"` + Gate *Gate `toml:"gate,omitempty"` + Tracking *Tracking `toml:"tracking,omitempty"` + Execution *Execution `toml:"execution,omitempty"` +} + +// PluginSummary provides a concise overview of a plugin. +type PluginSummary struct { + Name string `json:"name"` + Description string `json:"description"` + Location Location `json:"location"` + RigName string `json:"rig_name,omitempty"` + GateType GateType `json:"gate_type,omitempty"` + Path string `json:"path"` +} + +// Summary returns a PluginSummary for this plugin. +func (p *Plugin) Summary() PluginSummary { + var gateType GateType + if p.Gate != nil { + gateType = p.Gate.Type + } else { + gateType = GateManual + } + + return PluginSummary{ + Name: p.Name, + Description: p.Description, + Location: p.Location, + RigName: p.RigName, + GateType: gateType, + Path: p.Path, + } +} + +// PluginRun represents a single execution of a plugin. +type PluginRun struct { + PluginName string `json:"plugin_name"` + RigName string `json:"rig_name,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + Result string `json:"result"` // "success" or "failure" + Message string `json:"message,omitempty"` +}