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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This repo contains the CLI for Entire.
- `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)
- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md)
- `entire/cli/strategy`: strategy implementation (manual-commit) - see section below
- `entire/cli/checkpoint`: checkpoint storage abstractions (temporary and committed)
- `entire/cli/session`: session state management
Expand Down Expand Up @@ -115,16 +115,17 @@ mise run test:e2e --agent opencode [filter] # OpenCode only
mise run test:e2e --agent cursor [filter] # Cursor only
mise run test:e2e --agent factoryai-droid [filter] # Factory AI Droid only
mise run test:e2e --agent copilot-cli [filter] # Copilot CLI only
mise run test:e2e --agent pi [filter] # Pi only
```

E2E tests:

- Use the `//go:build e2e` build tag
- Located in `e2e/tests/`
- See [`e2e/README.md`](e2e/README.md) for full documentation (structure, debugging, adding agents)
- Test real agent interactions (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, or Vogon creating files, committing, etc.)
- Test real agent interactions (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi, or Vogon creating files, committing, etc.)
- Validate checkpoint scenarios documented in `docs/architecture/checkpoint-scenarios.md`
- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`, `cursor`, `factoryai-droid`, `copilot-cli`, `vogon`)
- Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`, `cursor`, `factoryai-droid`, `copilot-cli`, `pi`, `vogon`)

**Environment variables:**

Expand Down
73 changes: 73 additions & 0 deletions cmd/entire/cli/agent/pi/entire_extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Entire CLI extension for Pi
// Auto-generated by `entire enable --agent pi`
// Do not edit manually — changes will be overwritten on next install.
//
// Forwards Pi lifecycle events to `entire hooks pi <event>` so Entire can
// create checkpoints, capture transcripts, and offer rewind/resume.
//
// ENTIRE_CMD is replaced at install time by Entire's installer.

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execFile } from "node:child_process";

export default function (pi: ExtensionAPI) {
const ENTIRE_CMD = "__ENTIRE_CMD__";

function fireHook(hookName: string, data: Record<string, unknown>): Promise<void> {
return new Promise((resolve) => {
try {
const child = execFile(
"sh",
["-c", `${ENTIRE_CMD} hooks pi ${hookName}`],
{ timeout: 10000, windowsHide: true },
() => resolve(),
);
child.stdin?.end(JSON.stringify(data));
} catch {
// best effort — never block the agent on a hook failure
resolve();
}
});
}

// Agent-driven bash subprocesses inherit a real TTY but cannot answer
// hook prompts. Disable git/Entire terminal prompts for bash calls so
// Entire treats agent-driven commits as non-interactive.
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
const input = event.input as { command?: string };
if (typeof input.command !== "string" || input.command.includes("GIT_TERMINAL_PROMPT=")) {
return;
}
input.command = "export GIT_TERMINAL_PROMPT=0\n" + input.command;
});

pi.on("session_start", async (_event, ctx) => {
await fireHook("session_start", {
type: "session_start",
cwd: ctx.cwd,
session_file: ctx.sessionManager.getSessionFile(),
});
});

pi.on("before_agent_start", async (event, ctx) => {
await fireHook("before_agent_start", {
type: "before_agent_start",
cwd: ctx.cwd,
session_file: ctx.sessionManager.getSessionFile(),
prompt: event.prompt,
});
});

pi.on("agent_end", async (_event, ctx) => {
await fireHook("agent_end", {
type: "agent_end",
cwd: ctx.cwd,
session_file: ctx.sessionManager.getSessionFile(),
});
});

pi.on("session_shutdown", async () => {
await fireHook("session_shutdown", { type: "session_shutdown" });
});
}
124 changes: 124 additions & 0 deletions cmd/entire/cli/agent/pi/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package pi

import (
"context"
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

// Compile-time interface assertion
var _ agent.HookSupport = (*PiAgent)(nil)

//go:embed entire_extension.ts
var extensionTemplate string

const (
// extensionDirName is the directory pi auto-discovers project-local
// extensions from.
extensionDirName = ".pi/extensions/entire"

// extensionFileName is the file pi loads from extensionDirName.
extensionFileName = "index.ts"

// entireMarker identifies the file as Entire-owned. Substring of the
// auto-generated header so AreHooksInstalled can verify ownership by
// content (and so it survives the ENTIRE_CMD placeholder substitution).
entireMarker = "Auto-generated by `entire enable --agent pi`"

// entireCmdPlaceholder is replaced at install time with either `entire`
// (production) or a `go run …` path (local-dev).
entireCmdPlaceholder = "__ENTIRE_CMD__"
)

func extensionPath(ctx context.Context) (string, error) {
root, err := paths.WorktreeRoot(ctx)
if err != nil {
// Fall back to CWD for tests run outside a git repo.
//nolint:forbidigo // explicit fallback when WorktreeRoot fails
root, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve repo root: %w", err)
}
}
return filepath.Join(root, extensionDirName, extensionFileName), nil
}

func renderExtension(localDev bool) string {
var cmd string
if localDev {
cmd = `go run "$(git rev-parse --show-toplevel)"/cmd/entire/main.go`
} else {
cmd = "entire"
}
return strings.ReplaceAll(extensionTemplate, entireCmdPlaceholder, cmd)
}

// InstallHooks writes the Entire pi extension to .pi/extensions/entire/index.ts.
// Returns 1 if the extension was written, 0 if already up-to-date (idempotent).
// If the file exists but content differs (e.g., localDev vs production), it is
// rewritten as long as it is recognisable as Entire-owned (contains the
// marker). A foreign file at the same path is left untouched unless force is
// true — this protects user-authored extensions that happen to live at
// .pi/extensions/entire/index.ts.
func (a *PiAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) {
path, err := extensionPath(ctx)
if err != nil {
return 0, err
}
content := renderExtension(localDev)

if !force {
//nolint:gosec // path constructed from validated repo root
existing, readErr := os.ReadFile(path)
switch {
case readErr == nil && string(existing) == content:
return 0, nil // already up-to-date
case readErr == nil && !strings.Contains(string(existing), entireMarker):
return 0, fmt.Errorf("refusing to overwrite foreign file at %s; remove it or pass --force", path)
}
}
Comment thread
dipree marked this conversation as resolved.

//nolint:gosec // G301: pi reads the directory; standard 0755 permissions
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return 0, fmt.Errorf("create extension dir: %w", err)
}
//nolint:gosec // G306: pi reads the file; standard 0644 permissions
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return 0, fmt.Errorf("write extension: %w", err)
}
return 1, nil
}

// UninstallHooks removes the entire pi extension directory (if present).
func (a *PiAgent) UninstallHooks(ctx context.Context) error {
path, err := extensionPath(ctx)
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove pi extension dir: %w", err)
}
return nil
}

// AreHooksInstalled returns true when the extension file exists and is
// recognisable as Entire-owned (contains the marker string).
func (a *PiAgent) AreHooksInstalled(ctx context.Context) bool {
path, err := extensionPath(ctx)
if err != nil {
return false
}
//nolint:gosec // path from validated repo root
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.Contains(string(data), entireMarker)
}
Loading
Loading