Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b9a4cf5
Add kubectl-style plugin dispatch for entire-<name> binaries
Soph May 4, 2026
e6fce1c
Track plugin invocations for an allowlisted set only
Soph May 4, 2026
606f5d8
Add integration coverage for the early-dispatch path in main.go
Soph May 4, 2026
7761c45
Simplify plugin dispatch: dedupe, trim narrative comments, tighten tests
Soph May 4, 2026
b63670f
Surface non-executable plugins as launch errors instead of falling th…
Soph May 4, 2026
568f18f
Run version-check post-hook for plugin invocations
Soph May 4, 2026
94384d5
Gate plugin post-hooks on a successful (exit-0) run
Soph May 4, 2026
a06fe44
Document plugin dispatch architecture
Soph May 4, 2026
cc1a6b8
Correct agent-discovery row in plugin/agent comparison table
Soph May 4, 2026
ca83470
Assert ENTIRE_REPO_ROOT propagation in plugin env-vars test
Soph May 4, 2026
6fc6a46
Rename "plugin dispatch" → "external commands" to avoid collision
Soph May 4, 2026
e9b1392
Use execx.NonInteractive for integration test subprocesses
Soph May 4, 2026
700abdb
Add managed plugin install dir and ENTIRE_PLUGIN_DATA_DIR
ashtom May 5, 2026
7e1451d
Address Copilot/Cursor review feedback on plugin manager
ashtom May 5, 2026
6aab5ce
Address second-round Copilot feedback
ashtom May 5, 2026
8ac8bc5
Address third-round Copilot feedback
ashtom May 6, 2026
48391f9
Address fourth-round Copilot feedback
ashtom May 6, 2026
108065b
Reuse external.StripExeExt for plugin extension stripping
ashtom May 6, 2026
9fa42ce
Fix for Copilot finding
ashtom May 6, 2026
b8ef07e
Address Cursor Bugbot findings on plugin store
ashtom May 6, 2026
2afff28
Address Cursor Bugbot follow-up findings
ashtom May 6, 2026
6118494
Add .com to StripExeExt
ashtom May 6, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This repo contains the CLI for Entire.

### Commands (`cmd/`)

- `entire/`: Main CLI entry point
- `entire/`: Main CLI entry point. Also home to kubectl-style external-command resolution (`entire <name>` → `entire-<name>` on PATH) — see [External Commands](docs/architecture/external-commands.md).
- `entire/cli`: CLI utilities and helpers (Cobra commands, helpers, group roots)
- `entire/cli/commands`: actual command implementations
- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md)
Expand Down
6 changes: 3 additions & 3 deletions cmd/entire/cli/agent/external/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func discoverAndRegister(ctx context.Context) {

// Strip Windows executable extensions (.exe, .bat) before deriving agent name.
// On Unix, binaries have no extension, so this is a no-op.
cleanName := stripExeExt(name)
cleanName := StripExeExt(name)
agentName := types.AgentName(strings.TrimPrefix(cleanName, binaryPrefix))
if registered[agentName] {
logging.Debug(ctx, "skipping external agent (name conflict with built-in)",
Expand Down Expand Up @@ -131,10 +131,10 @@ func discoverAndRegister(ctx context.Context) {
}
}

// stripExeExt removes Windows executable extensions (.exe, .bat, .cmd) from a
// StripExeExt removes Windows executable extensions (.exe, .bat, .cmd) from a
// file name so that the agent name derived from the binary matches on all platforms.
// On Unix this is effectively a no-op because binaries have no extension.
func stripExeExt(name string) string {
func StripExeExt(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".exe", ".bat", ".cmd":
return strings.TrimSuffix(name, filepath.Ext(name))
Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/agent/external/external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,8 +876,8 @@ func TestStripExeExt(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripExeExt(tt.in); got != tt.want {
t.Errorf("stripExeExt(%q) = %q, want %q", tt.in, got, tt.want)
if got := StripExeExt(tt.in); got != tt.want {
t.Errorf("StripExeExt(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:build integration && !windows

package integration

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/execx"
)

// SIGINT to the parent must reach the plugin so it can clean up — not
// just be SIGKILL'd by the runtime. Guards both signal paths: terminal
// (via process group) and parent's context-cancel handler.
func TestExternalCommand_SigintReachesPlugin(t *testing.T) {
t.Parallel()
dir := t.TempDir()
signalFile := filepath.Join(dir, "got-sigint.txt")

// The plugin loops longer than the parent's WaitDelay+grace so that if
// the signal path were broken, the parent would SIGKILL the child and
// the marker would never be written. Ready-marker handshake avoids
// racing SIGINT against shell startup before the trap is installed.
readyFile := filepath.Join(dir, "ready.txt")
const pluginLoopSeconds = 10 // > parent WaitDelay (5s) + grace
body := fmt.Sprintf(
"#!/bin/sh\ntrap 'echo trapped > %q; exit 130' INT\n"+
"echo ready > %q\n"+
"i=0\nwhile [ $i -lt %d ]; do sleep 0.1; i=$((i+1)); done\nexit 0\n",
signalFile, readyFile, pluginLoopSeconds*10,
)
if err := os.WriteFile(filepath.Join(dir, "entire-trapint"), []byte(body), 0o755); err != nil { //nolint:gosec // test fixture
t.Fatalf("write plugin: %v", err)
}

cmd := execx.NonInteractive(context.Background(), getTestBinary(), "trapint")
cmd.Env = pathWith(dir)
var pStderr bytes.Buffer
cmd.Stdout = &bytes.Buffer{}
cmd.Stderr = &pStderr

if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}

if !waitForFile(readyFile, 3*time.Second) {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Fatalf("plugin never reached ready state\nparent stderr:\n%s", pStderr.String())
}

if err := cmd.Process.Signal(os.Interrupt); err != nil {
t.Fatalf("signal parent: %v", err)
}

if !waitForFile(signalFile, 5*time.Second) {
_ = cmd.Wait()
t.Fatalf("plugin never observed SIGINT — marker missing\nparent stderr:\n%s", pStderr.String())
}
_ = cmd.Wait()

contents, err := os.ReadFile(signalFile)
if err != nil {
t.Fatalf("read marker: %v", err)
}
if got := strings.TrimSpace(string(contents)); got != "trapped" {
t.Errorf("marker = %q, want %q", got, "trapped")
}
}

func waitForFile(path string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return true
}
time.Sleep(20 * time.Millisecond)
}
return false
}
Loading
Loading