Skip to content

Plugin Extension System Specification

Authors
Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
Date
21 March 2026
Status
DRAFT

Overview

Users cannot extend GTB-based tools with custom commands without forking the project. A plugin system allows users to drop scripts or binaries into a well-known directory and have them automatically discovered and registered as subcommands. This follows the pattern established by tools like git (any git-foo on PATH becomes git foo), kubectl plugins, and Homebrew taps.


Design Decisions

Script-based, not Go plugins: Go's plugin package has severe limitations (Linux/macOS only, exact Go version match, no unloading). Script-based plugins work with any language, are simpler to author, and match user expectations from kubectl/git patterns.

Manifest file for metadata: Each plugin provides a plugin.yaml manifest declaring its name, description, version, and argument schema. Without a manifest, the plugin is still discovered but gets minimal help text.

Subprocess execution: Plugins run as subprocesses. GTB passes arguments via command-line args and environment variables (config values, tool metadata). Plugin stdout/stderr are captured and relayed to the user.

Feature flag gated: Plugin loading is controlled by a PluginsCmd feature flag, disabled by default until the system is stable.

Security boundary: Plugins execute with the user's permissions. GTB does not sandbox them. A warning is logged on first plugin discovery. Plugins from untrusted sources are the user's responsibility.


Public API Changes

New Feature Flag

// In pkg/props/tool.go
const PluginsCmd FeatureCmd = "plugins"

New Package: pkg/plugins

// Plugin represents a discovered plugin.
type Plugin struct {
    Name        string
    Description string
    Version     string
    Path        string   // absolute path to the executable
    Args        []Arg    // declared arguments from manifest
}

// Arg describes a plugin argument.
type Arg struct {
    Name        string
    Description string
    Required    bool
    Default     string
}

// Manifest is the plugin.yaml schema.
type Manifest struct {
    Name        string `yaml:"name"`
    Description string `yaml:"description"`
    Version     string `yaml:"version"`
    Args        []Arg  `yaml:"args"`
}

// Discoverer finds and loads plugins from the plugins directory.
type Discoverer interface {
    Discover() ([]Plugin, error)
}

// Executor runs a plugin as a subprocess.
type Executor interface {
    Execute(ctx context.Context, plugin Plugin, args []string) error
}

Internal Implementation

Plugin Directory Structure

~/.toolname/plugins/
โ”œโ”€โ”€ my-plugin/
โ”‚   โ”œโ”€โ”€ plugin.yaml       # manifest (optional but recommended)
โ”‚   โ””โ”€โ”€ run.sh            # executable (must be chmod +x)
โ”œโ”€โ”€ another-plugin/
โ”‚   โ”œโ”€โ”€ plugin.yaml
โ”‚   โ””โ”€โ”€ main.py
โ””โ”€โ”€ simple-script         # single-file plugin (no manifest)

Plugin Discovery

type fsDiscoverer struct {
    fs         afero.Fs
    pluginsDir string
}

func (d *fsDiscoverer) Discover() ([]Plugin, error) {
    entries, err := afero.ReadDir(d.fs, d.pluginsDir)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, nil // no plugins dir is fine
        }
        return nil, errors.Wrap(err, "reading plugins directory")
    }

    var plugins []Plugin
    for _, entry := range entries {
        plugin, err := d.loadPlugin(entry)
        if err != nil {
            // Log warning but continue โ€” one bad plugin shouldn't break others
            continue
        }
        plugins = append(plugins, plugin)
    }
    return plugins, nil
}

func (d *fsDiscoverer) loadPlugin(entry os.FileInfo) (Plugin, error) {
    pluginPath := filepath.Join(d.pluginsDir, entry.Name())

    if entry.IsDir() {
        return d.loadDirectoryPlugin(pluginPath, entry.Name())
    }
    return d.loadFilePlugin(pluginPath, entry.Name())
}

Manifest Loading

func (d *fsDiscoverer) loadManifest(dir string) (*Manifest, error) {
    manifestPath := filepath.Join(dir, "plugin.yaml")
    data, err := afero.ReadFile(d.fs, manifestPath)
    if err != nil {
        return nil, err // manifest is optional
    }

    var m Manifest
    if err := yaml.Unmarshal(data, &m); err != nil {
        return nil, errors.Wrap(err, "parsing plugin manifest")
    }
    return &m, nil
}

Plugin Execution

type subprocessExecutor struct {
    logger *slog.Logger
    env    []string
}

func (e *subprocessExecutor) Execute(ctx context.Context, plugin Plugin, args []string) error {
    cmd := exec.CommandContext(ctx, plugin.Path, args...)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Env = append(os.Environ(), e.env...)

    e.logger.Info("executing plugin", "name", plugin.Name, "path", plugin.Path)

    if err := cmd.Run(); err != nil {
        var exitErr *exec.ExitError
        if errors.As(err, &exitErr) {
            return errors.Newf("plugin %q exited with code %d", plugin.Name, exitErr.ExitCode())
        }
        return errors.Wrapf(err, "executing plugin %q", plugin.Name)
    }
    return nil
}

Environment Variables Passed to Plugins

Variable Description
GTB_TOOL_NAME Name of the parent tool
GTB_TOOL_VERSION Version of the parent tool
GTB_CONFIG_DIR Path to the tool's config directory
GTB_PLUGINS_DIR Path to the plugins directory
GTB_VERBOSE "true" if verbose mode is active

Cobra Command Registration

