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
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ With Entire, you can:

- Git
- macOS or Linux (Windows via WSL)
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and authenticated
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [OpenCode](https://opencode.ai) installed and authenticated

## Quick Start

Expand All @@ -55,15 +55,15 @@ entire status
entire enable
```

This installs agent and git hooks to work with your AI agent (Claude Code or Gemini CLI). The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch.
This installs agent and git hooks to work with your AI agent (Claude Code, Gemini CLI, or OpenCode). The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch.

**When checkpoints are created** depends on your chosen strategy (default is `manual-commit`):
- **Manual-commit**: Checkpoints are created when you or the agent make a git commit
- **Auto-commit**: Checkpoints are created after each agent response

### 2. Work with Your AI Agent

Just use Claude Code or Gemini CLI normally. Entire runs in the background, tracking your session:
Just use Claude Code, Gemini CLI, or OpenCode normally. Entire runs in the background, tracking your session:

```
entire status # Check current session status anytime
Expand Down Expand Up @@ -179,7 +179,7 @@ Multiple AI sessions can run on the same commit. If you start a second session w

| Flag | Description |
|------------------------|--------------------------------------------------------------------|
| `--agent <name>` | AI agent to setup hooks for: `claude-code` (default) or `gemini` |
| `--agent <name>` | AI agent to setup hooks for: `claude-code` (default), `gemini`, or `opencode` |
| `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) |
| `--local` | Write settings to `settings.local.json` instead of `settings.json` |
| `--project` | Write settings to `settings.json` even if it already exists |
Expand Down Expand Up @@ -276,6 +276,20 @@ All commands (`rewind`, `status`, `doctor`, etc.) work the same regardless of wh

If you run into any issues with Gemini CLI integration, please [open an issue](https://github.com/entireio/cli/issues).

### OpenCode (Preview)

OpenCode support is currently in preview. Entire can work with [OpenCode](https://opencode.ai) as an alternative to Claude Code or Gemini CLI, or alongside them — you can have multiple agents' hooks enabled at the same time.

To enable:

```bash
entire enable --agent opencode
```

OpenCode integration uses a TypeScript plugin (`entire.ts`) installed into `.opencode/plugins/`, which OpenCode auto-loads at startup. All commands (`rewind`, `status`, `doctor`, etc.) work the same regardless of which agent is configured.

If you run into any issues with OpenCode integration, please [open an issue](https://github.com/entireio/cli/issues).

## Troubleshooting

### Common Issues
Expand All @@ -284,7 +298,7 @@ If you run into any issues with Gemini CLI integration, please [open an issue](h
|--------------------------|-------------------------------------------------------------------------------------------|
| "Not a git repository" | Navigate to a Git repository first |
| "Entire is disabled" | Run `entire enable` |
| "No rewind points found" | Work with Claude Code and commit (manual-commit) or wait for agent response (auto-commit) |
| "No rewind points found" | Work with your AI agent and commit (manual-commit) or wait for agent response (auto-commit) |
| "shadow branch conflict" | Run `entire reset --force` |

### SSH Authentication Errors
Expand Down
178 changes: 178 additions & 0 deletions cmd/entire/cli/agent/opencode/entire.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Entire integration plugin for OpenCode.
// This file is auto-generated by `entire enable` — do not edit manually.
// It hooks into OpenCode's plugin system to notify Entire of session lifecycle events.

import { execSync, execFileSync } from "child_process";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

interface HookPayload {
session_id: string;
session_ref: string;
timestamp: string;
transcript_path: string;
tool_name?: string;
tool_use_id?: string;
tool_input?: Record<string, unknown>;
tool_response?: Record<string, unknown>;
subagent_transcript_path?: string;
}

// Resolve the entire binary path once at load time.
// Supports ENTIRE_BIN env var override for development/testing.
function resolveBinary(): string {
const envBin = process.env.ENTIRE_BIN;
if (envBin) return envBin;
try {
return execSync("which entire", { encoding: "utf-8" }).trim();
} catch {
return "entire";
}
}

const entireBin = resolveBinary();
Comment on lines +22 to +34
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states that the ENTIRE_BIN environment variable is "set by hook handlers", but there's no code in hooks_opencode_handlers.go or the hook execution path that actually sets this variable. The resolveBinary function will fall back to using 'which entire' or PATH lookup, which works fine, but the PR description is misleading. Either the description should be corrected to say ENTIRE_BIN is optional (with fallback to PATH), or the hook handlers should actually set this variable for consistency.

Copilot uses AI. Check for mistakes.

function callEntire(
log: (msg: string) => void,
verb: string,
payload: HookPayload,
): void {
try {
const input = JSON.stringify(payload);
log(`calling: ${entireBin} hooks opencode ${verb}`);
execFileSync(entireBin, ["hooks", "opencode", verb], {
input,
stdio: ["pipe", "pipe", "pipe"],
timeout: 30_000,
});
log(`hook ${verb} completed`);
} catch (e: any) {
log(`hook ${verb} failed: ${e?.message ?? e}`);
}
}

async function exportTranscript(
client: any,
log: (msg: string) => void,
sessionId: string,
): Promise<string> {
const dir = join(tmpdir(), "entire-opencode");
mkdirSync(dir, { recursive: true });
const filePath = join(dir, `${sessionId}.jsonl`);
try {
log(`exporting transcript for session ${sessionId}`);
const response = await client.session.messages({
path: { id: sessionId },
});
const messages: any[] = response.data ?? response ?? [];
const lines = messages.map((m: any) => JSON.stringify(m));
writeFileSync(filePath, lines.join("\n") + "\n");
log(`transcript exported: ${messages.length} messages → ${filePath}`);
} catch (e: any) {
log(`transcript export failed: ${e?.message ?? e}`);
// Write empty file so hook handler still runs
writeFileSync(filePath, "");
}
return filePath;
}

export const EntirePlugin = async (ctx: {
project: any;
client: any;
$: any;
directory: string;
worktree: string;
}) => {
// Structured logging via OpenCode SDK
const log = (message: string) => {
ctx.client.app
.log({
body: {
service: "entire",
level: "info",
message,
},
})
.catch(() => {});
};

log(`plugin loaded, binary: ${entireBin}`);

return {
event: async ({ event }: { event: { type: string; properties: any } }) => {
if (event.type === "session.created") {
const sessionId = event.properties.info?.id;
log(`session.created event, sessionId=${sessionId}`);
if (!sessionId) return;
const payload: HookPayload = {
session_id: sessionId,
session_ref: sessionId,
timestamp: new Date().toISOString(),
transcript_path: "",
};
callEntire(log, "session-start", payload);
}

if (event.type === "session.idle") {
const sessionId = event.properties.sessionID;
log(`session.idle event, sessionId=${sessionId}`);
if (!sessionId) return;
const transcriptPath = await exportTranscript(
ctx.client,
log,
sessionId,
);
const payload: HookPayload = {
session_id: sessionId,
session_ref: sessionId,
timestamp: new Date().toISOString(),
transcript_path: transcriptPath,
};
callEntire(log, "stop", payload);
}
},

"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
) => {
if (input.tool !== "task") return;
log(`tool.execute.before: task ${input.callID}`);
const payload: HookPayload = {
session_id: input.sessionID,
session_ref: input.sessionID,
timestamp: new Date().toISOString(),
transcript_path: "",
tool_name: input.tool,
tool_use_id: input.callID,
tool_input: output.args,
};
callEntire(log, "task-start", payload);
},

"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: any },
) => {
if (input.tool !== "task") return;
log(`tool.execute.after: task ${input.callID}`);
const transcriptPath = await exportTranscript(
ctx.client,
log,
input.sessionID,
);
const payload: HookPayload = {
session_id: input.sessionID,
session_ref: input.sessionID,
timestamp: new Date().toISOString(),
transcript_path: transcriptPath,
tool_name: input.tool,
tool_use_id: input.callID,
tool_response: { output: output.output },
subagent_transcript_path: transcriptPath,
};
callEntire(log, "task-complete", payload);
},
};
};
114 changes: 114 additions & 0 deletions cmd/entire/cli/agent/opencode/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package opencode

import (
"embed"
"fmt"
"os"
"path/filepath"

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

// Ensure OpenCodeAgent implements HookSupport and HookHandler
var (
_ agent.HookSupport = (*OpenCodeAgent)(nil)
_ agent.HookHandler = (*OpenCodeAgent)(nil)
)

// pluginFileName is the name of the Entire plugin file installed into OpenCode.
const pluginFileName = "entire.ts"

// pluginDir is the directory within .opencode where plugins are stored.
const pluginDir = "plugins"

//go:embed entire.ts
var pluginFS embed.FS

// GetHookNames returns the hook verbs OpenCode supports.
// These become subcommands: entire hooks opencode <verb>
func (o *OpenCodeAgent) GetHookNames() []string {
return []string{
HookNameSessionStart,
HookNameStop,
HookNameTaskStart,
HookNameTaskComplete,
}
}

// InstallHooks installs the Entire plugin into .opencode/plugins/entire.ts.
// OpenCode auto-loads TypeScript plugins from .opencode/plugins/ at startup.
// If force is true, overwrites the existing plugin file.
// Returns the number of hooks installed (1 plugin file = 1).
func (o *OpenCodeAgent) InstallHooks(_ bool, force bool) (int, error) {
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos)
if err != nil {
return 0, fmt.Errorf("failed to get current directory: %w", err)
}
}

pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName)

// Idempotency: if plugin already exists and force is false, skip
if !force {
if _, err := os.Stat(pluginPath); err == nil {
return 0, nil
}
}

// Read the embedded plugin source
pluginContent, err := pluginFS.ReadFile(pluginFileName)
if err != nil {
return 0, fmt.Errorf("failed to read embedded plugin: %w", err)
}

// Create the plugins directory
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil {
return 0, fmt.Errorf("failed to create plugins directory: %w", err)
}

// Write the plugin file
if err := os.WriteFile(pluginPath, pluginContent, 0o600); err != nil {
return 0, fmt.Errorf("failed to write plugin file: %w", err)
}

return 1, nil
}

// UninstallHooks removes the Entire plugin from .opencode/plugins/.
func (o *OpenCodeAgent) UninstallHooks() error {
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot = "."
}

pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName)
if err := os.Remove(pluginPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove plugin file: %w", err)
}
return nil
}

// AreHooksInstalled checks if the Entire plugin file exists.
func (o *OpenCodeAgent) AreHooksInstalled() bool {
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot = "."
}

pluginPath := filepath.Join(repoRoot, ".opencode", pluginDir, pluginFileName)
_, err = os.Stat(pluginPath)
return err == nil
}

// GetSupportedHooks returns the hook types OpenCode supports.
func (o *OpenCodeAgent) GetSupportedHooks() []agent.HookType {
return []agent.HookType{
agent.HookSessionStart,
agent.HookStop,
agent.HookPreToolUse,
agent.HookPostToolUse,
}
}
Loading
Loading