- 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>
229 lines
5.8 KiB
Go
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
|
|
}
|