Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,25 @@ builds:
main: .
env:
- CGO_ENABLED=0
# Linux + macOS only. The manifest declares platforms = ["linux", "macos"]
# (we don't test on Windows, and the socket client + sh-based build step
# aren't validated there), so we don't ship Windows binaries to match.
goos: [linux, darwin]
# The manifest declares platforms = ["linux", "macos", "windows"], so we ship
# binaries for all three. Skip windows/arm64 — less common, and we'd rather
# not ship a binary we can't easily test; add it back if there's demand.
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w

archives:
- id: default
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats: [tar.gz]
# Windows archives ship as .zip, the platform-native format.
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE*
- README*
Expand Down
18 changes: 13 additions & 5 deletions action.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
"text/template"
)

// templateFuncs are the helper functions available inside a quick action's
// command template, on top of text/template's builtins (e.g. urlquery). opener
// expands to the platform's default open command so a single action works on
// every OS — see opener in shell.go.
func templateFuncs() template.FuncMap {
return template.FuncMap{"opener": opener}
}

// Action types. An action's Type decides what happens between selecting it in
// the picker and running its command.
const (
Expand Down Expand Up @@ -147,7 +154,7 @@ func (a Action) validate() error {
// position the value precisely with {{.Value}} or just receive it as its last
// argument.
func (a Action) render(ctx RunContext) (string, error) {
tmpl, err := template.New(a.Name).Parse(a.Command)
tmpl, err := template.New(a.Name).Funcs(templateFuncs()).Parse(a.Command)
if err != nil {
return "", fmt.Errorf("parse command for %q: %w", a.Name, err)
}
Expand All @@ -166,15 +173,16 @@ func (a Action) render(ctx RunContext) (string, error) {

// run renders and executes the action's command through the shell, in the
// invoking pane's working directory and with the context exported as
// HERDR_PLUS_* environment variables. Running through "sh -c" lets commands use
// pipes, arguments, and full scripts.
// HERDR_PLUS_* environment variables. Running through a shell (see
// shellCommand: `sh -c`, or PowerShell on Windows) lets commands use pipes,
// arguments, and full scripts.
func (a Action) run(ctx RunContext) error {
cmdline, err := a.render(ctx)
if err != nil {
return err
}

cmd := exec.Command("sh", "-c", cmdline)
cmd := shellCommand(cmdline)
if ctx.WorkDir != "" {
cmd.Dir = ctx.WorkDir
}
Expand Down
49 changes: 49 additions & 0 deletions action_run_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// Date: 2026-06-15
// Author: Spicer Matthews (spicer@cloudmanic.com)
// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved.
//

package main

import (
"strings"
"testing"
)

// TestActionRunQuotingRoundTrip is the empirical half of the quoting contract: it
// renders and runs a real action through the host's actual shell (PowerShell on
// Windows, sh elsewhere) and asserts a hostile Value — spaces, a single quote, a
// double quote, and a $ that PowerShell would otherwise expand as a variable —
// comes back out verbatim. The unit tests prove shellQuote's output; this proves
// that output actually survives the shell shellCommand launches, which is the DoD
// guarantee for quick-action value injection on Windows.
//
// It exercises render()'s auto-append path: a command with no {{.Value}} gets the
// value appended as one shell-quoted argument (via shellQuote). That is the path
// herdr-plus is responsible for making injection-safe. A command that hand-quotes
// {{.Value}} itself (e.g. echo '{{.Value}}') is the author's responsibility — the
// raw value is substituted verbatim there — so it is intentionally not asserted.
func TestActionRunQuotingRoundTrip(t *testing.T) {
// A value that breaks naive concatenation on both shells at once: spaces, both
// quote styles, and a PowerShell variable sigil.
const nasty = `a b 'c" $x`

// echo with no {{.Value}} → render appends shellQuote(value). `echo` is
// Write-Output on PowerShell and the echo builtin on sh; both print their
// argument verbatim.
a := Action{Name: "roundtrip", Type: TypeForm, Command: "echo"}
cmdline, err := a.render(RunContext{Value: nasty})
if err != nil {
t.Fatalf("render: %v", err)
}

out, err := shellCommand(cmdline).Output()
if err != nil {
t.Fatalf("run %q: %v", cmdline, err)
}

if got := strings.TrimRight(string(out), "\r\n"); got != nasty {
t.Fatalf("round-trip through shell = %q, want %q (cmdline: %q)", got, nasty, cmdline)
}
}
25 changes: 14 additions & 11 deletions action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,18 @@ func TestActionRender(t *testing.T) {
want: "open https://github.com/cloudmanic/herdr-plus",
},
{
// The value is appended and quoted for the current platform's shell;
// build the expected string with shellQuote so it holds on Windows too.
name: "value appended when template omits it",
action: Action{Name: "Say", Type: TypeForm, Command: "say"},
ctx: RunContext{Value: "hi there"},
want: "say 'hi there'",
want: "say " + shellQuote("hi there"),
},
{
name: "value with single quote is shell-safe when appended",
action: Action{Name: "Say", Type: TypeForm, Command: "say"},
ctx: RunContext{Value: "it's me"},
want: `say 'it'\''s me'`,
want: "say " + shellQuote("it's me"),
},
{
name: "workdir variable",
Expand All @@ -64,6 +66,14 @@ func TestActionRender(t *testing.T) {
ctx: RunContext{Value: "hello world"},
wantSub: []string{"hello", "world"},
},
{
// {{opener}} renders to the host's open command (open/xdg-open/
// Start-Process); assert against opener() so it holds on every OS.
name: "opener helper renders host open command",
action: Action{Name: "Open", Command: "{{opener}} https://github.com"},
ctx: RunContext{},
want: opener() + " https://github.com",
},
}

for _, tc := range cases {
Expand Down Expand Up @@ -128,12 +138,5 @@ func TestOptionResolvedValue(t *testing.T) {
}
}

// TestShellQuote verifies single-quote escaping produces a single shell token.
func TestShellQuote(t *testing.T) {
if got := shellQuote("plain"); got != "'plain'" {
t.Fatalf("shellQuote(plain) = %q", got)
}
if got := shellQuote("it's"); got != `'it'\''s'` {
t.Fatalf("shellQuote(it's) = %q", got)
}
}
// Shell quoting is OS-specific and lives in shell_test.go (posixQuote /
// powershellQuote are tested directly there, on every platform).
4 changes: 3 additions & 1 deletion examples/quick-actions/github.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

name = "GitHub"
description = "Open https://github.com"
command = "open https://github.com"
# {{opener}} expands to your OS's default open command (open / xdg-open /
# Start-Process), so the same action works on macOS, Linux, and Windows.
command = "{{opener}} https://github.com"
2 changes: 1 addition & 1 deletion examples/quick-actions/google-search.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
name = "Search Google"
description = "Type a query and open the results"
type = "form"
command = "open 'https://www.google.com/search?q={{.Value | urlquery}}'"
command = "{{opener}} 'https://www.google.com/search?q={{.Value | urlquery}}'"

[form]
prompt = "Search Google for"
Expand Down
2 changes: 1 addition & 1 deletion examples/quick-actions/google.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
name = "Google"
description = "Open https://google.com"
type = "command"
command = "open https://google.com"
command = "{{opener}} https://google.com"
2 changes: 1 addition & 1 deletion examples/quick-actions/open-repo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
name = "Open Repo on GitHub"
description = "Pick one of our repos and open it"
type = "select"
command = "open https://github.com/cloudmanic/{{.Value}}"
command = "{{opener}} https://github.com/cloudmanic/{{.Value}}"

[[options]]
label = "Herdr Plus"
Expand Down
6 changes: 4 additions & 2 deletions examples/quick-actions/open-working-dir.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# HERDR_PLUS_WORKDIR, HERDR_PLUS_SESSION_TITLE, and so on.

name = "Reveal Working Dir"
description = "Open the launch directory in Finder"
description = "Open the launch directory in your file manager"
type = "command"
command = "open {{.WorkDir}}"
# {{opener}} is your OS's open command; single-quoting {{.WorkDir}} keeps a path
# with spaces intact on every shell.
command = "{{opener}} '{{.WorkDir}}'"
94 changes: 89 additions & 5 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,60 +18,132 @@ name = "Herdr Plus"
version = "0.1.16"
min_herdr_version = "0.7.0"
description = "An extension for herdr — a collection of tools that make it better. Projects: fuzzy-pick a declarative template to spin up a whole workspace — every tab, pane, and startup command. Quick Actions: a fuzzy launcher for one-off scripts, run in the directory you launched from."
platforms = ["linux", "macos"]
# Windows is supported under herdr's Windows beta (a manifest platform in
# preview). Per-command `platforms` below override this list so each OS runs the
# right build step and the right binary name (herdr-plus vs herdr-plus.exe).
platforms = ["linux", "macos", "windows"]

# Produce the binary at install time. herdr runs this once, after you confirm a
# GitHub install and before registering the plugin. The script prefers a local Go
# toolchain (an exact build of the cloned source) and falls back to downloading
# the latest prebuilt release binary, so installing works with or without Go.
# Either way the result is ./bin/herdr-plus, which the entry points below invoke.
# GitHub install and before registering the plugin.
#
# Unix: the sh script prefers a local Go toolchain (an exact build of the cloned
# source) and falls back to downloading the latest prebuilt release binary, so
# installing works with or without Go. The result is ./bin/herdr-plus.
[[build]]
platforms = ["linux", "macos"]
command = ["sh", "scripts/build.sh"]

# Windows: no POSIX `sh`, so build straight from source with the Go toolchain.
# The result is ./bin/herdr-plus.exe. Installing via `herdr plugin install` on
# Windows therefore needs Go on PATH; for a checkout you can also build once
# (`go build -o bin/herdr-plus.exe .`) and `herdr plugin link .`.
[[build]]
platforms = ["windows"]
command = ["go", "build", "-o", "bin/herdr-plus.exe", "."]

# Every entry point below is declared twice: a unix entry (platforms = linux,
# macos → ./bin/herdr-plus) and a windows twin (platforms = windows). Two entries
# are unavoidable — herdr requires a unique id per action/pane, and the two OSes
# need different commands — so the windows twins carry a `-windows` id suffix. The
# Go code that opens a pane picks the right entrypoint at runtime via
# paneEntrypoint() (see shell.go), so nothing is hardcoded per-OS.
#
# The windows command is `powershell ... & .\bin\herdr-plus.exe <sub>`, not the
# bare `.\bin\herdr-plus.exe`, for two Windows-specific reasons proven
# empirically against the herdr Windows beta:
# 1. Relative exe resolution. herdr runs the command with cwd = plugin root, but
# Windows' CreateProcessW resolves a *relative* application path against
# herdr's own cwd, not the child cwd it sets — so `.\bin\herdr-plus.exe`
# spawned directly fails with os error 3. Going through powershell (found on
# PATH) fixes this: powershell's cwd is the plugin root, and its `&` operator
# resolves `.\bin\herdr-plus.exe` from there.
# 2. Extension. Windows cannot spawn an extensionless PE by its bare path
# (CreateProcessW appends .exe), so the binary must be named herdr-plus.exe.

# projects — the flagship action. Opens a full-screen browser to fuzzy-pick a
# project (a declarative set of tabs/panes from ~/.config/herdr-plus/projects/)
# and spins up its whole herdr workspace. Bind a key to it or run it from herdr's
# action menu — see the README.
[[actions]]
id = "projects"
title = "Herdr Plus: Projects"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "projects"]

[[actions]]
id = "projects-windows"
title = "Herdr Plus: Projects"
platforms = ["windows"]
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command", "& .\\bin\\herdr-plus.exe projects"]

# picker — the projects browser as a herdr-managed pane (no throwaway workspace).
# Opened by the projects action via `herdr plugin pane open`; herdr gives it a
# zoomed pane, runs the browser, and tears the pane down when it exits.
[[panes]]
id = "picker"
title = "Herdr Plus: Projects"
placement = "zoomed"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "projects-ui"]

[[panes]]
id = "picker-windows"
title = "Herdr Plus: Projects"
placement = "zoomed"
platforms = ["windows"]
# Panes deliberately omit -NonInteractive (which actions/events keep): this hosts
# an interactive bubbletea TUI, and -NonInteractive would break its input.
command = ["powershell", "-NoProfile", "-Command", "& .\\bin\\herdr-plus.exe projects-ui"]

# quick-actions — a fuzzy launcher for one-off actions/scripts (command, select,
# or form), loaded from ~/.config/herdr-plus/quick-actions/ (plus a repo's own
# .herdr-plus/quick-actions/). Commands template against and run in the directory
# you launched from. Bind a key or run it from herdr's action menu.
[[actions]]
id = "quick-actions"
title = "Herdr Plus: Quick Actions"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "quick-actions"]

[[actions]]
id = "quick-actions-windows"
title = "Herdr Plus: Quick Actions"
platforms = ["windows"]
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command", "& .\\bin\\herdr-plus.exe quick-actions"]

# quick-actions-picker — the action picker as a herdr-managed overlay pane. The
# quick-actions action opens it (passing the launch context), and herdr restores
# your previous focus when it closes.
[[panes]]
id = "quick-actions-picker"
title = "Quick Actions"
placement = "overlay"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "quick-actions-ui"]

[[panes]]
id = "quick-actions-picker-windows"
title = "Quick Actions"
placement = "overlay"
platforms = ["windows"]
# Omits -NonInteractive on purpose — interactive TUI pane; see picker-windows.
command = ["powershell", "-NoProfile", "-Command", "& .\\bin\\herdr-plus.exe quick-actions-ui"]

# ping — a smoke-test action that proves the plugin loop end to end: it talks to
# herdr over the socket and prints the focused pane/workspace. Its output is
# captured in `herdr plugin log list --plugin cloudmanic.herdr-plus`.
[[actions]]
id = "ping"
title = "Herdr Plus: ping"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "ping"]

[[actions]]
id = "ping-windows"
title = "Herdr Plus: ping"
platforms = ["windows"]
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command", "& .\\bin\\herdr-plus.exe ping"]

# worktree.created / worktree.opened — herdr fires worktree.created when it
# creates a new git worktree and worktree.opened when it opens an existing one
# into a workspace (e.g. the right-click dialog for a branch that already has a
Expand All @@ -84,8 +156,20 @@ command = ["./bin/herdr-plus", "ping"]
# it by hand. Output is captured in the plugin log.
[[events]]
on = "worktree.created"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "on-worktree"]

[[events]]
on = "worktree.created"
platforms = ["windows"]
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command", "& .\\bin\\herdr-plus.exe on-worktree"]

[[events]]
on = "worktree.opened"
platforms = ["linux", "macos"]
command = ["./bin/herdr-plus", "on-worktree"]

[[events]]
on = "worktree.opened"
platforms = ["windows"]
command = ["powershell", "-NoProfile", "-NonInteractive", "-Command", "& .\\bin\\herdr-plus.exe on-worktree"]
Loading
Loading