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>
This commit is contained in:
228
internal/plugin/scanner.go
Normal file
228
internal/plugin/scanner.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user