Skip to content
440 changes: 440 additions & 0 deletions EXTENDING.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ stackctl template quick-deploy 1
stackctl stack list --mine
```

## Extending — add your own subcommands

Drop an executable named `stackctl-<name>` anywhere on your `$PATH` and it becomes `stackctl <name>` automatically. No plugin SDK, no recompile, no changes to stackctl itself. Same pattern as `git`, `kubectl`, and `gh`.

Because the contract is "any executable with the right name", plugins can be written in any language (shell, Python, Go, Node, Rust, …) and shipped however your team already distributes binaries.

Quick example:

```bash
cat > ~/.local/bin/stackctl-hello <<'EOF'
#!/usr/bin/env bash
echo "Hello! API=${STACKCTL_API_URL} args=$*"
EOF
chmod +x ~/.local/bin/stackctl-hello

stackctl hello world # → Hello! API=http://... args=world
stackctl --help | grep hello
# hello Plugin: hello
```

The plugin inherits the user's full environment. If `STACKCTL_API_URL` and `STACKCTL_API_KEY` are exported in your shell, the plugin sees them. Values saved with `stackctl config set` are **not** automatically exported — export them yourself (`export STACKCTL_API_URL="$(stackctl config get api-url)"`) or see [EXTENDING.md](EXTENDING.md) for the full story. Built-in subcommands always win on name collisions (a safety feature — a malicious `stackctl-config` on PATH can't intercept credentials).

👉 **[Full guide: EXTENDING.md](EXTENDING.md)** — tutorial, recipes in bash/Python/Go, best practices, and how plugins pair with [server-side action webhooks](https://github.com/omattsson/k8s-stack-manager/blob/main/EXTENDING.md) for end-to-end custom operations.

## Configuration

stackctl uses named contexts to manage multiple environments. Configuration is stored in `~/.stackmanager/config.yaml`.
Expand Down
214 changes: 214 additions & 0 deletions cli/cmd/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package cmd

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"

"github.com/spf13/cobra"
)

// pluginPrefix is the filename prefix that marks an executable as a stackctl
// plugin. A binary at PATH/stackctl-foo becomes the subcommand `stackctl foo`.
const pluginPrefix = "stackctl-"

// pluginNamePattern restricts plugin names to lowercase ASCII letters, digits,
// and dashes, and requires the first character to be a letter or digit so names
// form valid Cobra command names. A name like "stackctl- bad" (with whitespace)
// or "stackctl--help" breaks help routing and is skipped at discovery time.
// Uppercase filenames (stackctl-Foo) are skipped — Cobra is case-sensitive and
// mixed-case subcommands are a footgun more than a feature.
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)

// registerPlugins scans $PATH for executables named stackctl-<name> and adds
// each as a top-level subcommand that proxies to the external binary.
//
// Behaviour:
// - Built-in subcommands always win on name collision; the plugin is skipped.
// - Earlier entries in $PATH win over later duplicates (standard PATH semantics).
// - Non-regular files, directories, and non-executables are ignored.
// - Plugin stdout/stderr/stdin pass through the parent tty; exit codes propagate.
//
// Pattern modelled on git, kubectl, and gh. See also:
//
// https://git-scm.com/docs/git#_git_commands
// https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/
func registerPlugins(root *cobra.Command, pathEnv string) {
builtins := existingCommandNames(root)
// Cobra registers "help" and "completion" implicitly — make sure a
// stackctl-help or stackctl-completion on PATH can't shadow them.
builtins["help"] = struct{}{}
builtins["completion"] = struct{}{}
for name, path := range discoverPlugins(pathEnv) {
if _, collides := builtins[name]; collides {
continue
}
root.AddCommand(newPluginCommand(name, path))
builtins[name] = struct{}{}
}
Comment on lines +40 to +52
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin collision protection currently only considers commands already present in root.Commands(). Since stackctl doesn’t explicitly register Cobra’s default help command before calling registerPlugins(), a stackctl-help executable on PATH can be registered as stackctl help, effectively shadowing the built-in help UX. Consider reserving names like help (and potentially other Cobra-reserved commands) or explicitly initializing the default help command before computing existingCommandNames() so plugins cannot override it.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +52
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerPlugins iterates over the map returned by discoverPlugins; Go map iteration order is randomized, so the order plugins are added (and thus shown in stackctl --help) will be nondeterministic across runs. To keep help output stable, collect plugin names into a slice, sort it, then add commands in sorted order.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +52
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registerPlugins iterates over a map returned by discoverPlugins. Go map iteration order is randomized, so the order that plugin subcommands are added (and therefore the order they appear in stackctl --help) will be nondeterministic between runs. Consider collecting plugin names, sorting them, and then adding commands in sorted order for stable help output.

Copilot uses AI. Check for mistakes.
}

// discoverPlugins walks pathEnv (the process $PATH) and returns a map of
// plugin name to absolute path. First-win semantics: earlier PATH entries
// take precedence over later ones when multiple binaries share a name.
//
// Paths are resolved to absolute via filepath.Abs so execution is safe
// regardless of relative entries in $PATH — the captured path is what we
// later exec, which means rebinding $PATH after this function runs cannot
// change which binary a plugin routes to.
//
// Windows: discovery strips a trailing .exe so stackctl-foo.exe surfaces as
// the subcommand `foo`. Other PATHEXT extensions (.bat, .cmd, .ps1) are not
// currently recognised — if you need them, name the plugin binary .exe or
// front it with a .exe shim.
func discoverPlugins(pathEnv string) map[string]string {
found := make(map[string]string)
for _, dir := range filepath.SplitList(pathEnv) {
if dir == "" {
continue
}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, pluginPrefix) || name == pluginPrefix {
continue
}
pluginName := strings.TrimPrefix(name, pluginPrefix)
if runtime.GOOS == "windows" {
if low := strings.ToLower(pluginName); strings.HasSuffix(low, ".exe") {
pluginName = pluginName[:len(pluginName)-4]
}
}
if !pluginNamePattern.MatchString(pluginName) {
continue
}
Comment on lines +83 to +91
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, trimming the .exe suffix is case-sensitive (strings.TrimSuffix(pluginName, ".exe")), so a plugin named stackctl-foo.EXE (valid on Windows) will not be discovered because the suffix isn't removed and the regex check will fail. Consider normalizing the filename case for the extension check or using a case-insensitive .exe match.

Copilot uses AI. Check for mistakes.
if _, seen := found[pluginName]; seen {
Comment on lines +79 to +92
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discoverPlugins accepts any suffix after stackctl- as a Cobra command name. If the filename contains whitespace or starts with - (e.g. stackctl--help), Cobra parsing/help output can break or route unexpectedly. Consider validating pluginName against the documented allowed set (letters/digits/dashes) and skipping invalid names.

Copilot uses AI. Check for mistakes.
continue
}
full, err := filepath.Abs(filepath.Join(dir, name))
if err != nil {
continue
}
info, err := os.Stat(full)
if err != nil || !info.Mode().IsRegular() {
continue
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
continue
}
Comment on lines +83 to +105
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, this will accept stackctl-foo files without a .exe suffix as plugins (since only the name is stripped), but exec.Command typically can't run extensionless files. This can lead to registering non-runnable plugins and confusing runtime errors. Consider requiring .exe on Windows (or using exec.LookPath/PATHEXT-aware resolution) and skipping entries that aren't actually runnable.

Copilot uses AI. Check for mistakes.
found[pluginName] = full
}
}
return found
}

// pluginEnv returns the environment to pass to a plugin subprocess. It
// preserves the full parent environment — plugins might legitimately need
// unrelated variables (AWS_PROFILE, KUBECONFIG, etc.) — and injects
// STACKCTL_* values resolved from flags so a plugin sees the same effective
// config stackctl itself would use for a built-in command.
//
// Precedence: an explicitly-passed flag wins over a pre-existing env var.
// Flags that weren't set on the command line leave the inherited env
// untouched, so STACKCTL_INSECURE=1 in the parent shell keeps working for
// plugin invocations when no --insecure flag is passed.
//
// Flag-to-env wiring documented in EXTENDING.md as a plugin-author contract.
func pluginEnv(cmd *cobra.Command) []string {
env := os.Environ()
if cmd == nil {
return env
}
flags := cmd.Root().PersistentFlags()
if flags.Changed("insecure") {
if insecure, err := flags.GetBool("insecure"); err == nil {
env = setEnv(env, "STACKCTL_INSECURE", boolEnvValue(insecure))
}
Comment on lines +124 to +133
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluginEnv only appends STACKCTL_INSECURE=1 when the flag is true. If the user already has STACKCTL_INSECURE=1 in their environment and explicitly runs with --insecure=false, the plugin will still see insecure mode enabled (because the original env var is preserved). If the intent is "flag > env", consider checking whether the flag was explicitly set (PersistentFlags().Changed) and then forcing STACKCTL_INSECURE=0/1 accordingly.

Copilot uses AI. Check for mistakes.
}
if flags.Changed("quiet") {
if quiet, err := flags.GetBool("quiet"); err == nil {
env = setEnv(env, "STACKCTL_QUIET", boolEnvValue(quiet))
}
}
if flags.Changed("output") {
if output, err := flags.GetString("output"); err == nil {
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STACKCTL_OUTPUT is exported to plugins using the raw --output flag value. Since stackctl itself normalizes output names case-insensitively (and trims whitespace) in output.NewPrinter, a user running --output JSON will get JSON output but plugins will see STACKCTL_OUTPUT=JSON, which is inconsistent. Normalize the value before exporting (lowercase + trim) so plugins see the effective format key stackctl will use.

Suggested change
if output, err := flags.GetString("output"); err == nil {
if output, err := flags.GetString("output"); err == nil {
output = strings.ToLower(strings.TrimSpace(output))

Copilot uses AI. Check for mistakes.
env = setEnv(env, "STACKCTL_OUTPUT", strings.ToLower(strings.TrimSpace(output)))
}
}
return env
Comment on lines +124 to +145
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluginEnv only injects STACKCTL_QUIET/STACKCTL_OUTPUT when the corresponding persistent flags were explicitly set (Flags().Changed). This means plugins may (a) see no STACKCTL_OUTPUT at all when the user relies on the default "table", and (b) accidentally inherit STACKCTL_QUIET/STACKCTL_OUTPUT from the parent shell even when the user did not request quiet/custom output for this invocation—diverging from stackctl’s own flag behavior. Consider always setting STACKCTL_QUIET and STACKCTL_OUTPUT from the resolved flag values for each invocation (and only preserving an existing env var for STACKCTL_INSECURE if that’s an intentional compatibility feature).

Copilot uses AI. Check for mistakes.
}

// setEnv replaces (or appends) KEY=value in env, preserving order.
func setEnv(env []string, key, value string) []string {
prefix := key + "="
for i, kv := range env {
if strings.HasPrefix(kv, prefix) {
env[i] = prefix + value
return env
}
}
return append(env, prefix+value)
}

func boolEnvValue(b bool) string {
if b {
return "1"
}
return "0"
}

// existingCommandNames returns the set of top-level subcommand names already
// registered on root, so discovery can avoid clobbering built-ins.
func existingCommandNames(root *cobra.Command) map[string]struct{} {
names := make(map[string]struct{})
for _, c := range root.Commands() {
names[c.Name()] = struct{}{}
for _, alias := range c.Aliases {
names[alias] = struct{}{}
}
}
return names
}

// newPluginCommand wraps an external binary as a Cobra subcommand. The binary
// receives all arguments after the plugin name; stdin/stdout/stderr pass
// through directly, and the exit code propagates to the caller.
//
// binaryPath is captured at discovery time so later PATH modifications can't
// rebind which binary we exec — the absolute path we resolved in
// discoverPlugins is the exact path we run.
func newPluginCommand(name, binaryPath string) *cobra.Command {
return &cobra.Command{
// Hide the absolute path from --help listings (leaks home dir in screenshots).
// The full path is in Long so `stackctl help <plugin>` still reveals it for debugging.
Use: name,
Short: "Plugin: " + name,
Long: "External plugin resolved to " + binaryPath,
DisableFlagParsing: true,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
proc := exec.Command(binaryPath, args...) //nolint:gosec // absolute path captured at discovery time; rebinding via PATH is impossible after that point
proc.Stdin = cmd.InOrStdin()
proc.Stdout = cmd.OutOrStdout()
proc.Stderr = cmd.ErrOrStderr()
proc.Env = pluginEnv(cmd)
if err := proc.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Propagate the plugin's exit code. Cobra will surface
// the error; explicit os.Exit keeps the code intact.
os.Exit(exitErr.ExitCode())
}
Comment on lines +203 to +208
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The os.Exit(exitErr.ExitCode()) path (exit-code propagation) isn't covered by tests right now, but it’s a key part of the plugin contract. Consider adding a test that runs a failing plugin via a subprocess (e.g., invoking a small helper test binary) and asserts stackctl exits with the plugin’s exact exit code.

Copilot uses AI. Check for mistakes.
return fmt.Errorf("plugin %q: %w", name, err)
}
return nil
},
}
}
Loading
Loading