feat(plugin): add run and history commands for plugin management

- gt plugin run: Manual plugin execution with gate check
  - --force to bypass cooldown gate
  - --dry-run to preview without executing
  - Records successful runs as ephemeral beads
- gt plugin history: Show execution history from ephemeral beads
  - --json for machine-readable output
  - --limit to control number of results
- Fix recording.go to use valid bd list flags (--created-after instead of --since)

Closes: gt-n08ix.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
george
2026-01-12 16:42:33 -08:00
committed by Steve Yegge
parent 069fe0f285
commit 5a7c328f1f
2 changed files with 192 additions and 4 deletions

View File

@@ -17,8 +17,12 @@ import (
// Plugin command flags
var (
pluginListJSON bool
pluginShowJSON bool
pluginListJSON bool
pluginShowJSON bool
pluginRunForce bool
pluginRunDryRun bool
pluginHistoryJSON bool
pluginHistoryLimit int
)
var pluginCmd = &cobra.Command{
@@ -78,6 +82,37 @@ Examples:
RunE: runPluginShow,
}
var pluginRunCmd = &cobra.Command{
Use: "run <name>",
Short: "Manually trigger plugin execution",
Long: `Manually trigger a plugin to run.
By default, checks if the gate would allow execution and informs you
if it wouldn't. Use --force to bypass gate checks.
Examples:
gt plugin run rebuild-gt # Run if gate allows
gt plugin run rebuild-gt --force # Bypass gate check
gt plugin run rebuild-gt --dry-run # Show what would happen`,
Args: cobra.ExactArgs(1),
RunE: runPluginRun,
}
var pluginHistoryCmd = &cobra.Command{
Use: "history <name>",
Short: "Show plugin execution history",
Long: `Show recent execution history for a plugin.
Queries ephemeral beads (wisps) that record plugin runs.
Examples:
gt plugin history rebuild-gt
gt plugin history rebuild-gt --json
gt plugin history rebuild-gt --limit 20`,
Args: cobra.ExactArgs(1),
RunE: runPluginHistory,
}
func init() {
// List subcommand flags
pluginListCmd.Flags().BoolVar(&pluginListJSON, "json", false, "Output as JSON")
@@ -85,9 +120,19 @@ func init() {
// Show subcommand flags
pluginShowCmd.Flags().BoolVar(&pluginShowJSON, "json", false, "Output as JSON")
// Run subcommand flags
pluginRunCmd.Flags().BoolVar(&pluginRunForce, "force", false, "Bypass gate check")
pluginRunCmd.Flags().BoolVar(&pluginRunDryRun, "dry-run", false, "Show what would happen without executing")
// History subcommand flags
pluginHistoryCmd.Flags().BoolVar(&pluginHistoryJSON, "json", false, "Output as JSON")
pluginHistoryCmd.Flags().IntVar(&pluginHistoryLimit, "limit", 10, "Maximum number of runs to show")
// Add subcommands
pluginCmd.AddCommand(pluginListCmd)
pluginCmd.AddCommand(pluginShowCmd)
pluginCmd.AddCommand(pluginRunCmd)
pluginCmd.AddCommand(pluginHistoryCmd)
rootCmd.AddCommand(pluginCmd)
}
@@ -324,3 +369,139 @@ func outputPluginShowText(p *plugin.Plugin) error {
return nil
}
func runPluginRun(cmd *cobra.Command, args []string) error {
name := args[0]
scanner, townRoot, err := getPluginScanner()
if err != nil {
return err
}
p, err := scanner.GetPlugin(name)
if err != nil {
return err
}
// Check gate status for cooldown gates
gateOpen := true
gateReason := ""
if p.Gate != nil && p.Gate.Type == plugin.GateCooldown && !pluginRunForce {
recorder := plugin.NewRecorder(townRoot)
duration := p.Gate.Duration
if duration == "" {
duration = "1h" // default
}
count, err := recorder.CountRunsSince(p.Name, duration)
if err != nil {
// Log warning but continue
fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err)
} else if count > 0 {
gateOpen = false
gateReason = fmt.Sprintf("ran %d time(s) within %s cooldown", count, duration)
}
}
if pluginRunDryRun {
fmt.Printf("%s Dry run for plugin: %s\n", style.Bold.Render("Plugin:"), p.Name)
fmt.Printf("%s %s\n", style.Bold.Render("Location:"), p.Path)
if p.Gate != nil {
fmt.Printf("%s %s\n", style.Bold.Render("Gate type:"), p.Gate.Type)
}
if !gateOpen {
fmt.Printf("%s %s (use --force to override)\n", style.Warning.Render("Gate closed:"), gateReason)
} else {
fmt.Printf("%s Would execute plugin instructions\n", style.Success.Render("Gate open:"))
}
return nil
}
if !gateOpen && !pluginRunForce {
fmt.Printf("%s Gate closed: %s\n", style.Warning.Render("⚠"), gateReason)
fmt.Printf(" Use --force to bypass gate check\n")
return nil
}
// Execute the plugin
// For manual runs, we print the instructions for the agent/user to execute
// Automatic execution via dogs is handled by gt-n08ix.2
fmt.Printf("%s Running plugin: %s\n", style.Success.Render("●"), p.Name)
if pluginRunForce && !gateOpen {
fmt.Printf(" %s\n", style.Dim.Render("(gate bypassed with --force)"))
}
fmt.Println()
fmt.Printf("%s\n", style.Bold.Render("Instructions:"))
fmt.Println(p.Instructions)
// Record the run
recorder := plugin.NewRecorder(townRoot)
beadID, err := recorder.RecordRun(plugin.PluginRunRecord{
PluginName: p.Name,
RigName: p.RigName,
Result: plugin.ResultSuccess, // Manual runs are marked success
Body: "Manual run via gt plugin run",
})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err)
} else {
fmt.Printf("\n%s Recorded run: %s\n", style.Dim.Render("●"), beadID)
}
return nil
}
func runPluginHistory(cmd *cobra.Command, args []string) error {
name := args[0]
_, townRoot, err := getPluginScanner()
if err != nil {
return err
}
recorder := plugin.NewRecorder(townRoot)
runs, err := recorder.GetRunsSince(name, "")
if err != nil {
return fmt.Errorf("querying history: %w", err)
}
if runs == nil {
runs = []*plugin.PluginRunBead{}
}
// Apply limit
if pluginHistoryLimit > 0 && len(runs) > pluginHistoryLimit {
runs = runs[:pluginHistoryLimit]
}
if pluginHistoryJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(runs)
}
if len(runs) == 0 {
fmt.Printf("%s No execution history for plugin: %s\n", style.Dim.Render("○"), name)
return nil
}
fmt.Printf("%s Execution history for %s (%d runs)\n\n", style.Success.Render("●"), name, len(runs))
for _, run := range runs {
resultStyle := style.Success
resultIcon := "✓"
if run.Result == plugin.ResultFailure {
resultStyle = style.Error
resultIcon = "✗"
} else if run.Result == plugin.ResultSkipped {
resultStyle = style.Dim
resultIcon = "○"
}
fmt.Printf(" %s %s %s\n",
resultStyle.Render(resultIcon),
run.CreatedAt.Format("2006-01-02 15:04"),
style.Dim.Render(run.ID))
}
return nil
}