From 19e9ff971583a163e60dc8ace00590ddef84e58a Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Fri, 21 Aug 2026 09:25:12 +0700 Subject: [PATCH 1/4] feat(cli): adapt help and listing to how the binary was invoked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trellis-cli registers any executable on $PATH named `trellis-*` as a subcommand and execs it with the remaining argv, so shipping wp-ops as a `trellis ops` plugin needs no plugin API — just a second name for the same binary. What it does need is for the binary to notice which name it was called under. Two things followed from that, both keyed off filepath.Base(os.Args[0]): Help text and suggestions now render against the invoked name. Telling someone who typed `trellis ops` to "Run 'wp-ops backup'" points them at a command they may not know exists. Root's Long help became a template (rootLong) rather than a literal string; output under bare wp-ops is byte-for-byte what it was. Listing views scope to @platform trellis under the plugin name — 27 commands across 6 categories instead of all 74. Someone at a `trellis` prompt is not looking for the image converters or the release scripts. Deliberately narrow: an explicit --platform wins, a category with nothing Trellis-tagged still lists in full rather than claiming to be empty, --json stays the whole catalog because it is a contract for external tooling, and execution is never scoped — name any command and it runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LEJsmBJKcH4dXK3LUDr6zN --- go/cmd/dispatch.go | 8 ++-- go/cmd/docs.go | 8 ++-- go/cmd/invoked.go | 49 ++++++++++++++++++++++ go/cmd/invoked_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++ go/cmd/list.go | 73 +++++++++++++++++++++++++++------ go/cmd/root.go | 81 +++++++++++++++++++++++++++---------- go/cmd/search.go | 6 +-- go/cmd/serverside.go | 6 +-- go/cmd/trellis.go | 2 +- 9 files changed, 276 insertions(+), 49 deletions(-) create mode 100644 go/cmd/invoked.go create mode 100644 go/cmd/invoked_test.go diff --git a/go/cmd/dispatch.go b/go/cmd/dispatch.go index df56dc6..3377f1f 100644 --- a/go/cmd/dispatch.go +++ b/go/cmd/dispatch.go @@ -165,7 +165,7 @@ func rootBasenameCompletions(cc *cobra.Command, args []string, toComplete string func runCategory(c *catalog.Catalog, scope categoryScope, args []string) { if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { printCategoryCommands(scope.name, scope.members(c)) - fmt.Printf("Usage: wp-ops %s [args...]\n", scope.name) + fmt.Printf("Usage: %s %s [args...]\n", cmdName(), scope.name) return } @@ -350,9 +350,9 @@ func printUnknownCommand(c *catalog.Catalog, candidate string) { fmt.Fprintf(os.Stderr, "Unknown command or category: %s\n\n", candidate) if best, ok := suggestSimilar(c, candidate); ok { fmt.Fprintln(os.Stderr, "Did you mean:") - fmt.Fprintf(os.Stderr, " wp-ops %s\n\n", best) + fmt.Fprintf(os.Stderr, " %s %s\n\n", cmdName(), best) } - fmt.Fprintf(os.Stderr, "Try wp-ops search %s, or wp-ops to browse everything.\n", candidate) + fmt.Fprintf(os.Stderr, "Try %s search %s, or %s to browse everything.\n", cmdName(), candidate, cmdName()) } // printAmbiguous ports print_ambiguous (wp-ops:1517). @@ -362,7 +362,7 @@ func printAmbiguous(name string, matches []catalog.Entry) { fmt.Fprintf(os.Stderr, " %-40s %s\n", m.Key, m.Description) } fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, "Run it by its full name, e.g. wp-ops %s\n", matches[0].Key) + fmt.Fprintf(os.Stderr, "Run it by its full name, e.g. %s %s\n", cmdName(), matches[0].Key) } // suggestSimilar ports suggest_similar (wp-ops:1533): a substring match on diff --git a/go/cmd/docs.go b/go/cmd/docs.go index 96c38d1..0d50f8a 100644 --- a/go/cmd/docs.go +++ b/go/cmd/docs.go @@ -87,7 +87,7 @@ func runDocs(term string, pathsOnly, wholeWord bool) int { if len(results) == 0 { fmt.Printf("No documents match '%s'.\n\n", term) - fmt.Printf("Try wp-ops search %s to look for a command instead.\n", term) + fmt.Printf("Try %s search %s to look for a command instead.\n", cmdName(), term) return 1 } @@ -120,9 +120,9 @@ func runDocs(term string, pathsOnly, wholeWord bool) int { } if wholeWord { - fmt.Printf("Paths only (for piping to an editor): wp-ops docs %s -l\n\n", term) + fmt.Printf("Paths only (for piping to an editor): %s docs %s -l\n\n", cmdName(), term) } else { - fmt.Printf("Whole words only: wp-ops docs %s -w · paths only: wp-ops docs %s -l\n\n", term, term) + fmt.Printf("Whole words only: %s docs %s -w · paths only: %s docs %s -l\n\n", cmdName(), term, cmdName(), term) } return 0 @@ -140,7 +140,7 @@ func printDocsList(root string) { fmt.Printf(" %s\n", relDocPath(root, d)) } fmt.Println() - fmt.Println("Search inside them with: wp-ops docs ") + fmt.Printf("Search inside them with: %s docs \n", cmdName()) fmt.Println() } diff --git a/go/cmd/invoked.go b/go/cmd/invoked.go new file mode 100644 index 0000000..6a101a2 --- /dev/null +++ b/go/cmd/invoked.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// trellis-cli discovers plugins by scanning $PATH for executables named +// `trellis-*`, then splits the filename on "-", drops the first segment, +// and joins the rest with spaces to form the subcommand (trellis-cli +// plugin/finder.go:62-67). So the binary must be named `trellis-ops` for +// the one-word `trellis ops`: `trellis-wp-ops` would become the +// three-word `trellis wp ops`. +// +// The Homebrew cask ships this name as a second symlink to the same +// binary (.goreleaser.yml, homebrew_casks.custom_block), so wp-ops is +// literally the same executable under both names — which is why the only +// thing distinguishing the two invocations is argv[0]. +const trellisPluginBinary = "trellis-ops" + +// invokedAs is argv[0]'s basename. A var rather than a func so tests can +// swap it; nothing else should write to it. +var invokedAs = filepath.Base(os.Args[0]) + +// asTrellisPlugin reports whether we were run as `trellis ops ...` rather +// than as bare `wp-ops ...`. +func asTrellisPlugin() bool { return invokedAs == trellisPluginBinary } + +// cmdName is how the user actually typed us, for help text and +// suggestions. Printing "wp-ops " at someone who typed +// "trellis ops" tells them to run a command they may not know exists. +func cmdName() string { + if asTrellisPlugin() { + return "trellis ops" + } + return "wp-ops" +} + +// defaultPlatform scopes the *listing* views when we're running inside +// trellis-cli: someone at a `trellis` prompt wants the 27 commands tagged +// @platform trellis, not the image converters and release scripts. It +// deliberately does not scope *execution* — every command still runs if +// you name it, and an explicit --platform always wins. +func defaultPlatform() string { + if asTrellisPlugin() { + return "trellis" + } + return "" +} diff --git a/go/cmd/invoked_test.go b/go/cmd/invoked_test.go new file mode 100644 index 0000000..209174c --- /dev/null +++ b/go/cmd/invoked_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/imagewize/wp-ops/go/internal/catalog" +) + +// withInvokedAs runs fn with argv[0]'s basename swapped, restoring it after. +func withInvokedAs(t *testing.T, name string, fn func()) { + t.Helper() + prev := invokedAs + invokedAs = name + defer func() { invokedAs = prev }() + fn() +} + +func TestCmdNameAndPlatformFollowArgv0(t *testing.T) { + cases := []struct { + argv0 string + wantName string + wantPlat string + }{ + {"wp-ops", "wp-ops", ""}, + {"trellis-ops", "trellis ops", "trellis"}, + // Only the exact plugin name flips behavior — a user who renames + // the binary to something else still gets plain wp-ops. + {"trellis-wpops", "wp-ops", ""}, + {"wp-ops-dev", "wp-ops", ""}, + } + for _, tc := range cases { + withInvokedAs(t, tc.argv0, func() { + if got := cmdName(); got != tc.wantName { + t.Errorf("argv0 %q: cmdName() = %q, want %q", tc.argv0, got, tc.wantName) + } + if got := defaultPlatform(); got != tc.wantPlat { + t.Errorf("argv0 %q: defaultPlatform() = %q, want %q", tc.argv0, got, tc.wantPlat) + } + }) + } +} + +// The whole point of the argv[0] plumbing: a `trellis ops` user must never +// be told to run a command they didn't type. +func TestRootLongUsesInvokedName(t *testing.T) { + long := rootLong("trellis ops") + if strings.Contains(long, "wp-ops") { + t.Errorf("rootLong(\"trellis ops\") still mentions wp-ops:\n%s", long) + } + for _, want := range []string{"trellis ops list", "trellis ops doctor", "trellis ops --version"} { + if !strings.Contains(long, want) { + t.Errorf("rootLong missing %q:\n%s", want, long) + } + } +} + +// Under bare wp-ops the help must be exactly what it was before the +// template — this is the regression guard on the rewrite. +func TestRootLongUnderWpOpsIsUnchanged(t *testing.T) { + long := rootLong("wp-ops") + for _, want := range []string{ + "wp-ops / [args...] Run a command by its full key", + "wp-ops --where Print the path to a command's script", + "wp-ops list List every command by category", + "wp-ops --version Show version", + } { + if !strings.Contains(long, want) { + t.Errorf("rootLong(\"wp-ops\") missing exact line %q:\n%s", want, long) + } + } + if strings.Contains(long, "trellis ops") { + t.Error("rootLong(\"wp-ops\") leaked the plugin name") + } +} + +func TestFilterEntriesByPlatform(t *testing.T) { + entries := []catalog.Entry{ + {Key: "trellis/backup/database-pull", Platform: "trellis"}, + {Key: "scripts/images/jpg-to-webp", Platform: "any"}, + {Key: "wp-cli/security/scanner", Platform: "wordpress"}, + } + + if got := filterEntriesByPlatform(entries, ""); len(got) != 3 { + t.Errorf("empty platform must not filter: got %d, want 3", len(got)) + } + + got := filterEntriesByPlatform(entries, "trellis") + if len(got) != 1 || got[0].Key != "trellis/backup/database-pull" { + t.Errorf("platform trellis: got %v, want just the trellis entry", got) + } +} diff --git a/go/cmd/list.go b/go/cmd/list.go index 802fc24..c0f3727 100644 --- a/go/cmd/list.go +++ b/go/cmd/list.go @@ -32,7 +32,7 @@ func validatePlatform(platform string) error { return nil } } - return fmt.Errorf("wp-ops: unknown platform %q — expected one of: %s", + return fmt.Errorf("%s: unknown platform %q — expected one of: %s", cmdName(), platform, strings.Join(catalog.Platforms, ", ")) } @@ -45,7 +45,14 @@ var listCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) os.Exit(1) } - filtered := c.FilterByPlatform(platformFlag) + platform := platformFlag + if platform == "" { + // Unset means "the default for how we were invoked": no + // filter under wp-ops, @platform trellis under `trellis ops`. + // An explicit --platform always wins over that default. + platform = defaultPlatform() + } + filtered := c.FilterByPlatform(platform) switch { case jsonFlag: printJSON(filtered) @@ -79,13 +86,21 @@ func printCategorizedList(c *catalog.Catalog) { fmt.Printf(" %-22s (%2d) %s\n", catalog.CategoryDisplayNames[category], len(entries), catalog.CategoryBlurbs[category]) } + n := cmdName() fmt.Println() - fmt.Println("Run 'wp-ops ' to see a category's commands (e.g. 'wp-ops backup')") - fmt.Println("Run 'wp-ops list --all' to see every command with its description") - fmt.Println("Run 'wp-ops list --platform wordpress' to see only what runs on any WP site") - fmt.Println("Run 'wp-ops search ' to find a command") - fmt.Println("Run 'wp-ops doctor' to check dependencies and environment") - fmt.Println("Run 'wp-ops --json' for machine-readable command list") + fmt.Printf("Run '%s ' to see a category's commands (e.g. '%s backup')\n", n, n) + fmt.Printf("Run '%s list --all' to see every command with its description\n", n) + if asTrellisPlugin() { + // The scoped view hides ~two thirds of the catalog, so say where + // the rest is. Same binary, one word away — the cask installs + // both names. + fmt.Println("Showing @platform trellis commands only — run 'wp-ops' for the full catalog") + } else { + fmt.Printf("Run '%s list --platform wordpress' to see only what runs on any WP site\n", n) + } + fmt.Printf("Run '%s search ' to find a command\n", n) + fmt.Printf("Run '%s doctor' to check dependencies and environment\n", n) + fmt.Printf("Run '%s --json' for machine-readable command list\n", n) } // printAllCommands is the original full listing: every command in every @@ -102,10 +117,11 @@ func printAllCommands(c *catalog.Catalog) { fmt.Println() } - fmt.Println("Run 'wp-ops list' for a compact category summary") - fmt.Println("Run 'wp-ops search ' to find a command") - fmt.Println("Run 'wp-ops doctor' to check dependencies and environment") - fmt.Println("Run 'wp-ops --json' for machine-readable command list") + n := cmdName() + fmt.Printf("Run '%s list' for a compact category summary\n", n) + fmt.Printf("Run '%s search ' to find a command\n", n) + fmt.Printf("Run '%s doctor' to check dependencies and environment\n", n) + fmt.Printf("Run '%s --json' for machine-readable command list\n", n) } func printCategoryCommands(category string, entries []catalog.Entry) { @@ -113,11 +129,42 @@ func printCategoryCommands(category string, entries []catalog.Entry) { fmt.Printf("No commands found in category: %s\n", catalog.CategoryDisplayNames[category]) return } + + // Scope the listing the same way the category summary is scoped, so + // the counts agree. But a category with nothing tagged @platform + // trellis (SEO, Images, Git...) still has commands that run fine here, + // so fall back to the whole category rather than claiming it's empty. + scoped := filterEntriesByPlatform(entries, defaultPlatform()) + unscoped := len(scoped) == 0 + if unscoped { + scoped = entries + } + fmt.Printf("%s Commands:\n\n", catalog.CategoryDisplayNames[category]) - printCategoryEntries(entries) + printCategoryEntries(scoped) + if unscoped { + fmt.Printf("\nNothing here is Trellis-specific — these run on any WordPress site.\n") + } fmt.Println() } +// filterEntriesByPlatform keeps the per-category listing consistent with +// the counts in the category summary. Execution is deliberately not +// filtered — naming a non-trellis command under `trellis ops` still runs +// it; only what we *advertise* is scoped. +func filterEntriesByPlatform(entries []catalog.Entry, platform string) []catalog.Entry { + if platform == "" { + return entries + } + var kept []catalog.Entry + for _, e := range entries { + if e.Platform == platform { + kept = append(kept, e) + } + } + return kept +} + func printCategoryEntries(entries []catalog.Entry) { for _, e := range entries { tag := "" diff --git a/go/cmd/root.go b/go/cmd/root.go index 9b57018..027d514 100644 --- a/go/cmd/root.go +++ b/go/cmd/root.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -19,24 +20,8 @@ var jsonFlag bool var rootCmd = &cobra.Command{ Use: "wp-ops", Short: "Unified CLI wrapper for wp-ops tools", - Long: `wp-ops is a single entry point for WordPress operations tools, -scripts, and utilities: backups, monitoring, WP-CLI helpers, Trellis -playbooks, and more. - - wp-ops / [args...] Run a command by its full key - wp-ops [args...] Run a command by category + name - wp-ops [args...] Run a command by its bare name - wp-ops --where Print the path to a command's script - wp-ops --help Show a command's own help - - wp-ops list List every command by category - wp-ops search Search commands by name or description - wp-ops docs [term] [-l] Search the guides (no term lists them) - wp-ops doctor Check dependencies and environment - wp-ops init Install shell completions - wp-ops mcp-register Show MCP registration snippets for Claude/Mistral/Codex - wp-ops --json Output the command list as JSON - wp-ops --version Show version`, + // Long is filled in by Execute() so every usage line spells the name + // the user actually typed — see rootLong and cmdName(). // Arbitrary args: a bare command name that isn't one of the explicitly // registered children (list/search/doctor/category commands/full-key // commands) falls through here and is resolved as a basename against @@ -62,6 +47,10 @@ playbooks, and more. func rootRunE(cc *cobra.Command, args []string) error { c := mustCatalog() + // What we *show* is scoped to how we were invoked (see + // defaultPlatform); what we *run*, below, never is. --json stays + // unscoped too — it's a stable contract for external tooling. + listing := c.FilterByPlatform(defaultPlatform()) if len(args) == 0 { // Port of main()'s `[[ -t 0 && -t 1 ]]` branch (wp-ops:2154-2157): @@ -69,10 +58,10 @@ func rootRunE(cc *cobra.Command, args []string) error { // piped/redirected invocation keeps printing list-equivalent output // so `wp-ops | less` etc. still work. if detect.IsTerminal(os.Stdin) && detect.IsTerminal(os.Stdout) { - os.Exit(runInteractive(c)) + os.Exit(runInteractive(listing)) return nil } - printCategorizedList(c) + printCategorizedList(listing) return nil } @@ -84,7 +73,7 @@ func rootRunE(cc *cobra.Command, args []string) error { printVersion() return nil case "--help", "-h": - printCategorizedList(c) + printCategorizedList(listing) return nil } @@ -109,8 +98,58 @@ func rootRunE(cc *cobra.Command, args []string) error { return nil } +// rootLong renders the root help against the invoked name, so a +// `trellis ops` user is told to run `trellis ops backup`, not +// `wp-ops backup`. Under bare wp-ops the output is byte-for-byte what it +// was before this became a template. +func rootLong(name string) string { + pad := func(usage string) string { + // Descriptions start at column 40, as they did when this was a + // literal string built around "wp-ops". The longest wp-ops row + // ("wp-ops / [args...]") is 37, so nothing + // overflows under the original name; "trellis ops" is 5 wider and + // pushes its three longest rows out, hence the guard. + if len(usage) >= 40 { + return usage + " " + } + return usage + strings.Repeat(" ", 40-len(usage)) + } + rows := [][2]string{ + {name + " / [args...]", "Run a command by its full key"}, + {name + " [args...]", "Run a command by category + name"}, + {name + " [args...]", "Run a command by its bare name"}, + {name + " --where", "Print the path to a command's script"}, + {name + " --help", "Show a command's own help"}, + {"", ""}, + {name + " list", "List every command by category"}, + {name + " search ", "Search commands by name or description"}, + {name + " docs [term] [-l]", "Search the guides (no term lists them)"}, + {name + " doctor", "Check dependencies and environment"}, + {name + " init", "Install shell completions"}, + {name + " mcp-register", "Show MCP registration snippets for Claude/Mistral/Codex"}, + {name + " --json", "Output the command list as JSON"}, + {name + " --version", "Show version"}, + } + + var b strings.Builder + fmt.Fprintf(&b, `%s is a single entry point for WordPress operations tools, +scripts, and utilities: backups, monitoring, WP-CLI helpers, Trellis +playbooks, and more. +`, name) + for _, r := range rows { + if r[0] == "" { + b.WriteString("\n") + continue + } + fmt.Fprintf(&b, " %s%s\n", pad(r[0]), r[1]) + } + return strings.TrimRight(b.String(), "\n") +} + // Execute runs the root command. func Execute() error { + rootCmd.Use = cmdName() + rootCmd.Long = rootLong(cmdName()) registerCatalogCommands(mustCatalog()) if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/go/cmd/search.go b/go/cmd/search.go index 1e4f91f..2e73245 100644 --- a/go/cmd/search.go +++ b/go/cmd/search.go @@ -15,7 +15,7 @@ var searchCmd = &cobra.Command{ Args: cobra.ArbitraryArgs, RunE: func(cc *cobra.Command, args []string) error { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "Usage: wp-ops search ") + fmt.Fprintf(os.Stderr, "Usage: %s search \n", cmdName()) os.Exit(1) } runSearch(args[0]) @@ -42,9 +42,9 @@ func runSearch(term string) { if len(matches) == 0 { fmt.Printf("No commands match '%s'.\n\n", term) if hasDocMatches(term) { - fmt.Printf("The documentation mentions it though — try wp-ops docs %s.\n\n", term) + fmt.Printf("The documentation mentions it though — try %s docs %s.\n\n", cmdName(), term) } - fmt.Println("Run wp-ops to browse everything by category.") + fmt.Printf("Run %s to browse everything by category.\n", cmdName()) os.Exit(1) } diff --git a/go/cmd/serverside.go b/go/cmd/serverside.go index 9a207d3..4dae613 100644 --- a/go/cmd/serverside.go +++ b/go/cmd/serverside.go @@ -146,8 +146,8 @@ func printServerSideGuidance(w io.Writer, e catalog.Entry) { fmt.Fprintln(w, "That leaves the archive on the server. To back up *and* retrieve a copy") fmt.Fprintln(w, "to this machine in one step, use the Ansible playbooks instead:") fmt.Fprintln(w) - fmt.Fprintln(w, " wp-ops database-pull example.com production") - fmt.Fprintln(w, " wp-ops files-pull example.com production") + fmt.Fprintf(w, " %s database-pull example.com production\n", cmdName()) + fmt.Fprintf(w, " %s files-pull example.com production\n", cmdName()) return } @@ -168,7 +168,7 @@ func printServerSideGuidance(w io.Writer, e catalog.Entry) { if hasGNUDate() { fmt.Fprintln(w, "Already have a log file on this machine? Pass its path and it runs here:") - fmt.Fprintf(w, " wp-ops %s /path/to/access.log\n", e.CommandName()) + fmt.Fprintf(w, " %s %s /path/to/access.log\n", cmdName(), e.CommandName()) } else { fmt.Fprintln(w, "The SSH route above is unaffected by what's installed here — date and") fmt.Fprintln(w, "gawk both resolve on the server. Only running it against a log copied") diff --git a/go/cmd/trellis.go b/go/cmd/trellis.go index 5e59524..91b5cb3 100644 --- a/go/cmd/trellis.go +++ b/go/cmd/trellis.go @@ -35,7 +35,7 @@ func resolveTrellisDir() (string, bool) { fmt.Fprintln(os.Stderr, "TRELLIS_DIR is not set.") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Ansible playbook commands run against a real Trellis project (they read") - fmt.Fprintln(os.Stderr, "its ansible.cfg, inventory, and group_vars/), so wp-ops needs to know") + fmt.Fprintf(os.Stderr, "its ansible.cfg, inventory, and group_vars/), so %s needs to know\n", cmdName()) fmt.Fprintln(os.Stderr, "where that project lives. Either run this from inside the project, or:") fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, " export TRELLIS_DIR=/path/to/your/trellis") From 681939f79f8bd1ee5fc99e4c432837b2cd646f8d Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Fri, 21 Aug 2026 09:25:12 +0700 Subject: [PATCH 2/4] feat(release): install a trellis-ops alias alongside wp-ops The entire trellis-cli integration is a second symlink to the same binary. wp-ops ships as a goreleaser-generated cask, so it belongs in .goreleaser.yml rather than hand-edited into imagewize/homebrew-tap, whose Casks/wp-ops.rb is stamped "DO NOT EDIT". homebrew_casks has no field for a second `binary` stanza with a target, so this goes through custom_block. Verified by rendering the cask with a snapshot build. The name must be exactly `trellis-ops`: the plugin finder splits the filename on "-", drops the first segment, and joins the rest with spaces, so `trellis-wp-ops` would register the three-word `trellis wp ops`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LEJsmBJKcH4dXK3LUDr6zN --- .goreleaser.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 24da6e1..5ebe960 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -70,6 +70,20 @@ homebrew_casks: directory: Casks homepage: "https://github.com/imagewize/wp-ops" description: "Unified CLI for WordPress operations, Trellis, and Bedrock workflows" + # trellis-cli discovers plugins by scanning $PATH for executables named + # `trellis-*` and turns each into a subcommand, exec'ing the binary with + # the remaining argv (roots/trellis-cli plugin/finder.go, cmd/passthrough.go). + # So a second symlink to the same binary is the entire integration: no + # plugin API, no new code, no separate repo. The name must be + # `trellis-ops` — the finder splits on "-" and joins the rest with + # spaces, so `trellis-wp-ops` would become `trellis wp ops`. + # + # goreleaser's homebrew_casks has no field for a second `binary` stanza + # with a target, hence custom_block. It renders at the top of the cask + # body rather than beside the generated `binary "wp-ops"` — cask stanza + # order is cosmetic, and an unofficial tap isn't `brew audit`ed. + custom_block: | + binary "wp-ops", target: "trellis-ops" # The binary isn't code-signed/notarized (no Apple Developer ID), so # macOS quarantines it on download and Gatekeeper kills it outright on # first run (exit 137) rather than showing the usual "open anyway" From b0cefec0df406efaa667d5ae5e14677c642e06a3 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Fri, 21 Aug 2026 09:25:12 +0700 Subject: [PATCH 3/4] docs: document wp-ops as a trellis-cli plugin README gains a "trellis-cli plugin" section under the CLI docs, including the manual symlink for people who build from source rather than installing the cask. trellis-extensions-evaluation.md's Path A was written as a proposal and recommended the name `trellis-wpops`. Rewritten to describe what actually shipped as `trellis ops`, with the cask mechanics that the original (which assumed a formula) got wrong, and the verified finding that plugin commands run outside a Trellis project while core ones do not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LEJsmBJKcH4dXK3LUDr6zN --- CHANGELOG.md | 43 ++++++++ README.md | 27 +++++ docs/trellis-extensions-evaluation.md | 139 ++++++++++++++++---------- 3 files changed, 154 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9177e1d..abb1b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`trellis ops` — wp-ops as a trellis-cli plugin.** trellis-cli scans `$PATH` + for executables named `trellis-*`, drops the first `-`-separated segment, and + registers what's left as a subcommand, exec'ing the binary with the remaining + argv (roots/trellis-cli `plugin/finder.go`, `cmd/passthrough.go`). That's a + git/kubectl-style plugin model with no API to implement, so the whole + integration is a second symlink to the same binary: the Homebrew cask now + installs `trellis-ops` alongside `wp-ops`, and every wp-ops command is + reachable as `trellis ops <...>` from inside the tool Trellis users already + have open. + + The name has to be exactly `trellis-ops`. The finder joins the remaining + segments with *spaces*, so `trellis-wp-ops` would register the three-word + `trellis wp ops`; and a first segment matching a core root command is + silently skipped with no error (`isUnderCoreRootCommands`), which rules out + `db`, `backup`-adjacent core names, and anything else on `trellis --help`. + `ops` is free. + + Plugins are registered from `$PATH` before trellis-cli resolves a project, so + unlike core subcommands `trellis ops` runs anywhere — the playbook commands + locate the Trellis directory through wp-ops's own `detect.TrellisDir`, exactly + as they do under bare `wp-ops`. + +### Changed + +- **Help text and suggestions now name the command you actually typed.** Every + "Run `wp-ops ...`" line, usage string, and did-you-mean suggestion is rendered + against `filepath.Base(os.Args[0])`, so a `trellis ops` user is pointed at + `trellis ops backup` rather than a command they may not know exists. Output + under bare `wp-ops` is byte-for-byte unchanged. + +- **`trellis ops` scopes its listing to `@platform trellis`.** The bare listing, + `list`, and per-category views show the 27 Trellis-tagged commands across 6 + categories instead of all 74 — someone at a `trellis` prompt isn't looking for + the image converters or release scripts. An explicit `--platform` overrides + it, a category with nothing Trellis-tagged still lists in full rather than + claiming to be empty, and *execution* is never scoped: any command runs if you + name it. `--json` stays the full catalog, since it's a contract for external + tooling. + ## [5.5.0] - 2026-08-07 ### Added diff --git a/README.md b/README.md index f256bb4..64f1980 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,33 @@ Run `wp-ops doctor` first — it reports which of the external tools these scrip `wp-ops init` installs `wp-ops ` completion for zsh, bash, or fish, auto-detected from `$SHELL`. Worth running right after install — the Homebrew cask this ships as doesn't wire up completions on its own the way a Homebrew formula would. +### As a trellis-cli plugin + +The Homebrew cask installs the same binary under a second name, `trellis-ops`, +which [trellis-cli](https://github.com/roots/trellis-cli) picks up as a plugin — +it scans `$PATH` for `trellis-*` executables and turns each into a subcommand. So +everything below is also reachable from inside the tool you already have open: + +```bash +trellis ops # Trellis-relevant categories +trellis ops backup database-pull # same as: wp-ops backup database-pull +trellis ops search backup +``` + +`trellis ops` scopes its **listing** to the commands tagged `@platform trellis`; +run plain `wp-ops` for the full catalog. Running a command is never scoped — name +any command and it works. Unlike core `trellis` subcommands, a plugin doesn't need +you to be inside a Trellis project; the playbook commands find the project the same +way `wp-ops` always has. + +Requires trellis-cli new enough to have plugin support (v1.19.0 or later) and the +default `load_plugins: true`. If you built from source instead of installing the +cask, make the alias yourself: + +```bash +ln -s "$(command -v wp-ops)" /usr/local/bin/trellis-ops +``` + Two categories resolve their project directory from an environment variable — but you don't need to export it by hand if you're standing inside the project: wp-ops detects it by walking up from your current directory and asks before using what it finds. diff --git a/docs/trellis-extensions-evaluation.md b/docs/trellis-extensions-evaluation.md index 0ae8c23..3020e38 100644 --- a/docs/trellis-extensions-evaluation.md +++ b/docs/trellis-extensions-evaluation.md @@ -1,6 +1,8 @@ # Publishing wp-ops Trellis tools as Trellis extensions -**Status:** evaluation, nothing implemented. +**Status:** Path A **shipped** (see [Path A](#path-a-a-trellis-ops-plugin-shim)); +Path B still an evaluation. The trellis-sync issues are closed and the repo is +archived. **Date:** 2026-08-21. What this repo already has that the Trellis ecosystem wants, which of the two @@ -20,7 +22,7 @@ better evidence. - [The core-namespace guard](#the-core-namespace-guard) - [What the ecosystem already has](#what-the-ecosystem-already-has) - [What we have that overlaps](#what-we-have-that-overlaps) -- [Path A: a `trellis-wpops` plugin shim](#path-a-a-trellis-wpops-plugin-shim) +- [Path A: a `trellis-ops` plugin shim](#path-a-a-trellis-ops-plugin-shim) - [Path B: an Ansible role on Galaxy](#path-b-an-ansible-role-on-galaxy) - [The trellis-sync repo](#the-trellis-sync-repo) - [The open issues are a distribution channel](#the-open-issues-are-a-distribution-channel) @@ -85,14 +87,14 @@ executable** — a Bash script, or the existing `wp-ops` Go binary. Verified by running it: ```console -$ printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > /somewhere/on/path/trellis-wpopstest -$ chmod +x /somewhere/on/path/trellis-wpopstest +$ printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > /somewhere/on/path/trellis-demoplugin +$ chmod +x /somewhere/on/path/trellis-demoplugin $ trellis --help ... Available plugin commands: - wpopstest + demoplugin -$ trellis wpopstest hello world +$ trellis demoplugin hello world plugin works: hello world ``` @@ -107,11 +109,14 @@ joined **with spaces** (`plugin/finder.go:62-67`): | Binary name | Subcommand | | --- | --- | -| `trellis-wpops` | `trellis wpops` | +| `trellis-ops` | `trellis ops` | | `trellis-backup` | `trellis backup` | | `trellis-wp-ops` | `trellis wp ops` ← two words, almost certainly not what you want | -So the shim must be named `trellis-wpops`, not `trellis-wp-ops`. +So the shim must be a single segment after the prefix. **Shipped as +`trellis-ops` → `trellis ops`**; `trellis-wp-ops` would have become the +three-word `trellis wp ops`. An earlier draft of this document proposed +`trellis-wpops`, which works but reads like a typo. ### The core-namespace guard @@ -146,7 +151,7 @@ command: `alias check db deploy dotenv exec galaxy info init key logs new open provision rollback server shell-init ssh valet vault vm xdebug-tunnel`, plus `droplet` and `venv`. -Free and relevant: `backup`, `monitor`, `wpops`, `security`, `seo`, `scan`. +Free and relevant: `backup`, `monitor`, `ops`, `security`, `seo`, `scan`. Note that `trellis db` currently only has `open` — the obvious `trellis db pull` / `db push` namespace is reserved by core but unimplemented. @@ -228,77 +233,102 @@ fourth database push/pull tool. --- -## Path A: a `trellis-wpops` plugin shim +## Path A: a `trellis-ops` plugin shim -The cheapest distribution win available to this repo. +**Shipped.** The cheapest distribution win available to this repo, and it cost +one config block plus a display-name fix. `wp-ops` is already a single Go binary that runs all 13 Trellis commands. Because `PassthroughCommand` execs an arbitrary binary with the remaining argv, making it a `trellis` subcommand requires **no new code** — just a second name -on `PATH`: +on `PATH`. wp-ops ships as a Homebrew *cask*, generated by goreleaser, so the +symlink is declared in `.goreleaser.yml` rather than edited into the tap: -```ruby -# in the Homebrew formula -bin.install "wp-ops" -bin.install_symlink bin/"wp-ops" => "trellis-wpops" +```yaml +homebrew_casks: + - name: wp-ops + binaries: [wp-ops] + custom_block: | + binary "wp-ops", target: "trellis-ops" ``` +goreleaser's `homebrew_casks` has no field for a second `binary` stanza with a +target, hence `custom_block`. It renders at the top of the cask body rather +than beside the generated `binary "wp-ops"`; cask stanza order is cosmetic and +an unofficial tap isn't `brew audit`ed. + Verified working with the real binary, not a stub: ```console -$ ln -s /path/to/wp-ops /somewhere/on/path/trellis-wpops - $ trellis --help ... Available plugin commands: - wpops + ops -$ trellis wpops # full category listing, as bare wp-ops +$ trellis ops # scoped to @platform trellis wp-ops — WordPress Operations Tools - Monitoring (17) Log monitoring, uptime checks, and traffic analysis - Backup (10) Database and file backups — Ansible and shell - ... -$ trellis wpops backup # category navigation works + Monitoring (11) Log monitoring, uptime checks, and traffic analysis + Backup ( 9) Database and file backups — Ansible and shell + Content ( 2) Block pattern screenshots, page creation, and pattern validation + Security ( 2) Malware scanning, fail2ban, IP blocking, and admin recovery + Misc ( 2) Trellis updater, WooCommerce variations, and one-off utilities + Diagnostics ( 1) WordPress transient and post-count diagnostics + +Run 'trellis ops ' to see a category's commands (e.g. 'trellis ops backup') +Showing @platform trellis commands only — run 'wp-ops' for the full catalog + +$ trellis ops backup # category navigation works Backup Commands: db-backup Back up a remote site's database over SSH... database-pull Pull a site's database from a remote environment... - -$ trellis wpops search backup # subcommands work -10 matches for 'backup': - trellis/backup/database-pull [trellis] Pull a site's database from a remote... +Usage: trellis ops backup [args...] ``` Argv handling needs no changes — Cobra parses `os.Args[1:]`, and -`syscall.Exec` hands it exactly the post-`wpops` arguments. +`syscall.Exec` hands it exactly the post-`ops` arguments. -What this buys: every Trellis user who installs wp-ops discovers it from inside -the tool they already use, and it shows up in `trellis --help` on a machine -where they've forgotten it's installed. What it costs: one symlink line. +**Plugins don't need a Trellis project.** Registration is a `$PATH` walk at +startup (`main.go:232-235`), before trellis-cli resolves a project at all, so +`trellis ops` runs from anywhere while core `trellis info` refuses: -**The one real rough edge.** `go/cmd/root.go:20` sets `Use: "wp-ops"`, so the -help footer instructs users in terms of a command they did not type: +```console +$ cd /tmp && trellis info +No Trellis project detected in the current directory or any of its parent directories. +$ cd /tmp && trellis ops doctor +wp-ops doctor 5.5.0 ``` -Run 'wp-ops ' to see a category's commands (e.g. 'wp-ops backup') -Run 'wp-ops doctor' to check dependencies and environment -``` -Under the alias that should read `trellis wpops `. It is cosmetic — -every command still runs — but it is the difference between a shim that feels -deliberate and one that feels like a leak. Deriving the display name from -`filepath.Base(os.Args[0])` fixes it for both invocation paths at once. +The Ansible commands still need a project, but they always did — wp-ops finds it +through its own `detect.TrellisDir` (`go/internal/detect/detect.go:30`), which +walks up from the cwd exactly like trellis-cli's. Nothing is passed between the +two. + +**The rough edge, now fixed.** `go/cmd/root.go:20` set `Use: "wp-ops"`, so the +help footer instructed users in terms of a command they did not type. Every +usage line, footer, and did-you-mean suggestion now renders against +`filepath.Base(os.Args[0])` (`go/cmd/invoked.go`), which fixes both invocation +paths at once; output under bare `wp-ops` is byte-for-byte unchanged. + +The same argv[0] check scopes the *listing* to `@platform trellis` — 27 commands +across 6 categories rather than all 74. Someone at a `trellis` prompt isn't +looking for the image converters. Execution is deliberately not scoped: name any +command and it runs. -Remaining caveats for the formula comment: +Caveats, all documented in the config comments: -- Name it `trellis-wpops`. `trellis-wp-ops` becomes the two-word `trellis wp ops`. +- Name it `trellis-ops`. `trellis-wp-ops` becomes the three-word `trellis wp ops`. - Gated on `load_plugins`, which defaults true but users can disable. - Requires trellis-cli new enough to have `plugin/`. Confirmed present in v1.19.0. +- `ops` is generic. If Roots ever ships a core `ops` command, the plugin is + silently skipped by `isUnderCoreRootCommands` — no error, no warning. Lower + risk than claiming `backup` or `monitor`, but it is the trade for the better + name. -A narrower alternative is `trellis-backup` → `trellis backup`, exposing only -the backup group. That reads better as a command name, but it claims a generic -namespace that Roots might want later, and it fragments the entry point. Prefer -one vendor-named plugin. +A narrower alternative was `trellis-backup` → `trellis backup`, exposing only +the backup group. It claims a generic namespace Roots is more likely to want, +and it fragments the entry point. One plugin, one door. --- @@ -422,10 +452,9 @@ rather than announcing a suite. ## Recommendation -**1. Ship the `trellis-wpops` symlink now.** One line in the Homebrew formula, -no new code, no new repo, no maintenance surface. Verify the `argv[0]` behavior -and the alias name, then ship it. This is the highest ratio of reach to effort -anywhere in either evaluation document. +**1. ~~Ship the `trellis-wpops` symlink now.~~ Done, as `trellis ops`.** One +`custom_block` in `.goreleaser.yml`, no new repo, no maintenance surface. This +was the highest ratio of reach to effort anywhere in either evaluation document. **2. Then package the monitoring playbooks as a Galaxy role.** Not the backup ones. Backup is a crowded field where we'd be the fourth entrant offering a @@ -454,12 +483,12 @@ requires deciding anything about the WP-CLI package first. ```bash # The plugin mechanism, end to end S=$(mktemp -d) -printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > "$S/trellis-wpopstest" -cp "$S/trellis-wpopstest" "$S/trellis-backup" # free namespace -cp "$S/trellis-wpopstest" "$S/trellis-db-pull" # blocked by core 'db' +printf '#!/usr/bin/env bash\necho "plugin works: $*"\n' > "$S/trellis-demoplugin" +cp "$S/trellis-demoplugin" "$S/trellis-backup" # free namespace +cp "$S/trellis-demoplugin" "$S/trellis-db-pull" # blocked by core 'db' chmod +x "$S"/trellis-* -PATH="$S:$PATH" trellis --help | tail -6 # 'backup' + 'wpopstest' listed; 'db pull' absent +PATH="$S:$PATH" trellis --help | tail -6 # 'backup' + 'demoplugin' listed; 'db pull' absent PATH="$S:$PATH" trellis backup foo # => plugin works: foo PATH="$S:$PATH" trellis db pull # => core 'db' help; plugin never ran From 3704d8d9376f41a252b040fa0f83df0f80de4ae4 Mon Sep 17 00:00:00 2001 From: Jasper Frumau Date: Fri, 21 Aug 2026 09:25:46 +0700 Subject: [PATCH 4/4] chore(release): 5.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New feature, nothing breaking, so a minor bump. The version is read out of CHANGELOG.md's first `## [X.Y.Z]` heading at runtime (go/cmd/version.go getVersion, go/cmd/env.go embeddedVersion), so this heading is the whole bump — there is no second copy to keep in sync. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LEJsmBJKcH4dXK3LUDr6zN --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb1b78..c8d0003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [5.6.0] - 2026-08-21 ### Added