Skip to content
Open
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
131 changes: 131 additions & 0 deletions docs/plans/2026-02-06-external-plugin-discovery-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# External Plugin Discovery - Phase 1 MVP Design

Issue: https://github.com/steipete/gogcli/issues/188

## Overview

Implement cargo/git-style external command discovery allowing `gog foo bar` to execute `gog-foo-bar` binary from PATH.

## Design Decisions

### Decision 1: Post-parse fallback (Option B) vs Pre-parse interception (Option A)

**Chosen: Post-parse fallback (Option B)**

| Aspect | Option A (Pre-parse) | Option B (Post-parse) |
|--------|---------------------|----------------------|
| Performance | Slower on normal commands (PATH check first) | Faster for built-in commands |
| Simplicity | Cleaner - no error parsing needed | Need to detect Kong error types |
| Plugin priority | Plugins can shadow built-ins | Built-ins always take precedence |
| Safety | Less safe - accidental shadowing | Safer |

**Why Option B:**

1. **Safety**: Built-in commands always take precedence - an external binary cannot accidentally or maliciously shadow core functionality
2. **Convention**: Follows git/cargo pattern where built-ins win
3. **Performance**: No PATH scanning for normal command usage (majority of invocations)

### Decision 2: Longest-first (greedy) matching

**Chosen: Longest-first**

When user types `gog docs headings list`, search order:
1. `gog-docs-headings-list` (most specific)
2. `gog-docs-headings`
3. `gog-docs` (least specific)

**Why longest-first:**

1. **Specificity wins**: More specific plugin takes precedence over generic one
2. **Convention**: Matches cargo/git behavior
3. **Composability**: `gog-docs-headings` handles headings, while `gog-docs` could handle generic docs operations - no conflict

### Decision 3: Binary prefix `gog-*`

**Chosen: `gog-` prefix**

Example: `gog docs headings` → `gog-docs-headings`

**Why `gog-` not `gogcli-`:**

1. **Consistency**: Matches the CLI binary name users invoke
2. **Brevity**: Shorter for plugin developers
3. **Convention**: git uses `git-*`, cargo uses `cargo-*` (matches binary name)

## Phase 1 MVP Scope

**Included:**

* PATH discovery + exec
* Longest-first matching
* Pass remaining args to plugin
* Unit tests documenting behavior

**Excluded (Phase 2+):**

* `--help-oneliner` protocol
* Help integration (plugins in `gog --help`)
* Environment variable passing (GOG_AUTH_TOKEN_PATH, etc.)
* Discovery caching

## Implementation

### Files

| File | Purpose |
|------|---------|
| `internal/cmd/external.go` | Discovery + exec logic |
| `internal/cmd/external_test.go` | Unit tests |
| `internal/cmd/root.go` | Integrate into Execute() |

### Core Algorithm

```go
func tryExternalCommand(args []string) error {
// Longest-first: try most specific binary first
// Why: More specific plugins should take precedence (cargo/git pattern)
for i := len(args); i > 0; i-- {
binaryName := "gog-" + strings.Join(args[:i], "-")
if path, err := exec.LookPath(binaryName); err == nil {
return execExternal(path, args[i:])
}
}
return nil // not found
}
```

### Integration Point

In `root.go` `Execute()`, after `parser.Parse(args)` fails:

```go
kctx, err := parser.Parse(args)
if err != nil {
// Try external command before returning parse error
// Why post-parse: Built-in commands always take precedence (safer)
if extErr := tryExternalCommand(args); extErr != nil {
return extErr
}
// Fall through to original error if no external command found
...
}
```

## Commits

1. `feat(plugin): add external command discovery and execution`
2. `test(plugin): add unit tests for external command discovery`
3. `feat(plugin): integrate external commands into Execute()`

## Future Phases

**Phase 2 (separate PRs):**

* `--help-oneliner` protocol for help integration
* Environment variable passing to plugins
* Plugin listing in `gog --help`

**Phase 3:**

* Discovery caching for performance
* Version compatibility checks
243 changes: 243 additions & 0 deletions internal/cmd/external.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package cmd

import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)

// externalCommandPrefix is the prefix for external plugin binaries.
// Uses "gog-" to match the CLI binary name (following git/cargo convention).
const externalCommandPrefix = "gog-"

// ErrExternalNotFound indicates no external command was found for the given args.
var ErrExternalNotFound = errors.New("external command not found")

// tryExternalCommand attempts to find and execute an external plugin binary.
//
// Design: Post-parse fallback (Option B)
// This function is called AFTER Kong parsing fails with "unknown command".
// Why: Built-in commands always take precedence over external plugins.
// This prevents accidental or malicious shadowing of core functionality
// and matches the git/cargo convention.
//
// Algorithm: Longest-first (greedy) matching
// For args ["docs", "headings", "list"], tries in order:
// 1. gog-docs-headings-list (most specific)
// 2. gog-docs-headings
// 3. gog-docs (least specific)
//
// Why longest-first: More specific plugins should win over generic ones.
// Example: gog-docs-headings handles "headings" specifically, while gog-docs
// might handle generic docs operations. The specific plugin takes precedence.
//
// Returns:
// - nil if no external command found (caller should return original error)
// - error from exec if external command found but execution failed
// - does not return if exec succeeds (replaces current process)
func tryExternalCommand(args []string) error {
if len(args) == 0 {
return ErrExternalNotFound
}

path, remainingArgs := findExternalCommand(args)
if path == "" {
return ErrExternalNotFound
}

return execExternal(path, remainingArgs)
}

