Files
gastown/internal/plugin/scanner.go
george 1e3bf292f9 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), <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>
2026-01-12 16:38:11 -08:00

229 lines
5.8 KiB
Go

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
}