From 046ce067dfa59b084b90ece5e4a26ca6b8eea952 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 30 Jun 2026 11:13:42 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(hooks):=20community=20plugins=20?= =?UTF-8?q?=E2=80=94=20droppable,=20fan-out=20task=20event=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today task event hooks are one script per event in a shared dir, so two integrations that both want e.g. task.done collide on the same file. This adds plugins: a plugin is a self-contained directory under ~/.config/task/plugins/ with a plugin.yaml manifest declaring which events it handles. Drop it in and it's active — any number of plugins can handle the same event and all run. - internal/hooks/plugins.go: manifest schema + forgiving discovery/validation (a bad community plugin is skipped, not fatal). - internal/hooks: Runner fans out each event to the legacy single-script hook plus every matching plugin, in the background. Plugin hooks get TASK_PLUGIN_NAME/TASK_PLUGIN_DIR and run with cwd set to the plugin dir. - Move the 30s hook timeout into the background goroutine so the hook isn't cancelled the instant Run() returns. - cmd/task: `ty plugins list` / `ty plugins dir` to inspect what's installed. - examples/plugins/desktop-notify: a complete, copy-pasteable plugin. - docs/plugins.md + README: manifest format and authoring guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 ++- cmd/task/main.go | 3 + cmd/task/plugins.go | 79 +++++++++ docs/plugins.md | 99 ++++++++++++ examples/plugins/desktop-notify/notify.sh | 24 +++ examples/plugins/desktop-notify/plugin.yaml | 14 ++ internal/hooks/hooks.go | 95 ++++++++--- internal/hooks/plugins.go | 135 ++++++++++++++++ internal/hooks/plugins_test.go | 168 ++++++++++++++++++++ 9 files changed, 608 insertions(+), 27 deletions(-) create mode 100644 cmd/task/plugins.go create mode 100644 docs/plugins.md create mode 100755 examples/plugins/desktop-notify/notify.sh create mode 100644 examples/plugins/desktop-notify/plugin.yaml create mode 100644 internal/hooks/plugins.go create mode 100644 internal/hooks/plugins_test.go diff --git a/README.md b/README.md index d1e6c58f..0bc90d09 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The same UI is also served in your browser at `http://localhost:8484` whenever ` - **Kanban Board** - Visual task management with 4 columns (Backlog, In Progress, Blocked, Done) - **Git Worktrees** - Each task runs in an isolated worktree, no conflicts between parallel tasks - **Pluggable Executors** - Choose between Claude Code, OpenAI Codex, Gemini, Pi, OpenClaw, or OpenCode per task -- **Event Hooks** - Run scripts when tasks change state (see [Event Hooks](#event-hooks)) +- **Event Hooks & Plugins** - Run scripts when tasks change state, or drop in self-contained plugins (see [Event Hooks](#event-hooks) and [Plugins](#plugins)) - **Ghost Text Autocomplete** - LLM-powered suggestions for task titles and descriptions as you type - **VS Code-style Fuzzy Search** - Quick task navigation with smart matching (e.g., "dsno" matches "diseno website") - **Markdown Rendering** - Task descriptions render with proper formatting in the detail view @@ -569,6 +569,22 @@ TASK_TIMESTAMP # ISO 8601 timestamp See [examples/hooks/](examples/hooks/) for examples. +### Plugins + +The hooks dir above holds **one script per event**, so two integrations that +both want `task.done` collide. **Plugins** solve that: a plugin is a +self-contained directory under `~/.config/task/plugins/` with a `plugin.yaml` +manifest declaring which events it handles. Drop it in and it's live — any +number of plugins can handle the same event, and all of them run. + +```bash +cp -R examples/plugins/desktop-notify ~/.config/task/plugins/ +ty plugins list +``` + +See [docs/plugins.md](docs/plugins.md) for the manifest format and the authoring +guide. + ## Configuration ### Settings diff --git a/cmd/task/main.go b/cmd/task/main.go index 819fb4de..63fb2a14 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -511,6 +511,9 @@ Examples: rootCmd.AddCommand(sessionsCmd) + // Plugins subcommand - inspect installed task plugins + rootCmd.AddCommand(newPluginsCmd()) + // Alias: claudes -> sessions (for backwards compatibility) claudesCmd := &cobra.Command{ Use: "claudes", diff --git a/cmd/task/plugins.go b/cmd/task/plugins.go new file mode 100644 index 00000000..e964cd61 --- /dev/null +++ b/cmd/task/plugins.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "os" + "sort" + + "github.com/spf13/cobra" + + "github.com/bborn/workflow/internal/hooks" +) + +// newPluginsCmd returns the `ty plugins` command group for inspecting installed +// task plugins (self-contained hook bundles under ~/.config/task/plugins/). +func newPluginsCmd() *cobra.Command { + pluginsCmd := &cobra.Command{ + Use: "plugins", + Short: "List and inspect installed task plugins", + Long: `Task plugins are self-contained directories under ~/.config/task/plugins/ +that react to task events. Each plugin has a plugin.yaml manifest declaring +which events it handles; drop a plugin directory in and it is active. Any number +of plugins may handle the same event.`, + Run: func(cmd *cobra.Command, args []string) { + listPlugins() + }, + } + + pluginsCmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed plugins and the events they handle", + Run: func(cmd *cobra.Command, args []string) { listPlugins() }, + }) + + pluginsCmd.AddCommand(&cobra.Command{ + Use: "dir", + Short: "Print the plugins directory path", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(hooks.DefaultPluginsDir()) + }, + }) + + return pluginsCmd +} + +func listPlugins() { + dir := hooks.DefaultPluginsDir() + plugins, warnings := hooks.LoadPlugins(dir) + + for _, w := range warnings { + fmt.Fprintln(os.Stderr, "warning: "+w) + } + + if len(plugins) == 0 { + fmt.Printf("No plugins installed in %s\n", dir) + fmt.Println("Add one by creating /plugin.yaml there. See docs/plugins.md.") + return + } + + fmt.Printf("Plugins in %s:\n\n", dir) + for _, p := range plugins { + ver := p.Version + if ver == "" { + ver = "—" + } + fmt.Printf(" %s (%s)\n", p.Name, ver) + if p.Description != "" { + fmt.Printf(" %s\n", p.Description) + } + events := make([]string, 0, len(p.Hooks)) + for e := range p.Hooks { + events = append(events, e) + } + sort.Strings(events) + for _, e := range events { + fmt.Printf(" %-22s → %s\n", e, p.Hooks[e]) + } + fmt.Println() + } +} diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..138b57d8 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,99 @@ +# TaskYou plugins + +A plugin is a **self-contained directory** that reacts to task events. Drop it +into `~/.config/task/plugins/` and it's live — no rebuild, no config edits, and +no collisions with other plugins. This is the easy on-ramp for community +integrations (notifications, proxies, trackers, chat bridges, …). + +It builds on TaskYou's existing [event hooks](../README.md#event-hooks). The +difference: the legacy hooks dir allows **one script per event**, so two +integrations that both want `task.done` fight over the same file. A plugin +namespaces its scripts in its own directory and declares what it handles in a +manifest, so **any number of plugins can handle the same event** and all of them +run. + +## Anatomy + +``` +~/.config/task/plugins/ +└── my-plugin/ + ├── plugin.yaml # manifest (required) + └── on-done.sh # your script(s) +``` + +### `plugin.yaml` + +```yaml +name: my-plugin # required, unique +version: 0.1.0 # optional +description: What it does. # optional +hooks: # event -> script path (relative to this dir) + task.done: on-done.sh + task.blocked: on-blocked.sh +``` + +Script paths are resolved relative to the plugin directory. Scripts must be +executable (`chmod +x`). + +## Events + +Plugins handle the same events as the [event hooks](../README.md#event-hooks) +system. The ones dispatched today: + +| Event | When | +|-------|------| +| `task.started` | Execution begins | +| `task.done` | Agent finished successfully | +| `task.blocked` | Task needs input | +| `task.failed` | Agent execution failed | +| `task.auth_required` | Executor session needs re-authentication | + +A plugin may declare any event string; it only runs for events TaskYou actually +emits, so unknown events are harmless. + +## Environment + +Every hook receives the standard task variables: + +``` +TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE +TASK_MESSAGE TASK_EVENT WORKTREE_PATH +``` + +Plugin hooks additionally receive: + +``` +TASK_PLUGIN_NAME # this plugin's name +TASK_PLUGIN_DIR # absolute path to this plugin's directory +``` + +The script's working directory is set to the plugin directory, so it can read +its own bundled files (config, templates, helper binaries) with relative paths. + +## Behavior & guarantees + +- **Fan-out**: for each event, the legacy single-script hook *and* every plugin + that declares the event run, concurrently and in the background. A slow or + failing plugin never blocks task execution or the other plugins. +- **Isolation**: a malformed manifest, a missing script, or a plugin with no + name is skipped (surfaced via `ty plugins list` and the daemon log) — one bad + community plugin can't break your pipeline. +- **Timeout**: each hook is given 30s before it's killed. +- **Deterministic order**: plugins run sorted by name. + +## Inspecting + +```bash +ty plugins list # what's installed and which events each handles +ty plugins dir # the plugins directory path +``` + +## Example + +A complete, copy-pasteable plugin lives at +[`examples/plugins/desktop-notify/`](../examples/plugins/desktop-notify/): + +```bash +cp -R examples/plugins/desktop-notify ~/.config/task/plugins/ +ty plugins list +``` diff --git a/examples/plugins/desktop-notify/notify.sh b/examples/plugins/desktop-notify/notify.sh new file mode 100755 index 00000000..a0949b14 --- /dev/null +++ b/examples/plugins/desktop-notify/notify.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Desktop-notify plugin: fire a native notification on a task event. +# +# TaskYou exports these for every hook: +# TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE +# TASK_MESSAGE TASK_EVENT WORKTREE_PATH +# and for plugin hooks specifically: +# TASK_PLUGIN_NAME TASK_PLUGIN_DIR +set -euo pipefail + +title="TaskYou: ${TASK_EVENT#task.}" +body="#${TASK_ID} ${TASK_TITLE} — ${TASK_MESSAGE:-}" + +case "$(uname -s)" in + Darwin) + osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title}\"" || true + ;; + Linux) + command -v notify-send >/dev/null 2>&1 && notify-send "$title" "$body" || true + ;; + *) + echo "[$title] $body" + ;; +esac diff --git a/examples/plugins/desktop-notify/plugin.yaml b/examples/plugins/desktop-notify/plugin.yaml new file mode 100644 index 00000000..e98fb198 --- /dev/null +++ b/examples/plugins/desktop-notify/plugin.yaml @@ -0,0 +1,14 @@ +# A TaskYou plugin manifest. +# +# Copy this directory to ~/.config/task/plugins/desktop-notify/ and it is live — +# no code changes, no collision with other plugins that also handle these events. +name: desktop-notify +version: 0.1.0 +description: Native desktop notifications when a task finishes or needs you. + +# event name -> script path (relative to this plugin directory). +# Run `ty plugins list` to see what was discovered. +hooks: + task.done: notify.sh + task.blocked: notify.sh + task.failed: notify.sh diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index d0c18687..cdc70d31 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -26,46 +26,76 @@ const ( // Runner executes hooks for task events. type Runner struct { - hooksDir string - logger *log.Logger + hooksDir string + pluginsDir string + plugins []Plugin + logger *log.Logger } // New creates a new hook runner. // hooksDir is typically ~/.config/task/hooks/ func New(hooksDir string) *Runner { - return &Runner{ - hooksDir: hooksDir, - logger: log.NewWithOptions(os.Stderr, log.Options{Prefix: "hooks"}), - } + return newRunner(hooksDir, DefaultPluginsDir(), + log.NewWithOptions(os.Stderr, log.Options{Prefix: "hooks"})) } // NewSilent creates a hook runner without logging. func NewSilent(hooksDir string) *Runner { - return &Runner{ - hooksDir: hooksDir, - logger: log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel}), + return newRunner(hooksDir, DefaultPluginsDir(), + log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel})) +} + +// newRunner constructs a Runner and loads any plugins from pluginsDir. It is the +// shared constructor used by New/NewSilent and tests. +func newRunner(hooksDir, pluginsDir string, logger *log.Logger) *Runner { + r := &Runner{hooksDir: hooksDir, pluginsDir: pluginsDir, logger: logger} + plugins, warnings := LoadPlugins(pluginsDir) + for _, w := range warnings { + logger.Warn("plugin load", "detail", w) + } + for _, p := range plugins { + logger.Debug("plugin loaded", "name", p.Name, "hooks", len(p.Hooks)) } + r.plugins = plugins + return r } -// Run executes hooks for the given event. -// Hooks are scripts in hooksDir named after the event (e.g., task.blocked) +// Plugins returns the loaded plugins (read-only view for inspection/CLI). +func (r *Runner) Plugins() []Plugin { return r.plugins } + +// Run executes every hook registered for the given event: the legacy +// single-script hook in hooksDir (named after the event), plus the matching +// hook from each loaded plugin. All run concurrently in the background. func (r *Runner) Run(event string, task *db.Task, message string) { - if r.hooksDir == "" { - return - } + baseEnv := taskEnv(event, task, message) - // Look for hook script - hookPath := filepath.Join(r.hooksDir, event) - if _, err := os.Stat(hookPath); os.IsNotExist(err) { - return + // Legacy single-script hook: ~/.config/task/hooks/ + if r.hooksDir != "" { + hookPath := filepath.Join(r.hooksDir, event) + if fi, err := os.Stat(hookPath); err == nil && !fi.IsDir() { + r.runScript(event, "", hookPath, r.hooksDir, baseEnv) + } } - // Execute hook with environment variables - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + // Plugin hooks: fan out to every plugin that handles this event. + for _, p := range r.plugins { + script, ok := p.ScriptFor(event) + if !ok { + continue + } + env := make([]string, 0, len(baseEnv)+2) + env = append(env, baseEnv...) + env = append(env, + fmt.Sprintf("TASK_PLUGIN_NAME=%s", p.Name), + fmt.Sprintf("TASK_PLUGIN_DIR=%s", p.Dir), + ) + r.runScript(event, p.Name, script, p.Dir, env) + } +} - cmd := exec.CommandContext(ctx, hookPath) - cmd.Env = append(os.Environ(), +// taskEnv builds the environment shared by every hook for an event. +func taskEnv(event string, task *db.Task, message string) []string { + return append(os.Environ(), fmt.Sprintf("TASK_ID=%d", task.ID), fmt.Sprintf("TASK_TITLE=%s", task.Title), fmt.Sprintf("TASK_STATUS=%s", task.Status), @@ -75,14 +105,27 @@ func (r *Runner) Run(event string, task *db.Task, message string) { fmt.Sprintf("TASK_EVENT=%s", event), fmt.Sprintf("WORKTREE_PATH=%s", task.WorktreePath), ) +} - // Run in background, don't block +// runScript launches a hook script in the background. The 30s timeout is owned +// by the goroutine (not the caller) so the context isn't cancelled the instant +// Run returns — which would otherwise kill the hook before it could do anything. +// plugin is "" for the legacy hook, or the plugin name for a plugin hook. +func (r *Runner) runScript(event, plugin, scriptPath, workDir string, env []string) { go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, scriptPath) + cmd.Dir = workDir + cmd.Env = env + output, err := cmd.CombinedOutput() if err != nil { - r.logger.Error("Hook failed", "event", event, "error", err, "output", strings.TrimSpace(string(output))) + r.logger.Error("Hook failed", "event", event, "plugin", plugin, "error", err, + "output", strings.TrimSpace(string(output))) } else { - r.logger.Debug("Hook executed", "event", event) + r.logger.Debug("Hook executed", "event", event, "plugin", plugin) } }() } diff --git a/internal/hooks/plugins.go b/internal/hooks/plugins.go new file mode 100644 index 00000000..f4433c52 --- /dev/null +++ b/internal/hooks/plugins.go @@ -0,0 +1,135 @@ +package hooks + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "gopkg.in/yaml.v3" +) + +// ManifestName is the file every plugin directory must contain to be loaded. +const ManifestName = "plugin.yaml" + +// Plugin is a self-contained, droppable unit that reacts to task events. +// +// A plugin is a directory under the plugins dir (default +// ~/.config/task/plugins//) containing a plugin.yaml manifest and the +// scripts it references. Unlike the legacy single-script hooks dir — where one +// file per event means two integrations collide — any number of plugins may +// each declare a handler for the same event, and all of them run. +type Plugin struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + // Hooks maps a task event name (e.g. "task.done") to a script path, + // resolved relative to the plugin directory. + Hooks map[string]string `yaml:"hooks"` + + // Dir is the absolute path to the plugin directory (not from the manifest). + Dir string `yaml:"-"` +} + +// ScriptFor returns the absolute path to the script handling event, and whether +// the plugin handles that event at all. +func (p Plugin) ScriptFor(event string) (string, bool) { + rel, ok := p.Hooks[event] + if !ok || rel == "" { + return "", false + } + return filepath.Join(p.Dir, rel), true +} + +// DefaultPluginsDir returns the default plugins directory path. +func DefaultPluginsDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "task", "plugins") +} + +// LoadPlugins discovers and validates every plugin under pluginsDir. +// +// It is intentionally forgiving: a malformed or incomplete plugin is skipped +// (and reported via the returned warnings) rather than failing the whole load, +// so one bad community plugin can't break a user's task pipeline. A missing +// plugins dir is not an error — it just yields no plugins. +func LoadPlugins(pluginsDir string) (plugins []Plugin, warnings []string) { + if pluginsDir == "" { + return nil, nil + } + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, []string{fmt.Sprintf("read plugins dir %s: %v", pluginsDir, err)} + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(pluginsDir, entry.Name()) + p, warn := loadPlugin(dir) + if warn != "" { + warnings = append(warnings, warn) + } + if p != nil { + plugins = append(plugins, *p) + } + } + + // Deterministic order so fan-out and `plugins list` are stable. + sort.Slice(plugins, func(i, j int) bool { return plugins[i].Name < plugins[j].Name }) + return plugins, warnings +} + +// loadPlugin parses and validates a single plugin directory. It returns a nil +// plugin (with a warning) when the directory should be skipped. +func loadPlugin(dir string) (*Plugin, string) { + manifestPath := filepath.Join(dir, ManifestName) + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + // Not a plugin directory; silently ignore. + return nil, "" + } + return nil, fmt.Sprintf("plugin %s: read manifest: %v", filepath.Base(dir), err) + } + + var p Plugin + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Sprintf("plugin %s: invalid %s: %v", filepath.Base(dir), ManifestName, err) + } + p.Dir = dir + + if p.Name == "" { + return nil, fmt.Sprintf("plugin %s: manifest is missing a name", filepath.Base(dir)) + } + if len(p.Hooks) == 0 { + return nil, fmt.Sprintf("plugin %q: manifest declares no hooks; skipping", p.Name) + } + + // Drop hooks whose script is missing or not a regular file, keeping the + // rest of the plugin usable. + var dropped []string + for event, rel := range p.Hooks { + script := filepath.Join(dir, rel) + fi, statErr := os.Stat(script) + if statErr != nil || fi.IsDir() { + delete(p.Hooks, event) + dropped = append(dropped, event) + } + } + if len(p.Hooks) == 0 { + return nil, fmt.Sprintf("plugin %q: no usable hook scripts found; skipping", p.Name) + } + if len(dropped) > 0 { + sort.Strings(dropped) + return &p, fmt.Sprintf("plugin %q: ignored hooks with missing scripts: %v", p.Name, dropped) + } + return &p, "" +} diff --git a/internal/hooks/plugins_test.go b/internal/hooks/plugins_test.go new file mode 100644 index 00000000..1187a14d --- /dev/null +++ b/internal/hooks/plugins_test.go @@ -0,0 +1,168 @@ +package hooks + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/charmbracelet/log" + + "github.com/bborn/workflow/internal/db" +) + +// writePlugin creates a plugin dir with the given manifest and scripts. +// scripts maps a relative filename to its body; each is made executable. +func writePlugin(t *testing.T, root, name, manifest string, scripts map[string]string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if manifest != "" { + if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + } + for rel, body := range scripts { + p := filepath.Join(dir, rel) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestLoadPlugins_DiscoversValidPlugins(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "zeta", "name: zeta\nversion: 1.0.0\nhooks:\n task.done: done.sh\n", + map[string]string{"done.sh": "#!/bin/sh\n"}) + writePlugin(t, root, "alpha", "name: alpha\nhooks:\n task.blocked: b.sh\n task.done: d.sh\n", + map[string]string{"b.sh": "#!/bin/sh\n", "d.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + if len(plugins) != 2 { + t.Fatalf("got %d plugins, want 2", len(plugins)) + } + // Sorted by name for deterministic fan-out. + if plugins[0].Name != "alpha" || plugins[1].Name != "zeta" { + t.Errorf("plugins not sorted by name: %q, %q", plugins[0].Name, plugins[1].Name) + } + if got, ok := plugins[0].ScriptFor("task.done"); !ok || filepath.Base(got) != "d.sh" { + t.Errorf("alpha task.done script = %q (ok=%v)", got, ok) + } +} + +func TestLoadPlugins_MissingDirIsNotAnError(t *testing.T) { + plugins, warnings := LoadPlugins(filepath.Join(t.TempDir(), "does-not-exist")) + if plugins != nil || warnings != nil { + t.Errorf("got plugins=%v warnings=%v, want nil/nil", plugins, warnings) + } +} + +func TestLoadPlugins_SkipsInvalidAndWarns(t *testing.T) { + root := t.TempDir() + // Loose file (not a dir) is ignored entirely. + if err := os.WriteFile(filepath.Join(root, "loose.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // Dir without a manifest is silently ignored (no warning). + writePlugin(t, root, "notaplugin", "", map[string]string{"x.sh": "#!/bin/sh\n"}) + // Manifest missing a name -> skipped with warning. + writePlugin(t, root, "noname", "hooks:\n task.done: d.sh\n", map[string]string{"d.sh": "#!/bin/sh\n"}) + // Hook references a script that doesn't exist -> skipped with warning. + writePlugin(t, root, "broken", "name: broken\nhooks:\n task.done: missing.sh\n", nil) + // One good plugin survives. + writePlugin(t, root, "good", "name: good\nhooks:\n task.done: d.sh\n", map[string]string{"d.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 || plugins[0].Name != "good" { + t.Fatalf("got %d plugins (%v), want only 'good'", len(plugins), plugins) + } + if len(warnings) != 2 { + t.Errorf("got %d warnings, want 2 (noname, broken): %v", len(warnings), warnings) + } +} + +func TestLoadPlugins_DropsMissingHookButKeepsRest(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "partial", + "name: partial\nhooks:\n task.done: ok.sh\n task.blocked: gone.sh\n", + map[string]string{"ok.sh": "#!/bin/sh\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("got %d plugins, want 1", len(plugins)) + } + if _, ok := plugins[0].ScriptFor("task.done"); !ok { + t.Error("task.done hook should survive") + } + if _, ok := plugins[0].ScriptFor("task.blocked"); ok { + t.Error("task.blocked hook should have been dropped") + } + if len(warnings) != 1 { + t.Errorf("expected 1 warning about dropped hook, got %v", warnings) + } +} + +func TestRunner_FansOutToPluginsAndLegacyHook(t *testing.T) { + hooksDir := t.TempDir() + pluginsDir := t.TempDir() + out := t.TempDir() // each hook touches a marker file here + + // Legacy single-script hook. + legacy := "#!/bin/sh\necho legacy > \"" + filepath.Join(out, "legacy") + "\"\n" + if err := os.WriteFile(filepath.Join(hooksDir, "task.done"), []byte(legacy), 0o755); err != nil { + t.Fatal(err) + } + + // Two plugins both handling task.done — neither collides. + for _, name := range []string{"one", "two"} { + marker := filepath.Join(out, name) + body := "#!/bin/sh\necho \"$TASK_PLUGIN_NAME:$TASK_ID\" > \"" + marker + "\"\n" + writePlugin(t, pluginsDir, name, + "name: "+name+"\nhooks:\n task.done: run.sh\n", + map[string]string{"run.sh": body}) + } + + r := newRunner(hooksDir, pluginsDir, log.NewWithOptions(os.Stderr, log.Options{Level: log.FatalLevel})) + if len(r.Plugins()) != 2 { + t.Fatalf("loaded %d plugins, want 2", len(r.Plugins())) + } + + r.Run("task.done", &db.Task{ID: 42, Title: "t"}, "done") + + // Hooks run in background goroutines; wait for all three markers. + for _, f := range []string{"legacy", "one", "two"} { + waitForFile(t, filepath.Join(out, f)) + } + + // Plugin env was injected. + if got := readFile(t, filepath.Join(out, "one")); got != "one:42\n" { + t.Errorf("plugin 'one' marker = %q, want %q", got, "one:42\n") + } +} + +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("hook marker %q never appeared", filepath.Base(path)) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} From ed9a01a4e6daf676236f2ae0f5c84edf47b30eea Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 1 Jul 2026 07:38:31 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(hooks):=20plugin=20actions=20=E2=80=94?= =?UTF-8?q?=20user-invoked=20plugin=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the plugin manifest with `actions`: user-triggered commands (vs. hooks, which fire automatically on events). Each action has an id, optional label, and a command script in the plugin dir. Plugins may now be actions-only. - internal/hooks: Action type + validation (drop malformed/missing, keep rest); RunAction runs synchronously with task+plugin env, 60s timeout; FindAction. - cmd/task: `ty plugins run [task-id]`; `ty plugins list` now shows actions with their run command. - examples/plugins/desktop-notify: a `test` action. - docs/plugins.md: actions section. This is the shared runner for all three action surfaces; the CLI ships here, the detail-view picker and command-palette entries build on it next. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/task/plugins.go | 65 ++++++++++- docs/plugins.md | 27 ++++- examples/plugins/desktop-notify/plugin.yaml | 7 ++ .../plugins/desktop-notify/test-notify.sh | 20 ++++ internal/hooks/actions.go | 70 +++++++++++ internal/hooks/actions_test.go | 110 ++++++++++++++++++ internal/hooks/plugins.go | 69 +++++++++-- 7 files changed, 354 insertions(+), 14 deletions(-) create mode 100755 examples/plugins/desktop-notify/test-notify.sh create mode 100644 internal/hooks/actions.go create mode 100644 internal/hooks/actions_test.go diff --git a/cmd/task/plugins.go b/cmd/task/plugins.go index e964cd61..c1bd37c7 100644 --- a/cmd/task/plugins.go +++ b/cmd/task/plugins.go @@ -1,12 +1,15 @@ package main import ( + "context" "fmt" "os" "sort" + "strconv" "github.com/spf13/cobra" + "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/hooks" ) @@ -39,9 +42,66 @@ of plugins may handle the same event.`, }, }) + runCmd := &cobra.Command{ + Use: "run [task-id]", + Short: "Run a plugin action, optionally in the context of a task", + Args: cobra.RangeArgs(2, 3), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var taskID int64 + if len(args) == 3 { + id, err := strconv.ParseInt(args[2], 10, 64) + if err != nil { + return fmt.Errorf("invalid task id %q: %w", args[2], err) + } + taskID = id + } + return runPluginAction(cmd.Context(), args[0], args[1], taskID) + }, + } + pluginsCmd.AddCommand(runCmd) + return pluginsCmd } +func runPluginAction(ctx context.Context, pluginName, actionID string, taskID int64) error { + plugins, warnings := hooks.LoadPlugins(hooks.DefaultPluginsDir()) + for _, w := range warnings { + fmt.Fprintln(os.Stderr, "warning: "+w) + } + + plugin, action, err := hooks.FindAction(plugins, pluginName, actionID) + if err != nil { + return err + } + + // Load the task for context, if one was named. + var task *db.Task + if taskID != 0 { + database, dberr := openTaskDB(db.DefaultPath()) + if dberr != nil { + return dberr + } + defer database.Close() + task, err = database.GetTask(taskID) + if err != nil { + return fmt.Errorf("task #%d: %w", taskID, err) + } + } + + out, runErr := hooks.RunAction(ctx, plugin, action, task) + if len(out) > 0 { + fmt.Print(string(out)) + if out[len(out)-1] != '\n' { + fmt.Println() + } + } + if runErr != nil { + return fmt.Errorf("action %s/%s failed: %w", pluginName, actionID, runErr) + } + return nil +} + func listPlugins() { dir := hooks.DefaultPluginsDir() plugins, warnings := hooks.LoadPlugins(dir) @@ -72,7 +132,10 @@ func listPlugins() { } sort.Strings(events) for _, e := range events { - fmt.Printf(" %-22s → %s\n", e, p.Hooks[e]) + fmt.Printf(" hook %-18s → %s\n", e, p.Hooks[e]) + } + for _, a := range p.Actions { + fmt.Printf(" action %-18s → %s (ty plugins run %s %s)\n", a.DisplayLabel(), a.Command, p.Name, a.ID) } fmt.Println() } diff --git a/docs/plugins.md b/docs/plugins.md index 138b57d8..6a5e35ba 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -30,10 +30,14 @@ description: What it does. # optional hooks: # event -> script path (relative to this dir) task.done: on-done.sh task.blocked: on-blocked.sh +actions: # user-invoked commands (optional) + - id: sync + label: Sync to tracker + command: sync.sh ``` Script paths are resolved relative to the plugin directory. Scripts must be -executable (`chmod +x`). +executable (`chmod +x`). A plugin needs at least one usable hook **or** action. ## Events @@ -70,6 +74,27 @@ TASK_PLUGIN_DIR # absolute path to this plugin's directory The script's working directory is set to the plugin directory, so it can read its own bundled files (config, templates, helper binaries) with relative paths. +## Actions (user-invoked) + +Hooks fire automatically on events. **Actions** are commands the user triggers on +demand. Each action has an `id`, an optional `label`, and a `command` (a script +path relative to the plugin dir). + +Run one from the CLI, optionally against a task: + +```bash +ty plugins run my-plugin sync # no task context +ty plugins run my-plugin sync 42 # with task #42's env +``` + +An action script receives `TASK_PLUGIN_NAME` / `TASK_PLUGIN_DIR` always, and the +`TASK_*` variables (`TASK_ID`, `TASK_TITLE`, `TASK_STATUS`, `TASK_PROJECT`, +`TASK_TYPE`, `WORKTREE_PATH`) when a task id is supplied. Unlike hooks, actions +run synchronously (up to 60s) and their output is shown to the caller. + +> The in-TUI surfaces for actions — a detail-view picker and command-palette +> entries — build on this same runner and are being added next. + ## Behavior & guarantees - **Fan-out**: for each event, the legacy single-script hook *and* every plugin diff --git a/examples/plugins/desktop-notify/plugin.yaml b/examples/plugins/desktop-notify/plugin.yaml index e98fb198..ed5afe86 100644 --- a/examples/plugins/desktop-notify/plugin.yaml +++ b/examples/plugins/desktop-notify/plugin.yaml @@ -12,3 +12,10 @@ hooks: task.done: notify.sh task.blocked: notify.sh task.failed: notify.sh + +# user-invoked commands: `ty plugins run desktop-notify test`, +# or from the detail-view picker / command palette. +actions: + - id: test + label: Send a test notification + command: test-notify.sh diff --git a/examples/plugins/desktop-notify/test-notify.sh b/examples/plugins/desktop-notify/test-notify.sh new file mode 100755 index 00000000..18a52bd8 --- /dev/null +++ b/examples/plugins/desktop-notify/test-notify.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# desktop-notify action: fire a test notification on demand. +# +# Actions get TASK_PLUGIN_NAME / TASK_PLUGIN_DIR always, and the TASK_* vars +# (TASK_ID, TASK_TITLE, …) only when invoked with a task in context. +set -euo pipefail + +title="TaskYou: ${TASK_PLUGIN_NAME:-plugin} test" +if [[ -n "${TASK_ID:-}" ]]; then + body="Wired up for #${TASK_ID} ${TASK_TITLE:-}" +else + body="Notifications are wired up." +fi + +case "$(uname -s)" in + Darwin) osascript -e "display notification \"${body//\"/\\\"}\" with title \"${title}\"" || true ;; + Linux) command -v notify-send >/dev/null 2>&1 && notify-send "$title" "$body" || true ;; + *) echo "[$title] $body" ;; +esac +echo "sent test notification" diff --git a/internal/hooks/actions.go b/internal/hooks/actions.go new file mode 100644 index 00000000..e387fb1e --- /dev/null +++ b/internal/hooks/actions.go @@ -0,0 +1,70 @@ +package hooks + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/bborn/workflow/internal/db" +) + +// ActionTimeout bounds how long a user-invoked action may run. Actions are +// synchronous (their output is shown to the user), so this is more generous than +// the fire-and-forget hook timeout. +const ActionTimeout = 60 * time.Second + +// ActionEnv builds the environment for a plugin action. task may be nil when an +// action is invoked without a task in context; the TASK_* vars are then omitted. +func ActionEnv(p Plugin, task *db.Task) []string { + env := os.Environ() + if task != nil { + env = append(env, + fmt.Sprintf("TASK_ID=%d", task.ID), + fmt.Sprintf("TASK_TITLE=%s", task.Title), + fmt.Sprintf("TASK_STATUS=%s", task.Status), + fmt.Sprintf("TASK_PROJECT=%s", task.Project), + fmt.Sprintf("TASK_TYPE=%s", task.Type), + fmt.Sprintf("WORKTREE_PATH=%s", task.WorktreePath), + ) + } + env = append(env, + "TASK_PLUGIN_NAME="+p.Name, + "TASK_PLUGIN_DIR="+p.Dir, + ) + return env +} + +// RunAction runs a plugin action to completion and returns its combined output. +// It is synchronous by design: actions are user-triggered and their result is +// surfaced back to the user (CLI stdout, a TUI banner, etc.). +func RunAction(ctx context.Context, p Plugin, a Action, task *db.Task) ([]byte, error) { + script := filepath.Join(p.Dir, a.Command) + if !isExecutableFile(script) { + return nil, fmt.Errorf("action %q: script %s not found", a.ID, a.Command) + } + + ctx, cancel := context.WithTimeout(ctx, ActionTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, script) + cmd.Dir = p.Dir + cmd.Env = ActionEnv(p, task) + return cmd.CombinedOutput() +} + +// FindAction locates a plugin and action by name/id across the given plugins. +func FindAction(plugins []Plugin, pluginName, actionID string) (Plugin, Action, error) { + for _, p := range plugins { + if p.Name != pluginName { + continue + } + if a, ok := p.Action(actionID); ok { + return p, a, nil + } + return Plugin{}, Action{}, fmt.Errorf("plugin %q has no action %q", pluginName, actionID) + } + return Plugin{}, Action{}, fmt.Errorf("no plugin named %q", pluginName) +} diff --git a/internal/hooks/actions_test.go b/internal/hooks/actions_test.go new file mode 100644 index 00000000..33ad919a --- /dev/null +++ b/internal/hooks/actions_test.go @@ -0,0 +1,110 @@ +package hooks + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +func TestLoadPlugins_ActionsValidatedAndKept(t *testing.T) { + root := t.TempDir() + manifest := "name: acty\n" + + "actions:\n" + + " - id: greet\n" + + " label: Say hi\n" + + " command: greet.sh\n" + + " - id: broken\n" + // script missing -> dropped + " command: missing.sh\n" + + " - label: no-id\n" + // no id -> dropped + " command: greet.sh\n" + writePlugin(t, root, "acty", manifest, map[string]string{"greet.sh": "#!/bin/sh\necho hi\n"}) + + plugins, warnings := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("got %d plugins, want 1", len(plugins)) + } + if len(plugins[0].Actions) != 1 || plugins[0].Actions[0].ID != "greet" { + t.Fatalf("kept actions = %+v, want only 'greet'", plugins[0].Actions) + } + if plugins[0].Actions[0].DisplayLabel() != "Say hi" { + t.Errorf("DisplayLabel = %q", plugins[0].Actions[0].DisplayLabel()) + } + if len(warnings) != 1 { + t.Errorf("want 1 warning about dropped actions, got %v", warnings) + } +} + +func TestLoadPlugins_ActionsOnlyPluginIsValid(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "actiononly", + "name: actiononly\nactions:\n - id: go\n command: go.sh\n", + map[string]string{"go.sh": "#!/bin/sh\n"}) + + plugins, _ := LoadPlugins(root) + if len(plugins) != 1 { + t.Fatalf("actions-only plugin should load; got %d", len(plugins)) + } + if len(plugins[0].Hooks) != 0 { + t.Errorf("expected no hooks") + } +} + +func TestRunAction_InjectsTaskAndPluginEnv(t *testing.T) { + root := t.TempDir() + script := "#!/bin/sh\necho \"$TASK_PLUGIN_NAME|$TASK_ID|$TASK_TITLE\"\n" + dir := writePlugin(t, root, "p", "name: p\nactions:\n - id: a\n command: a.sh\n", + map[string]string{"a.sh": script}) + + p := Plugin{Name: "p", Dir: dir, Actions: []Action{{ID: "a", Command: "a.sh"}}} + task := &db.Task{ID: 7, Title: "hello"} + + out, err := RunAction(context.Background(), p, p.Actions[0], task) + if err != nil { + t.Fatalf("RunAction: %v (%s)", err, out) + } + if got := strings.TrimSpace(string(out)); got != "p|7|hello" { + t.Errorf("action output = %q, want %q", got, "p|7|hello") + } +} + +func TestRunAction_NilTaskOmitsTaskVars(t *testing.T) { + root := t.TempDir() + // set -u would fail on unset TASK_ID; use default-expansion to prove it's unset. + script := "#!/bin/sh\nset -u\necho \"plugin=$TASK_PLUGIN_NAME id=${TASK_ID:-none}\"\n" + dir := writePlugin(t, root, "p", "name: p\nactions:\n - id: a\n command: a.sh\n", + map[string]string{"a.sh": script}) + + p := Plugin{Name: "p", Dir: dir, Actions: []Action{{ID: "a", Command: "a.sh"}}} + out, err := RunAction(context.Background(), p, p.Actions[0], nil) + if err != nil { + t.Fatalf("RunAction: %v (%s)", err, out) + } + if got := strings.TrimSpace(string(out)); got != "plugin=p id=none" { + t.Errorf("action output = %q, want %q", got, "plugin=p id=none") + } +} + +func TestFindAction(t *testing.T) { + plugins := []Plugin{{Name: "p", Actions: []Action{{ID: "a"}}}} + + if _, a, err := FindAction(plugins, "p", "a"); err != nil || a.ID != "a" { + t.Errorf("FindAction(p,a) = %+v, %v", a, err) + } + if _, _, err := FindAction(plugins, "p", "nope"); err == nil { + t.Error("expected error for unknown action") + } + if _, _, err := FindAction(plugins, "nope", "a"); err == nil { + t.Error("expected error for unknown plugin") + } +} + +func TestRunAction_MissingScriptErrors(t *testing.T) { + p := Plugin{Name: "p", Dir: filepath.Join(t.TempDir(), "p")} + _, err := RunAction(context.Background(), p, Action{ID: "x", Command: "nope.sh"}, nil) + if err == nil { + t.Error("expected error for missing action script") + } +} diff --git a/internal/hooks/plugins.go b/internal/hooks/plugins.go index f4433c52..49abaea3 100644 --- a/internal/hooks/plugins.go +++ b/internal/hooks/plugins.go @@ -24,13 +24,31 @@ type Plugin struct { Version string `yaml:"version"` Description string `yaml:"description"` // Hooks maps a task event name (e.g. "task.done") to a script path, - // resolved relative to the plugin directory. + // resolved relative to the plugin directory. Hooks fire automatically. Hooks map[string]string `yaml:"hooks"` + // Actions are user-invoked commands (from `ty plugins run`, the detail-view + // picker, or the command palette), each backed by a script in the plugin dir. + Actions []Action `yaml:"actions"` // Dir is the absolute path to the plugin directory (not from the manifest). Dir string `yaml:"-"` } +// Action is a user-triggered plugin command. +type Action struct { + ID string `yaml:"id"` // stable identifier, unique within the plugin + Label string `yaml:"label"` // human-facing label; defaults to ID if empty + Command string `yaml:"command"` // script path, relative to the plugin dir +} + +// DisplayLabel returns the label, falling back to the ID. +func (a Action) DisplayLabel() string { + if a.Label != "" { + return a.Label + } + return a.ID +} + // ScriptFor returns the absolute path to the script handling event, and whether // the plugin handles that event at all. func (p Plugin) ScriptFor(event string) (string, bool) { @@ -41,6 +59,16 @@ func (p Plugin) ScriptFor(event string) (string, bool) { return filepath.Join(p.Dir, rel), true } +// Action returns the action with the given ID. +func (p Plugin) Action(id string) (Action, bool) { + for _, a := range p.Actions { + if a.ID == id { + return a, true + } + } + return Action{}, false +} + // DefaultPluginsDir returns the default plugins directory path. func DefaultPluginsDir() string { configDir, err := os.UserConfigDir() @@ -109,27 +137,44 @@ func loadPlugin(dir string) (*Plugin, string) { if p.Name == "" { return nil, fmt.Sprintf("plugin %s: manifest is missing a name", filepath.Base(dir)) } - if len(p.Hooks) == 0 { - return nil, fmt.Sprintf("plugin %q: manifest declares no hooks; skipping", p.Name) + if len(p.Hooks) == 0 && len(p.Actions) == 0 { + return nil, fmt.Sprintf("plugin %q: manifest declares no hooks or actions; skipping", p.Name) } - // Drop hooks whose script is missing or not a regular file, keeping the - // rest of the plugin usable. + // Drop hooks and actions whose script is missing or not a regular file, + // keeping the rest of the plugin usable. var dropped []string for event, rel := range p.Hooks { - script := filepath.Join(dir, rel) - fi, statErr := os.Stat(script) - if statErr != nil || fi.IsDir() { + if !isExecutableFile(filepath.Join(dir, rel)) { delete(p.Hooks, event) - dropped = append(dropped, event) + dropped = append(dropped, "hook:"+event) } } - if len(p.Hooks) == 0 { - return nil, fmt.Sprintf("plugin %q: no usable hook scripts found; skipping", p.Name) + kept := p.Actions[:0] + for _, a := range p.Actions { + switch { + case a.ID == "" || a.Command == "": + dropped = append(dropped, "action:") + case !isExecutableFile(filepath.Join(dir, a.Command)): + dropped = append(dropped, "action:"+a.ID) + default: + kept = append(kept, a) + } + } + p.Actions = kept + + if len(p.Hooks) == 0 && len(p.Actions) == 0 { + return nil, fmt.Sprintf("plugin %q: no usable hook or action scripts found; skipping", p.Name) } if len(dropped) > 0 { sort.Strings(dropped) - return &p, fmt.Sprintf("plugin %q: ignored hooks with missing scripts: %v", p.Name, dropped) + return &p, fmt.Sprintf("plugin %q: ignored entries with missing/invalid scripts: %v", p.Name, dropped) } return &p, "" } + +// isExecutableFile reports whether path exists and is a regular file. +func isExecutableFile(path string) bool { + fi, err := os.Stat(path) + return err == nil && !fi.IsDir() +} From a9d704c9dd1f0d30e76c1ecf517e86138c20ca9b Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 5 Jul 2026 07:54:36 -0500 Subject: [PATCH 3/5] feat(ui): plugin action picker in the task detail view Press A in the detail view to open a modal list of the current task's plugin actions; selecting one runs it (with the task's env) off the UI loop and shows the result in the notification banner. - internal/ui/action_picker.go: ActionPickerModel, a self-contained sub-model in the command-palette style (own View + result accessors), so it avoids the huh form-router path entirely. gatherPluginActions() flattens installed plugins. - internal/ui/app.go: ViewActionPicker + Actions key (A); routes the picker like the command palette; runs the action via runPluginActionCmd; actionFinishedMsg is exempted from the view routers (isSystemMsg) so its result reaches the banner. When no plugins have actions, shows a hint instead of an empty modal. Next surface: command-palette entries on the same runner. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/action_picker.go | 171 ++++++++++++++++++++++++++++++ internal/ui/action_picker_test.go | 79 ++++++++++++++ internal/ui/app.go | 118 +++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 internal/ui/action_picker.go create mode 100644 internal/ui/action_picker_test.go diff --git a/internal/ui/action_picker.go b/internal/ui/action_picker.go new file mode 100644 index 00000000..b475e9fe --- /dev/null +++ b/internal/ui/action_picker.go @@ -0,0 +1,171 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/hooks" +) + +// PluginActionItem pairs a plugin with one of its actions for display/execution. +type PluginActionItem struct { + Plugin hooks.Plugin + Action hooks.Action +} + +// ActionPickerModel is a modal list of plugin actions for the current task. +// It mirrors CommandPaletteModel: a self-contained sub-model with its own View, +// switched to via a dedicated View constant, so it never touches the huh +// form-router path. +type ActionPickerModel struct { + taskTitle string + items []PluginActionItem + selectedIndex int + width int + height int + + // Result + selected *PluginActionItem + cancelled bool +} + +// gatherPluginActions loads all installed plugins and flattens their actions +// into a display list. Warnings from malformed plugins are ignored here; they +// surface via `ty plugins list`. +func gatherPluginActions() []PluginActionItem { + plugins, _ := hooks.LoadPlugins(hooks.DefaultPluginsDir()) + var items []PluginActionItem + for _, p := range plugins { + for _, a := range p.Actions { + items = append(items, PluginActionItem{Plugin: p, Action: a}) + } + } + return items +} + +// NewActionPickerModel creates an action picker for the given items. +func NewActionPickerModel(taskTitle string, items []PluginActionItem, width, height int) *ActionPickerModel { + return &ActionPickerModel{ + taskTitle: taskTitle, + items: items, + width: width, + height: height, + } +} + +// Init implements tea.Model. +func (m *ActionPickerModel) Init() tea.Cmd { return nil } + +// Update handles key input. It returns the model and never a command; the parent +// reads Selected()/IsCancelled() after each update. +func (m *ActionPickerModel) Update(msg tea.Msg) (*ActionPickerModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "esc", "q": + m.cancelled = true + case "enter": + if len(m.items) > 0 { + sel := m.items[m.selectedIndex] + m.selected = &sel + } + case "up", "ctrl+p", "ctrl+k", "k": + if len(m.items) > 0 { + m.selectedIndex-- + if m.selectedIndex < 0 { + m.selectedIndex = len(m.items) - 1 + } + } + case "down", "ctrl+n", "ctrl+j", "j": + if len(m.items) > 0 { + m.selectedIndex++ + if m.selectedIndex >= len(m.items) { + m.selectedIndex = 0 + } + } + } + return m, nil +} + +// View renders the modal. +func (m *ActionPickerModel) View() string { + modalWidth := min(72, m.width-4) + + header := lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + MarginBottom(1). + Render("Plugin Actions") + + var body strings.Builder + if len(m.items) == 0 { + body.WriteString(lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Render("No plugin actions installed. See docs/plugins.md.")) + } else { + for i, it := range m.items { + body.WriteString(m.renderItem(it, i == m.selectedIndex, modalWidth-6)) + if i < len(m.items)-1 { + body.WriteString("\n") + } + } + } + + help := lipgloss.NewStyle(). + Foreground(ColorMuted). + MarginTop(1). + Render("enter: run esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate") + + content := lipgloss.JoinVertical(lipgloss.Left, header, body.String(), help) + + modalBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPrimary). + Padding(1, 2). + Width(modalWidth) + + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Render(modalBox.Render(content)) +} + +func (m *ActionPickerModel) renderItem(it PluginActionItem, selected bool, width int) string { + var line strings.Builder + if selected { + line.WriteString(lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true).Render("> ")) + } else { + line.WriteString(" ") + } + + label := it.Action.DisplayLabel() + labelStyle := lipgloss.NewStyle() + if selected { + labelStyle = labelStyle.Bold(true).Foreground(ColorPrimary) + } + line.WriteString(labelStyle.Render(label)) + + // Dim "· plugin-name" suffix so it's clear which plugin owns the action. + suffix := " · " + it.Plugin.Name + line.WriteString(lipgloss.NewStyle().Foreground(ColorMuted).Render(suffix)) + + return line.String() +} + +// Selected returns the chosen item, or nil if none was chosen yet. +func (m *ActionPickerModel) Selected() *PluginActionItem { return m.selected } + +// IsCancelled reports whether the user dismissed the picker. +func (m *ActionPickerModel) IsCancelled() bool { return m.cancelled } + +// SetSize updates dimensions. +func (m *ActionPickerModel) SetSize(width, height int) { + m.width = width + m.height = height +} diff --git a/internal/ui/action_picker_test.go b/internal/ui/action_picker_test.go new file mode 100644 index 00000000..d2033ad2 --- /dev/null +++ b/internal/ui/action_picker_test.go @@ -0,0 +1,79 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/hooks" +) + +func testItems() []PluginActionItem { + return []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "p1"}, Action: hooks.Action{ID: "a", Label: "Alpha"}}, + {Plugin: hooks.Plugin{Name: "p2"}, Action: hooks.Action{ID: "b", Label: "Beta"}}, + } +} + +func keyFor(s string) tea.KeyMsg { + switch s { + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func TestActionPicker_SelectDefaultIsFirst(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("enter")) + sel := m.Selected() + if sel == nil || sel.Action.ID != "a" { + t.Fatalf("selected = %v, want action a", sel) + } +} + +func TestActionPicker_NavigateThenSelect(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("down")) + m, _ = m.Update(keyFor("enter")) + if sel := m.Selected(); sel == nil || sel.Action.ID != "b" { + t.Fatalf("selected = %v, want action b", sel) + } +} + +func TestActionPicker_WrapAround(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("up")) // wraps from 0 to last + m, _ = m.Update(keyFor("enter")) + if sel := m.Selected(); sel == nil || sel.Action.ID != "b" { + t.Fatalf("selected = %v, want last item after wrap", sel) + } +} + +func TestActionPicker_Cancel(t *testing.T) { + m := NewActionPickerModel("task", testItems(), 80, 24) + m, _ = m.Update(keyFor("esc")) + if !m.IsCancelled() { + t.Error("expected cancelled after esc") + } + if m.Selected() != nil { + t.Error("expected no selection after cancel") + } +} + +func TestActionPicker_EmptyListEnterIsNoop(t *testing.T) { + m := NewActionPickerModel("task", nil, 80, 24) + m, _ = m.Update(keyFor("enter")) + if m.Selected() != nil { + t.Error("enter on empty list should not select") + } + // View must not panic on an empty list. + _ = m.View() +} diff --git a/internal/ui/app.go b/internal/ui/app.go index f9ab1c84..81df7256 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -25,6 +25,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" + "github.com/bborn/workflow/internal/hooks" "github.com/bborn/workflow/internal/spotlight" "github.com/bborn/workflow/internal/tasksummary" ) @@ -52,6 +53,7 @@ const ( ViewWelcome // first-run fork: set up a project vs start a task ViewFolderPicker // fuzzy folder picker for "set up a project" ViewRoutines // global routines fleet-health view + ViewActionPicker // modal list of plugin actions for the current task ) // KeyMap defines key bindings. @@ -83,6 +85,7 @@ type KeyMap struct { OpenWorktree key.Binding ToggleShellPane key.Binding JumpToNotification key.Binding + Actions key.Binding // Column focus shortcuts FocusBacklog key.Binding FocusInProgress key.Binding @@ -233,6 +236,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("g"), key.WithHelp("g", "go to notification"), ), + Actions: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "plugin actions"), + ), FocusBacklog: key.NewBinding( key.WithKeys("B"), key.WithHelp("B", "backlog"), @@ -488,6 +495,10 @@ type AppModel struct { commandPaletteReturnView View commandPaletteReturnTaskID int64 + // Plugin action picker state (opened from the detail view) + actionPickerView *ActionPickerModel + actionPickerTask *db.Task + // AI command service for natural language command interpretation aiCommandService *ai.CommandService @@ -704,6 +715,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case tickMsg, focusTickMsg, dbChangeMsg, taskEventMsg, tasksLoadedMsg, prRefreshTickMsg, liveSpinnerTickMsg: isSystemMsg = true + case actionFinishedMsg: + // A plugin action completed off the UI loop; its result must reach the + // main switch to update the notification banner, not be routed to a view. + isSystemMsg = true } if !isSystemMsg { @@ -772,6 +787,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentView == ViewCommandPalette && m.commandPaletteView != nil { return m.updateCommandPalette(msg) } + if m.currentView == ViewActionPicker && m.actionPickerView != nil { + return m.updateActionPicker(msg) + } // Handle detail view feedback mode (needs all message types for text input) if m.currentView == ViewDetail && m.detailView != nil && m.detailView.InFeedbackMode() { return m.updateDetail(msg) @@ -1209,6 +1227,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.handleAICommand(msg.cmd)) } + case actionFinishedMsg: + if msg.err != nil { + m.notification = fmt.Sprintf("%s %s failed: %s", IconBlocked(), msg.label, msg.err.Error()) + } else { + summary := actionResultSummary(msg.output) + if summary == "" { + summary = "done" + } + m.notification = fmt.Sprintf("%s %s: %s", IconDone(), msg.label, summary) + } + m.notifyUntil = time.Now().Add(6 * time.Second) + case taskPermissionModeCycledMsg: cmds = append(cmds, m.loadTasks()) // Refresh the detail view panes since the executor window was recreated @@ -1482,6 +1512,9 @@ func (m *AppModel) applyWindowSize(width, height int) { if m.commandPaletteView != nil { m.commandPaletteView.SetSize(width, height) } + if m.actionPickerView != nil { + m.actionPickerView.SetSize(width, height) + } if m.newTaskForm != nil { m.newTaskForm.SetSize(width, height) } @@ -1570,6 +1603,10 @@ func (m *AppModel) View() string { if m.commandPaletteView != nil { return m.commandPaletteView.View() } + case ViewActionPicker: + if m.actionPickerView != nil { + return m.actionPickerView.View() + } } return "" @@ -2556,6 +2593,9 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.ToggleShellPane() return m, nil } + if key.Matches(keyMsg, m.keys.Actions) && m.selectedTask != nil { + return m.openActionPicker() + } // Arrow key navigation to prev/next task in the same column // j/k keys are passed through to the viewport for scrolling @@ -3857,6 +3897,84 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// actionFinishedMsg carries the result of a plugin action that ran off the UI +// loop. It is exempted from the view routers (see isSystemMsg) so it always +// reaches the main switch to update the notification banner. +type actionFinishedMsg struct { + label string + output string + err error +} + +// openActionPicker gathers plugin actions and opens the modal picker, or shows a +// notification when no actions are installed. +func (m *AppModel) openActionPicker() (tea.Model, tea.Cmd) { + items := gatherPluginActions() + if len(items) == 0 { + m.notification = fmt.Sprintf("%s No plugin actions installed (see docs/plugins.md)", IconBlocked()) + m.notifyUntil = time.Now().Add(4 * time.Second) + return m, nil + } + title := "" + if m.selectedTask != nil { + title = m.selectedTask.Title + } + m.actionPickerTask = m.selectedTask + m.actionPickerView = NewActionPickerModel(title, items, m.width, m.height) + m.currentView = ViewActionPicker + return m, m.actionPickerView.Init() +} + +// updateActionPicker drives the picker sub-model and acts on its result. +func (m *AppModel) updateActionPicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.actionPickerView == nil { + return m, nil + } + var cmd tea.Cmd + m.actionPickerView, cmd = m.actionPickerView.Update(msg) + + if m.actionPickerView.IsCancelled() { + m.actionPickerView = nil + m.currentView = ViewDetail + return m, nil + } + if sel := m.actionPickerView.Selected(); sel != nil { + item := *sel + task := m.actionPickerTask + m.actionPickerView = nil + m.actionPickerTask = nil + m.currentView = ViewDetail + m.notification = fmt.Sprintf("%s Running %s…", IconInProgress(), item.Action.DisplayLabel()) + m.notifyUntil = time.Now().Add(hooks.ActionTimeout) + return m, runPluginActionCmd(item, task) + } + return m, cmd +} + +// runPluginActionCmd runs a plugin action off the UI loop and reports the result. +func runPluginActionCmd(item PluginActionItem, task *db.Task) tea.Cmd { + return func() tea.Msg { + out, err := hooks.RunAction(context.Background(), item.Plugin, item.Action, task) + return actionFinishedMsg{label: item.Action.DisplayLabel(), output: string(out), err: err} + } +} + +// actionResultSummary reduces an action's output to a single, length-capped +// line for the notification banner. +func actionResultSummary(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if len(line) > 80 { + return line[:79] + "…" + } + return line + } + return "" +} + // projectInferredMsg carries the result of an async claude -p inference for the // project currently being offered in the detect-confirm card. type projectInferredMsg struct { From e8d9848ca34da6c95e3f6c7ef4ee96117a81a207 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sun, 5 Jul 2026 08:01:22 -0500 Subject: [PATCH 4/5] feat(ui): plugin actions in the command palette via ">" prefix Type a leading ">" in the Cmd+P palette to switch from task search to plugin action search; Enter runs the selected action with the current task's context and reports the result in the notification banner. Task-switching is unchanged when the query has no ">" prefix (strictly additive). - internal/ui/command_palette.go: action mode (filter/filterActions/activeLen), SelectedAction accessor, action-list rendering, "> for plugin actions" hint. - internal/ui/app.go: updateCommandPalette runs the chosen action via the shared runPluginActionCmd. - docs/plugins.md: document both TUI action surfaces. Completes the three action surfaces (CLI, detail-view picker, command palette). Next: the pane-tabs refactor (separate PR). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plugins.md | 13 ++- internal/ui/app.go | 23 +++++ internal/ui/command_palette.go | 127 +++++++++++++++++++++++++--- internal/ui/command_palette_test.go | 50 +++++++++++ 4 files changed, 195 insertions(+), 18 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 6a5e35ba..394a0cd9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -89,11 +89,16 @@ ty plugins run my-plugin sync 42 # with task #42's env An action script receives `TASK_PLUGIN_NAME` / `TASK_PLUGIN_DIR` always, and the `TASK_*` variables (`TASK_ID`, `TASK_TITLE`, `TASK_STATUS`, `TASK_PROJECT`, -`TASK_TYPE`, `WORKTREE_PATH`) when a task id is supplied. Unlike hooks, actions -run synchronously (up to 60s) and their output is shown to the caller. +`TASK_TYPE`, `WORKTREE_PATH`) when a task is in context. Unlike hooks, actions +run synchronously (up to 60s) and their output is shown to the caller (the CLI +prints it; the TUI shows the first line in the notification banner). -> The in-TUI surfaces for actions — a detail-view picker and command-palette -> entries — build on this same runner and are being added next. +In the TUI, actions are reachable two ways, both running the same command: + +- **Detail view:** press `A` on a task to open a picker of that task's plugin + actions; the chosen one runs with the task's env. +- **Command palette:** open it (`p` / `Ctrl+P`) and type a leading `>` to switch + from task search to action search. ## Behavior & guarantees diff --git a/internal/ui/app.go b/internal/ui/app.go index 81df7256..ad0d353b 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3853,6 +3853,29 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Check if user chose a plugin action (action mode, ">" prefix) + if act := m.commandPaletteView.SelectedAction(); act != nil { + item := *act + task := m.selectedTask // task context we were on, if any + returnView := m.commandPaletteReturnView + returnTaskID := m.commandPaletteReturnTaskID + if returnView == ViewDashboard && m.detailView != nil && m.selectedTask != nil { + returnView = ViewDetail + returnTaskID = m.selectedTask.ID + } + m.commandPaletteView = nil + m.commandPaletteReturnView = ViewDashboard + m.commandPaletteReturnTaskID = 0 + m.currentView = returnView + m.notification = fmt.Sprintf("%s Running %s…", IconInProgress(), item.Action.DisplayLabel()) + m.notifyUntil = time.Now().Add(hooks.ActionTimeout) + cmds := []tea.Cmd{runPluginActionCmd(item, task)} + if returnView == ViewDetail && m.detailView == nil && returnTaskID != 0 { + cmds = append(cmds, m.loadTask(returnTaskID)) + } + return m, tea.Batch(cmds...) + } + // Check if user selected a task if selectedTask := m.commandPaletteView.SelectedTask(); selectedTask != nil { taskID := selectedTask.ID diff --git a/internal/ui/command_palette.go b/internal/ui/command_palette.go index cc308804..2993a99c 100644 --- a/internal/ui/command_palette.go +++ b/internal/ui/command_palette.go @@ -34,8 +34,15 @@ type CommandPaletteModel struct { height int maxVisible int + // Action mode: entered by typing a leading ">". Filters plugin actions + // instead of tasks. Task-switching behavior is unchanged when not in it. + allActions []PluginActionItem + filteredActions []PluginActionItem + actionMode bool + // Result selectedTask *db.Task + selectedAction *PluginActionItem cancelled bool aiCommandRequest bool // True when user pressed Enter with text that should go to AI rawInput string // The raw input text for AI command processing @@ -44,7 +51,7 @@ type CommandPaletteModel struct { // NewCommandPaletteModel creates a new command palette model. func NewCommandPaletteModel(database *db.DB, tasks []*db.Task, width, height int) *CommandPaletteModel { searchInput := textinput.New() - searchInput.Placeholder = "Search tasks or type a command..." + searchInput.Placeholder = "Search tasks — or > for plugin actions" searchInput.Focus() searchInput.CharLimit = 200 searchInput.Width = min(70, width-10) @@ -56,15 +63,53 @@ func NewCommandPaletteModel(database *db.DB, tasks []*db.Task, width, height int db: database, allTasks: tasks, projects: projects, + allActions: gatherPluginActions(), searchInput: searchInput, width: width, height: height, maxVisible: 10, } - m.filterTasks() + m.filter() return m } +// filter dispatches to task or action filtering based on the ">" prefix. +func (m *CommandPaletteModel) filter() { + q := strings.TrimSpace(m.searchInput.Value()) + if strings.HasPrefix(q, ">") { + m.actionMode = true + m.filterActions(strings.TrimSpace(strings.TrimPrefix(q, ">"))) + return + } + m.actionMode = false + m.filterTasks() +} + +// filterActions selects plugin actions matching query (by label, plugin, or id). +func (m *CommandPaletteModel) filterActions(query string) { + ql := strings.ToLower(query) + m.filteredActions = m.filteredActions[:0] + for _, it := range m.allActions { + if ql == "" || + strings.Contains(strings.ToLower(it.Action.DisplayLabel()), ql) || + strings.Contains(strings.ToLower(it.Plugin.Name), ql) || + strings.Contains(strings.ToLower(it.Action.ID), ql) { + m.filteredActions = append(m.filteredActions, it) + } + } + if m.selectedIndex >= len(m.filteredActions) { + m.selectedIndex = max(0, len(m.filteredActions)-1) + } +} + +// activeLen returns the length of the list the user is currently navigating. +func (m *CommandPaletteModel) activeLen() int { + if m.actionMode { + return len(m.filteredActions) + } + return len(m.filteredTasks) +} + // Init initializes the command palette. func (m *CommandPaletteModel) Init() tea.Cmd { return textinput.Blink @@ -79,6 +124,13 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd m.cancelled = true return m, nil case "enter": + if m.actionMode { + if len(m.filteredActions) > 0 && m.selectedIndex < len(m.filteredActions) { + sel := m.filteredActions[m.selectedIndex] + m.selectedAction = &sel + } + return m, nil + } query := strings.TrimSpace(m.searchInput.Value()) if len(m.filteredTasks) > 0 && m.selectedIndex < len(m.filteredTasks) { // If there are matching tasks and we have a selection, select that task @@ -92,13 +144,13 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd case "up", "ctrl+p", "ctrl+k": if m.selectedIndex > 0 { m.selectedIndex-- - } else if len(m.filteredTasks) > 0 { + } else if m.activeLen() > 0 { // Wrap to bottom - m.selectedIndex = len(m.filteredTasks) - 1 + m.selectedIndex = m.activeLen() - 1 } return m, nil case "down", "ctrl+n", "ctrl+j": - if m.selectedIndex < len(m.filteredTasks)-1 { + if m.selectedIndex < m.activeLen()-1 { m.selectedIndex++ } else { // Wrap to top @@ -113,8 +165,8 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd return m, nil case "pgdown": m.selectedIndex += m.maxVisible - if m.selectedIndex >= len(m.filteredTasks) { - m.selectedIndex = len(m.filteredTasks) - 1 + if m.selectedIndex >= m.activeLen() { + m.selectedIndex = m.activeLen() - 1 } if m.selectedIndex < 0 { m.selectedIndex = 0 @@ -125,7 +177,7 @@ func (m *CommandPaletteModel) Update(msg tea.Msg) (*CommandPaletteModel, tea.Cmd // Update search input var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) - m.filterTasks() + m.filter() return m, cmd } @@ -595,9 +647,11 @@ func (m *CommandPaletteModel) View() string { modalWidth := min(80, m.width-4) query := strings.TrimSpace(m.searchInput.Value()) - // Header - changes based on whether we have matching tasks + // Header - changes based on mode / whether we have matching tasks headerText := "Go to Task" - if len(m.filteredTasks) == 0 && query != "" { + if m.actionMode { + headerText = "Run Plugin Action" + } else if len(m.filteredTasks) == 0 && query != "" { headerText = "AI Command" } header := lipgloss.NewStyle(). @@ -614,9 +668,11 @@ func (m *CommandPaletteModel) View() string { Width(modalWidth - 6) searchBox := inputStyle.Render(m.searchInput.View()) - // Task list + // Task list (or plugin-action list in action mode) var taskList strings.Builder - if len(m.filteredTasks) == 0 { + if m.actionMode { + m.renderActionList(&taskList, modalWidth-6) + } else if len(m.filteredTasks) == 0 { emptyStyle := lipgloss.NewStyle(). Foreground(ColorMuted). Italic(true). @@ -684,9 +740,12 @@ func (m *CommandPaletteModel) View() string { Foreground(ColorMuted). MarginTop(1) var helpText string - if len(m.filteredTasks) == 0 && query != "" { + switch { + case m.actionMode: + helpText = "Enter: run Esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate" + case len(m.filteredTasks) == 0 && query != "": helpText = "Enter: run command Esc: cancel" - } else { + default: helpText = "Enter: select Esc: cancel " + IconArrowUp() + "/" + IconArrowDown() + ": navigate" } help := helpStyle.Render(helpText) @@ -717,6 +776,41 @@ func (m *CommandPaletteModel) View() string { Render(modalContent) } +// renderActionList renders the plugin-action list (action mode). +func (m *CommandPaletteModel) renderActionList(b *strings.Builder, width int) { + if len(m.filteredActions) == 0 { + b.WriteString(lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Padding(1, 0). + Render("No matching plugin actions. See docs/plugins.md.")) + return + } + for i, it := range m.filteredActions { + b.WriteString(m.renderActionRow(it, i == m.selectedIndex, width)) + if i < len(m.filteredActions)-1 { + b.WriteString("\n") + } + } +} + +// renderActionRow renders a single "label · plugin" action row. +func (m *CommandPaletteModel) renderActionRow(it PluginActionItem, selected bool, width int) string { + var line strings.Builder + if selected { + line.WriteString(lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true).Render("> ")) + } else { + line.WriteString(" ") + } + labelStyle := lipgloss.NewStyle() + if selected { + labelStyle = labelStyle.Bold(true).Foreground(ColorPrimary) + } + line.WriteString(labelStyle.Render(it.Action.DisplayLabel())) + line.WriteString(lipgloss.NewStyle().Foreground(ColorMuted).Render(" · " + it.Plugin.Name)) + return line.String() +} + // renderTaskItem renders a single task in the list. func (m *CommandPaletteModel) renderTaskItem(task *db.Task, isSelected bool, width int) string { // Status icon @@ -781,6 +875,11 @@ func (m *CommandPaletteModel) SelectedTask() *db.Task { return m.selectedTask } +// SelectedAction returns the chosen plugin action (action mode), or nil. +func (m *CommandPaletteModel) SelectedAction() *PluginActionItem { + return m.selectedAction +} + // IsCancelled returns true if the user cancelled the palette. func (m *CommandPaletteModel) IsCancelled() bool { return m.cancelled diff --git a/internal/ui/command_palette_test.go b/internal/ui/command_palette_test.go index c3042e52..c0b45b9d 100644 --- a/internal/ui/command_palette_test.go +++ b/internal/ui/command_palette_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/hooks" ) func TestFuzzyMatch(t *testing.T) { @@ -156,6 +157,55 @@ func TestCommandPaletteFiltering(t *testing.T) { } } +func TestCommandPalette_ActionModePrefixAndFilter(t *testing.T) { + actions := []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "notify"}, Action: hooks.Action{ID: "test", Label: "Send test"}}, + {Plugin: hooks.Plugin{Name: "sync"}, Action: hooks.Action{ID: "push", Label: "Push to tracker"}}, + } + + cases := []struct { + name string + query string + wantAction bool + wantCount int + }{ + {"no prefix stays task mode", "notify", false, 0}, + {"bare prefix lists all actions", ">", true, 2}, + {"prefix filters by label", ">push", true, 1}, + {"prefix filters by plugin", ">notify", true, 1}, + {"prefix no match", ">zzz", true, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := &CommandPaletteModel{allActions: actions} + m.searchInput.SetValue(tc.query) + m.filter() + if m.actionMode != tc.wantAction { + t.Fatalf("actionMode = %v, want %v", m.actionMode, tc.wantAction) + } + if tc.wantAction && len(m.filteredActions) != tc.wantCount { + t.Errorf("filteredActions = %d, want %d", len(m.filteredActions), tc.wantCount) + } + }) + } +} + +func TestCommandPalette_ActionModeEnterSelects(t *testing.T) { + m := &CommandPaletteModel{allActions: []PluginActionItem{ + {Plugin: hooks.Plugin{Name: "notify"}, Action: hooks.Action{ID: "test", Label: "Send test"}}, + }} + m.searchInput.SetValue(">") + m.filter() + m, _ = m.Update(keyFor("enter")) + if m.SelectedAction() == nil || m.SelectedAction().Action.ID != "test" { + t.Fatalf("SelectedAction = %v, want action test", m.SelectedAction()) + } + // Task selection must remain untouched in action mode. + if m.SelectedTask() != nil { + t.Error("SelectedTask should be nil in action mode") + } +} + func TestMatchesQuery(t *testing.T) { task := &db.Task{ ID: 123, From 6ee82013c39ee4d3699b5827d93432a6db2aa732 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 7 Jul 2026 21:02:47 -0500 Subject: [PATCH 5/5] docs(plugins): canonical example plugins + idea gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more copy-pasteable example plugins, plus a brainstorm of what to build: - examples/plugins/slack: reactive hooks posting task done/blocked/failed to a Slack incoming webhook; shows the "config.env bundled in the plugin dir" pattern and fails quietly when unconfigured. - examples/plugins/worktree: action-only plugin (diff, test) that operates on the task's WORKTREE_PATH — the on-demand action surface with real utility. - docs/plugin-ideas.md: a grouped gallery of ~15 plugin ideas (notifications, analytics, worktree/quality, integrations, ambient) and guidance on when a plugin should live in examples/ vs. graduate to its own repo. - README + docs/plugins.md: link the examples and the gallery. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++ README.md | 4 +- docs/plugin-ideas.md | 62 +++++++++++++++++++++++ docs/plugins.md | 13 +++-- examples/plugins/slack/config.example.env | 3 ++ examples/plugins/slack/notify.sh | 41 +++++++++++++++ examples/plugins/slack/plugin.yaml | 14 +++++ examples/plugins/worktree/diff.sh | 24 +++++++++ examples/plugins/worktree/plugin.yaml | 14 +++++ examples/plugins/worktree/test.sh | 39 ++++++++++++++ 10 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 docs/plugin-ideas.md create mode 100644 examples/plugins/slack/config.example.env create mode 100755 examples/plugins/slack/notify.sh create mode 100644 examples/plugins/slack/plugin.yaml create mode 100755 examples/plugins/worktree/diff.sh create mode 100644 examples/plugins/worktree/plugin.yaml create mode 100755 examples/plugins/worktree/test.sh diff --git a/.gitignore b/.gitignore index dc4314e3..4351fabf 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ ty-chrome.zip .pr-body.md .pr-comment.md .qacfg/ + +# Plugin user config (copied from *.example.env) +examples/plugins/**/config.env diff --git a/README.md b/README.md index b847261e..db639f5c 100644 --- a/README.md +++ b/README.md @@ -644,7 +644,9 @@ ty plugins list ``` See [docs/plugins.md](docs/plugins.md) for the manifest format and the authoring -guide. +guide, [`examples/plugins/`](examples/plugins/) for ready-to-copy plugins +(desktop-notify, slack, worktree), and [docs/plugin-ideas.md](docs/plugin-ideas.md) +for a gallery of things to build. ## Configuration diff --git a/docs/plugin-ideas.md b/docs/plugin-ideas.md new file mode 100644 index 00000000..caa138a3 --- /dev/null +++ b/docs/plugin-ideas.md @@ -0,0 +1,62 @@ +# Plugin ideas + +A menu of things TaskYou plugins can do, to seed community (and your own) +plugins. Everything here is buildable on today's contract — [hooks](plugins.md) +(fire on task events) and [actions](plugins.md#actions-user-invoked) (run on +demand with the task's env). A plugin is just a directory with executables; it +can be any language and can bundle its own config/binaries. + +✅ = shipped as an example in [`examples/plugins/`](../examples/plugins/). + +## Notifications & awareness (hooks) + +- ✅ **desktop-notify** — native OS notification on done/blocked/failed. +- ✅ **slack** — post task updates to a Slack channel (incoming webhook). +- **sound** — play a chime on `task.done`, a different one on `task.failed` + (`afplay`/`paplay`/terminal bell). ~10 lines. +- **say / TTS** — `say "task 42 is done"` (macOS) or `espeak`. +- **phone push** — ntfy.sh / Pushover / Telegram bot for away-from-desk pings. +- **auth-alert** — loud, unmissable alert on `task.auth_required` (the executor + needs re-login and everything is stalled until you notice). + +## Logging & analytics (hooks) + +- **webhook** — POST the task-event JSON to a configured URL. The universal + primitive; every integration above is a specialization of this. +- **timetrack** — append one JSONL row per event; derive cycle time, throughput, + blocked-rate over time. A personal productivity ledger. +- **status-file** — maintain a tiny JSON of live counts for a tmux statusline or + menubar widget. + +## Worktree & quality (actions) + +- ✅ **worktree** — show the task's diff; run its tests. +- **lint** — run the project linter in the worktree, surface pass/fail. +- **format** — run the formatter and report what changed. +- **branch-copy** — copy the task's branch name / PR URL to the clipboard. + +## Integrations (hooks + actions) + +- **linear / jira / github-issue** — on `task.done`, comment or transition the + linked issue; an action to open it. (Needs an id convention + API token in the + plugin's `config.env`.) +- **calendar / timeblock** — log completed tasks to a calendar. +- **journal** — append a daily standup line ("finished #42: …") to a notes file. + +## Ambient / fun + +- **confetti** — a celebratory splash (or `cowsay`) on `task.done`. +- **now-playing** — pause music while an executor is actively running. + +## Where should plugins live? (in-repo vs. own repo) + +- **In-repo `examples/plugins/`** — small, canonical, copy-paste starting points + that ship with TaskYou and are covered by the loader's tests. The three above + live here. Best for anything short enough to read in one sitting. +- **Its own repo** — when a plugin grows an independent release cadence, ships a + compiled binary or heavier dependencies, or has a real surface of its own + (config, docs, versioning). Install by dropping (or symlinking) its directory + into `~/.config/task/plugins/`. This is where a bigger integration — say a + token-compressing proxy or a full issue-tracker sync — belongs. + +Rule of thumb: **start in `examples/`; graduate to a repo when it earns one.** diff --git a/docs/plugins.md b/docs/plugins.md index 394a0cd9..fafb676a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -118,12 +118,19 @@ ty plugins list # what's installed and which events each handles ty plugins dir # the plugins directory path ``` -## Example +## Examples -A complete, copy-pasteable plugin lives at -[`examples/plugins/desktop-notify/`](../examples/plugins/desktop-notify/): +Complete, copy-pasteable plugins live in [`examples/plugins/`](../examples/plugins/): + +| Plugin | Kind | What it shows | +|---|---|---| +| [`desktop-notify`](../examples/plugins/desktop-notify/) | hooks + action | native notifications; a `test` action | +| [`slack`](../examples/plugins/slack/) | hooks | webhook integration; bundled `config.env` | +| [`worktree`](../examples/plugins/worktree/) | actions | task-scoped `diff` / `test` using `WORKTREE_PATH` | ```bash cp -R examples/plugins/desktop-notify ~/.config/task/plugins/ ty plugins list ``` + +For more, see the [plugin idea gallery](plugin-ideas.md). diff --git a/examples/plugins/slack/config.example.env b/examples/plugins/slack/config.example.env new file mode 100644 index 00000000..e691154a --- /dev/null +++ b/examples/plugins/slack/config.example.env @@ -0,0 +1,3 @@ +# Copy to config.env (next to this file) and fill in. +# Create an incoming webhook at https://api.slack.com/messaging/webhooks +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ diff --git a/examples/plugins/slack/notify.sh b/examples/plugins/slack/notify.sh new file mode 100755 index 00000000..cb6e2171 --- /dev/null +++ b/examples/plugins/slack/notify.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Slack notification hook. Reads SLACK_WEBHOOK_URL from the environment or from +# config.env bundled next to this script (TASK_PLUGIN_DIR). Fails quietly (exit +# 0) when unconfigured so it never blocks the task pipeline. +set -euo pipefail + +# Load bundled config, if present. cwd is already the plugin dir, but be explicit. +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi + +if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "slack plugin: SLACK_WEBHOOK_URL not set (see config.example.env)" >&2 + exit 0 +fi + +# Pick an emoji per event. +case "${TASK_EVENT:-}" in + task.done) emoji=":white_check_mark:" ;; + task.blocked) emoji=":raised_hand:" ;; + task.failed) emoji=":x:" ;; + *) emoji=":information_source:" ;; +esac + +# Minimal JSON string escaping (backslash + double-quote). +esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } + +event="${TASK_EVENT#task.}" +header="$emoji *#${TASK_ID:-?} ${TASK_TITLE:-untitled}* — ${event}" +detail="${TASK_MESSAGE:-}" +project="${TASK_PROJECT:-}" +[[ -n "$project" ]] && header="$header \`${project}\`" + +text="$header" +[[ -n "$detail" ]] && text="$text\n${detail}" + +payload=$(printf '{"text":"%s"}' "$(esc "$text")") + +curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null \ + || echo "slack plugin: webhook POST failed" >&2 diff --git a/examples/plugins/slack/plugin.yaml b/examples/plugins/slack/plugin.yaml new file mode 100644 index 00000000..18722f06 --- /dev/null +++ b/examples/plugins/slack/plugin.yaml @@ -0,0 +1,14 @@ +# Slack notifications for task events. +# +# Setup: +# cp -R examples/plugins/slack ~/.config/task/plugins/ +# cp ~/.config/task/plugins/slack/config.example.env \ +# ~/.config/task/plugins/slack/config.env +# # edit config.env and paste your Slack incoming-webhook URL +name: slack +version: 0.1.0 +description: Post task updates to a Slack channel via an incoming webhook. +hooks: + task.done: notify.sh + task.blocked: notify.sh + task.failed: notify.sh diff --git a/examples/plugins/worktree/diff.sh b/examples/plugins/worktree/diff.sh new file mode 100755 index 00000000..97c7dbfb --- /dev/null +++ b/examples/plugins/worktree/diff.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Show a compact summary of the task's worktree changes. +# WORKTREE_PATH is provided when the action is run with a task in context. +set -euo pipefail + +wt="${WORKTREE_PATH:-}" +if [[ -z "$wt" || ! -d "$wt" ]]; then + echo "no worktree for this task" + exit 0 +fi +cd "$wt" + +changed=$(git status --porcelain | wc -l | tr -d ' ') +stat=$(git diff --stat 2>/dev/null | tail -1 | sed 's/^ *//') + +if [[ "$changed" == "0" && -z "$stat" ]]; then + echo "worktree clean" + exit 0 +fi + +# First line is what the TUI banner shows; the rest is visible in the CLI. +echo "${stat:-$changed uncommitted file(s)}" +echo +git status --short diff --git a/examples/plugins/worktree/plugin.yaml b/examples/plugins/worktree/plugin.yaml new file mode 100644 index 00000000..0675ef13 --- /dev/null +++ b/examples/plugins/worktree/plugin.yaml @@ -0,0 +1,14 @@ +# Task-scoped worktree helpers, invoked on demand: +# - detail view: press A, pick one +# - palette: type ">" then search +# - CLI: ty plugins run worktree diff +name: worktree +version: 0.1.0 +description: Inspect a task's worktree — show its diff, or run its tests. +actions: + - id: diff + label: Show worktree diff + command: diff.sh + - id: test + label: Run tests in worktree + command: test.sh diff --git a/examples/plugins/worktree/test.sh b/examples/plugins/worktree/test.sh new file mode 100755 index 00000000..83abeaf7 --- /dev/null +++ b/examples/plugins/worktree/test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Run the task worktree's tests. Uses $TEST_CMD if set (export it, or put it in +# a config.env next to this script); otherwise auto-detects by project type. +set -euo pipefail + +wt="${WORKTREE_PATH:-}" +if [[ -z "$wt" || ! -d "$wt" ]]; then + echo "no worktree for this task" + exit 0 +fi + +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi + +cd "$wt" + +cmd="${TEST_CMD:-}" +if [[ -z "$cmd" ]]; then + if [[ -f go.mod ]]; then + cmd="go test ./..." + elif [[ -f package.json ]]; then + cmd="npm test" + elif [[ -f Rakefile || -d spec ]]; then + cmd="bundle exec rspec" + else + echo "no TEST_CMD set and could not detect project type" + exit 0 + fi +fi + +echo "running: $cmd" +if eval "$cmd"; then + echo "tests passed" +else + echo "tests FAILED" + exit 1 +fi