Skip to content
Merged
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
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