// findExternalCommand searches PATH for a matching external plugin binary.
// Uses longest-first matching: tries most specific binary name first.
// Returns the path to the binary and remaining arguments to pass to it.
func findExternalCommand(args []string) (binaryPath string, remainingArgs []string) {
// Longest-first: start with all args, progressively try fewer
// Why: More specific plugins take precedence (e.g., gog-docs-headings over gog-docs)
for i := len(args); i > 0; i-- {
binaryName := externalCommandPrefix + strings.Join(args[:i], "-")
if path, err := exec.LookPath(binaryName); err == nil {
return path, args[i:]
}
}
return "", nil
}

// execExternal replaces the current process with the external command.
// Uses syscall.Exec for true process replacement (no child process).
func execExternal(binaryPath string, args []string) error {
// Build argv: binary name followed by remaining arguments
argv := append([]string{binaryPath}, args...)

// Use syscall.Exec to replace current process
// This is the standard pattern for CLI plugin dispatch (git, cargo)
return syscall.Exec(binaryPath, argv, os.Environ())
}

// LookPath is a variable to allow mocking in tests.
// In production, this is exec.LookPath.
var lookPath = exec.LookPath

// findExternalCommandWithLookPath is like findExternalCommand but uses
// the package-level lookPath variable for testability.
func findExternalCommandWithLookPath(args []string) (binaryPath string, remainingArgs []string) {
for i := len(args); i > 0; i-- {
binaryName := externalCommandPrefix + strings.Join(args[:i], "-")
if path, err := lookPath(binaryName); err == nil {
return path, args[i:]
}
}
return "", nil
}

// helpOnelinerTimeout is the maximum time to wait for --help-oneliner response.
// Short timeout to keep help responsive; plugins that don't respond are skipped.
const helpOnelinerTimeout = 100 * time.Millisecond

// ExternalPlugin represents a discovered external plugin binary.
type ExternalPlugin struct {
// Name is the plugin binary name without prefix (e.g., "docs-headings")
Name string
// Path is the full path to the binary
Path string
// Subcommands is the command hierarchy (e.g., ["docs", "headings"])
Subcommands []string
// Oneliner is the short description from --help-oneliner (may be empty)
Oneliner string
}

// CommandName returns the user-facing command (e.g., "docs headings")
func (p *ExternalPlugin) CommandName() string {
return strings.Join(p.Subcommands, " ")
}

// DiscoverExternalPlugins scans PATH for all gog-* binaries.
// Returns deduplicated list sorted by command name.
//
// Discovery happens lazily (only when help is requested) to avoid
// performance impact on normal command execution.
func DiscoverExternalPlugins() []ExternalPlugin {
seen := make(map[string]bool)
var plugins []ExternalPlugin

pathDirs := filepath.SplitList(os.Getenv("PATH"))
for _, dir := range pathDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue // Skip unreadable directories
}

for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, externalCommandPrefix) {
continue
}
if entry.IsDir() {
continue
}

// Check if executable (skip if not)
fullPath := filepath.Join(dir, name)
info, err := entry.Info()
if err != nil {
continue
}
if info.Mode()&0111 == 0 {
continue // Not executable
}

// Extract plugin name (remove prefix)
pluginName := strings.TrimPrefix(name, externalCommandPrefix)
if pluginName == "" {
continue
}

// Deduplicate: first occurrence in PATH wins
// Why: Matches exec.LookPath behavior and user expectations
if seen[pluginName] {
continue
}
seen[pluginName] = true

plugins = append(plugins, ExternalPlugin{
Name: pluginName,
Path: fullPath,
Subcommands: strings.Split(pluginName, "-"),
})
}
}

// Sort by command name for consistent display
sort.Slice(plugins, func(i, j int) bool {
return plugins[i].Name < plugins[j].Name
})

return plugins
}

// FetchOneliners queries each plugin for its --help-oneliner description.
// Uses short timeout to keep help responsive; unresponsive plugins get empty description.
//
// Protocol: Plugins should respond to --help-oneliner with a single line (≤80 chars)
// describing what they do. Exit code 0 indicates success.
func FetchOneliners(plugins []ExternalPlugin) []ExternalPlugin {
result := make([]ExternalPlugin, len(plugins))
copy(result, plugins)

for i := range result {
result[i].Oneliner = fetchOneliner(result[i].Path)
}

return result
}

// fetchOneliner invokes a plugin with --help-oneliner and returns the response.
// Returns empty string on timeout, error, or non-zero exit code.
func fetchOneliner(binaryPath string) string {
ctx, cancel := context.WithTimeout(context.Background(), helpOnelinerTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, binaryPath, "--help-oneliner")
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = nil // Ignore stderr

if err := cmd.Run(); err != nil {
return "" // Timeout, not found, or non-zero exit
}

// Take first line only, trim whitespace
oneliner := strings.TrimSpace(stdout.String())
if idx := strings.IndexByte(oneliner, '\n'); idx >= 0 {
oneliner = oneliner[:idx]
}

// Truncate to 80 chars for display
if len(oneliner) > 80 {
oneliner = oneliner[:77] + "..."
}

return oneliner
}

// GroupPluginsByTopLevel groups plugins by their first subcommand.
// Used to display plugins under their parent command in help output.
// Example: gog-docs-headings and gog-docs-bookmarks both under "docs"
func GroupPluginsByTopLevel(plugins []ExternalPlugin) map[string][]ExternalPlugin {
groups := make(map[string][]ExternalPlugin)
for _, p := range plugins {
if len(p.Subcommands) == 0 {
continue
}
topLevel := p.Subcommands[0]
groups[topLevel] = append(groups[topLevel], p)
}
return groups
}
Loading