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¶
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 --fixmust pass.- No new
nolintdirectives.
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.mdwithPluginsCmdfeature 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 installcommand 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¶
- Create
pkg/plugins/package with types - Add
PluginsCmdfeature flag - Implement
Discovererinterface andfsDiscoverer
Phase 2 โ Execution¶
- Implement
subprocessExecutor - Define environment variable contract
- Add Cobra command registration
Phase 3 โ Integration¶
- Wire
RegisterPluginsinto root command - Feature flag gating
- Warning on first plugin discovery
Phase 4 โ Tests & Documentation¶
- Add discovery tests with afero
- Add execution tests with test scripts
- 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!"