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:
326
internal/cmd/plugin.go
Normal file
326
internal/cmd/plugin.go
Normal file
@@ -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)
|
||||
<rig>/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 <name> # 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)
|
||||
- <rig>/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 <name>",
|
||||
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
|
||||
}
|
||||
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