func RegisterPlugins(rootCmd *cobra.Command, props *p.Props) {
    if props.Tool.IsDisabled(p.PluginsCmd) {
        return
    }

    discoverer := NewFSDiscoverer(props.FS, pluginsDir(props))
    plugins, err := discoverer.Discover()
    if err != nil {
        props.Logger.Warn("failed to discover plugins", "error", err)
        return
    }

    executor := NewSubprocessExecutor(props.Logger, buildPluginEnv(props))

    for _, plugin := range plugins {
        cmd := &cobra.Command{
            Use:   plugin.Name,
            Short: plugin.Description,
            RunE: func(cmd *cobra.Command, args []string) error {
                return executor.Execute(cmd.Context(), plugin, args)
            },
        }
        rootCmd.AddCommand(cmd)
    }
}

Validation

func (d *fsDiscoverer) validatePlugin(plugin Plugin) error {
    // Check executable exists
    info, err := d.fs.Stat(plugin.Path)
    if err != nil {
        return errors.Wrap(err, "plugin executable not found")
    }

    // Check executable permission (Unix only)
    if info.Mode()&0111 == 0 {
        return errors.Newf("plugin %q is not executable: %s", plugin.Name, plugin.Path)
    }

    // Check name doesn't conflict with built-in commands
    // (handled at registration time by Cobra)

    return nil
}

Project Structure

pkg/plugins/
โ”œโ”€โ”€ plugins.go         โ† NEW: Plugin, Manifest, Discoverer, Executor types
โ”œโ”€โ”€ discoverer.go      โ† NEW: fsDiscoverer implementation
โ”œโ”€โ”€ executor.go        โ† NEW: subprocessExecutor implementation
โ”œโ”€โ”€ plugins_test.go    โ† NEW: discovery and execution tests
pkg/props/
โ”œโ”€โ”€ tool.go            โ† MODIFIED: add PluginsCmd feature flag
pkg/cmd/root/
โ”œโ”€โ”€ root.go            โ† MODIFIED: call RegisterPlugins

Testing Strategy

Test Scenario
TestDiscover_EmptyDir No plugins directory โ†’ empty list, no error
TestDiscover_SinglePlugin One plugin with manifest โ†’ correctly loaded
TestDiscover_NoManifest Plugin without manifest โ†’ discovered with name from directory
TestDiscover_InvalidManifest Malformed YAML โ†’ plugin skipped with warning
TestDiscover_NotExecutable Non-executable file โ†’ plugin skipped with warning
TestDiscover_MultiplePlugins Three plugins โ†’ all discovered
TestExecute_Success Plugin script exits 0 โ†’ no error
TestExecute_NonZeroExit Plugin exits with code 1 โ†’ error with exit code
TestExecute_ContextCancelled Context cancelled โ†’ plugin killed
TestExecute_Environment Plugin receives expected env vars
TestRegisterPlugins_Disabled Feature flag off โ†’ no plugins registered
TestRegisterPlugins_ConflictingName Plugin name matches built-in โ†’ Cobra handles conflict

Test Plugin Script

func createTestPlugin(t *testing.T, fs afero.Fs, dir, name, content string) {
    t.Helper()
    pluginDir := filepath.Join(dir, name)
    fs.MkdirAll(pluginDir, 0755)
    afero.WriteFile(fs, filepath.Join(pluginDir, "plugin.yaml"), []byte(fmt.Sprintf(
        "name: %s\ndescription: test plugin\nversion: 1.0.0\n", name)), 0644)
    scriptPath := filepath.Join(pluginDir, "run.sh")
    afero.WriteFile(fs, scriptPath, []byte(content), 0755)
}

Coverage

  • Target: 90%+ for pkg/plugins/.

Linting

  • golangci-lint run --fix must pass.
  • No new nolint directives.

Documentation

  • Godoc for all exported types in pkg/plugins/.
  • User-facing documentation in docs/components/plugins.md:
  • How to create a plugin
  • Plugin directory structure
  • Manifest format reference
  • Environment variables available to plugins
  • Security considerations
  • Update docs/components/features.md with PluginsCmd feature flag.

Backwards Compatibility

  • No breaking changes. Plugins are disabled by default via feature flag.
  • Tools without a plugins directory are unaffected.
  • No existing commands are modified.

Future Considerations

  • Plugin marketplace: A plugin install command that downloads plugins from a registry (GitHub releases, custom registry).
  • Plugin versioning: Semantic version constraints in the manifest for compatibility with parent tool versions.
  • Structured I/O: JSON-over-stdin/stdout protocol for richer plugin interactions (like VS Code extensions).
  • Plugin hooks: Allow plugins to register as pre/post hooks for built-in commands.
  • Go plugin support: If Go's plugin package improves, compiled Go plugins could offer better performance and type safety.

Implementation Phases

Phase 1 โ€” Core Types

  1. Create pkg/plugins/ package with types
  2. Add PluginsCmd feature flag
  3. Implement Discoverer interface and fsDiscoverer

Phase 2 โ€” Execution

  1. Implement subprocessExecutor
  2. Define environment variable contract
  3. Add Cobra command registration

Phase 3 โ€” Integration

  1. Wire RegisterPlugins into root command
  2. Feature flag gating
  3. Warning on first plugin discovery

Phase 4 โ€” Tests & Documentation

  1. Add discovery tests with afero
  2. Add execution tests with test scripts
  3. Write user-facing documentation

Verification

go build ./...
go test -race ./pkg/plugins/...
go test ./...
golangci-lint run --fix

# Manual verification
mkdir -p ~/.toolname/plugins/hello
echo '#!/bin/bash\necho "Hello from plugin!"' > ~/.toolname/plugins/hello/run.sh
chmod +x ~/.toolname/plugins/hello/run.sh
echo 'name: hello\ndescription: Says hello\nversion: 1.0.0' > ~/.toolname/plugins/hello/plugin.yaml

# Enable plugins feature and run
go run . hello  # should print "Hello from plugin!"