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:
201
internal/plugin/recording.go
Normal file
201
internal/plugin/recording.go
Normal file
@@ -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
|
||||
}
|
||||
50
internal/plugin/recording_test.go
Normal file
50
internal/plugin/recording_test.go
Normal file
@@ -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.
|
||||
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
|
||||
}
|
||||
278
internal/plugin/scanner_test.go
Normal file
278
internal/plugin/scanner_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
165
internal/plugin/types.go
Normal file
165
internal/plugin/types.go
Normal file
@@ -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: <rig>/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 (<rig>/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"`
|
||||
}
|
||||
Reference in New Issue
Block a user