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
24 changes: 23 additions & 1 deletion docs/guide/getting-started/supported-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Agent runs "cargo test"
| Gemini CLI | Rust binary (`BeforeTool`) | Yes |
| OpenCode | TypeScript plugin (`tool.execute.before`) | Yes |
| OpenClaw | TypeScript plugin (`before_tool_call`) | Yes |
| Pi | TypeScript extension (`tool_call` event) | Yes |
| Hermes | Python plugin (`terminal` command mutation) | Yes |
| Cline / Roo Code | Rules file (prompt-level) | N/A |
| Windsurf | Rules file (prompt-level) | N/A |
Expand Down Expand Up @@ -85,6 +86,27 @@ rtk init --global --opencode

Creates `~/.config/opencode/plugins/rtk.ts`. Uses the `tool.execute.before` hook.

### Pi

```bash
# Project-local (default)
rtk init --agent pi

# Global — all projects
rtk init --agent pi --global
```

Creates `.pi/extensions/rtk.ts` (local) or `~/.pi/agent/extensions/rtk.ts` (global). Pi auto-discovers extensions from both paths on startup.

Uninstall:

```bash
rtk init --uninstall --agent pi
rtk init --uninstall --agent pi --global
```

Removes only the installed Pi extension file.

### OpenClaw

```bash
Expand Down Expand Up @@ -151,7 +173,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https://
| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it |
| **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk <cmd>` |

Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it.
Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API.

## Windows support

Expand Down
15 changes: 13 additions & 2 deletions hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here.

Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes).
Owns: per-agent hook scripts and configuration files for 9 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi).

Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`).

Expand Down Expand Up @@ -40,6 +40,7 @@ Each agent subdirectory has its own README with hook-specific details:
- **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped
- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location
- **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation
- **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/`
- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation

## Supported Agents
Expand All @@ -55,13 +56,15 @@ Each agent subdirectory has its own README with hook-specific details:
| Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A |
| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A |
| OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes |
| Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes |
| Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes |

## JSON Formats by Agent

### Claude Code (Shell Hook)

**Input** (stdin):

```json
{
"tool_name": "Bash",
Expand All @@ -70,6 +73,7 @@ Each agent subdirectory has its own README with hook-specific details:
```

**Output** (stdout, when rewritten):

```json
{
"hookSpecificOutput": {
Expand All @@ -86,6 +90,7 @@ Each agent subdirectory has its own README with hook-specific details:
**Input**: Same as Claude Code.

**Output** (stdout, when rewritten):

```json
{
"permission": "allow",
Expand All @@ -98,6 +103,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
### Copilot CLI (Rust Binary)

**Input** (stdin, camelCase, `toolArgs` is JSON-stringified):

```json
{
"toolName": "bash",
Expand All @@ -106,6 +112,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
```

**Output** (no `updatedInput` support -- uses deny-with-suggestion):

```json
{
"permissionDecision": "deny",
Expand All @@ -116,6 +123,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
### VS Code Copilot Chat (Rust Binary)

**Input** (stdin, snake_case):

```json
{
"tool_name": "Bash",
Expand All @@ -128,6 +136,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
### Gemini CLI (Rust Binary)

**Input** (stdin):

```json
{
"tool_name": "run_shell_command",
Expand All @@ -136,6 +145,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
```

**Output** (when rewritten):

```json
{
"decision": "allow",
Expand All @@ -150,6 +160,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths).
### OpenCode (TypeScript Plugin)

Mutates `args.command` in-place via the zx library:

```typescript
const result = await $`rtk rewrite ${command}`.quiet().nothrow()
const rewritten = String(result.stdout).trim()
Expand Down Expand Up @@ -230,7 +241,7 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [
| Tier | Mechanism | Maintenance | Examples |
|------|-----------|-------------|----------|
| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini |
| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes |
| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes, Pi |
| **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex |

### Eligibility
Expand Down
60 changes: 60 additions & 0 deletions hooks/pi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Pi Hooks

> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code

## Design Intent

RTK's Pi extension is a **rewrite-only token optimizer**. It mutates bash commands to their
`rtk`-prefixed equivalents, saving 60–90% context tokens.

**Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit
commands — that concern belongs to a dedicated permission extension (e.g. one that gates
`rm -rf`, `sudo`, etc.). This separation keeps RTK's hook fast, predictable, and composable
with other Pi extensions.

## Specifics

- TypeScript extension using Pi's `ExtensionAPI` (not a shell hook, no `zx` dependency)
- Subscribes to `tool_call` event, narrows to `bash` tool via `isToolCallEventType`
- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs
- All error paths return `undefined` (pass through); RTK never blocks execution
- Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing
- Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global`

## Uninstall

```bash
# Remove project-local install (run from the project root)
rtk init --uninstall --agent pi
# → removes .pi/extensions/rtk.ts

# Remove global install
rtk init --uninstall --agent pi --global
# → removes ~/.pi/agent/extensions/rtk.ts
```

Uninstall is idempotent — re-running when nothing is installed is a no-op.
Only the extension file is managed by install/uninstall.

## Testing

```bash
# Load the extension directly without installing
pi -e ./hooks/pi/rtk.ts

# Verify rewrites are active — ask the agent to run a command, then check history
rtk gain --history # should show rtk-prefixed commands with savings %

# Test RTK_DISABLED passthrough
RTK_DISABLED=1 pi -e ./hooks/pi/rtk.ts
# → commands pass through unchanged; no rewrites in rtk gain --history

# Test version guard — temporarily shadow rtk with a stub that prints "rtk 0.22.0"
# → extension logs a warning at startup and registers a no-op; pi starts normally
```

## Design Notes

- All filtering logic lives in `rtk rewrite` (the Rust registry), not in this file
- Exit codes 0 and 3 both mean "rewrite and allow"; they are handled identically
- Uses `pi.exec` for subprocess management — consistent with Pi's extension API
80 changes: 80 additions & 0 deletions hooks/pi/rtk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// RTK Pi extension — rewrites bash commands to use rtk for token savings.
// Requires: rtk >= 0.23.0 in PATH.
//
// This is a thin delegating extension: all rewrite logic lives in `rtk rewrite`,
// which is the single source of truth (src/discover/registry.rs).
// To add or change rewrite rules, edit the Rust registry — not this file.
//
// Exit code contract for `rtk rewrite`:
// 0 + stdout Rewrite found → mutate command
// 1 No RTK equivalent → pass through unchanged
// 3 + stdout Rewrite (advisory) → mutate command

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { isToolCallEventType } from "@earendil-works/pi-coding-agent"

const REWRITE_TIMEOUT_MS = 2_000
const MIN_SUPPORTED_RTK_MINOR = 23

// Parse "X.Y.Z" semver, return [major, minor, patch] or null.
function parseSemver(raw: string): [number, number, number] | null {
const m = raw.trim().match(/(\d+)\.(\d+)\.(\d+)/)
if (!m) return null
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
}

// Calls `rtk rewrite`; returns the rewritten command or null (pass through).
async function rewriteCommand(
pi: ExtensionAPI,
cmd: string,
signal?: AbortSignal
): Promise<string | null> {
const result = await pi.exec("rtk", ["rewrite", cmd], {
timeout: REWRITE_TIMEOUT_MS,
signal,
})
if (result.killed) return null
if (result.code !== 0 && result.code !== 3) return null
return result.stdout.trim() || null
}

export default async function (pi: ExtensionAPI) {
// Probe rtk version at load time; disables extension if missing or too old.
const ver = await pi.exec("rtk", ["--version"], { timeout: REWRITE_TIMEOUT_MS })
if (ver.code !== 0) {
console.warn("[rtk] rtk binary not found in PATH — extension disabled")
return
}

// Warn and bail if rtk predates 0.23.0 (when `rtk rewrite` was introduced).
const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, ""))
if (parsed) {
const [major, minor] = parsed
if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) {
console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`)
return
}
}

pi.on("tool_call", async (event, ctx) => {
try {
if (!isToolCallEventType("bash", event)) return

const cmd = event.input.command
if (typeof cmd !== "string" || cmd.trim() === "") return

if (cmd.startsWith("rtk ")) return
if (process.env.RTK_DISABLED === "1") return

// Delegate to RTK.
const rewritten = await rewriteCommand(pi, cmd, ctx.signal)
if (rewritten && rewritten !== cmd) {
event.input.command = rewritten
}
} catch (err) {
// Fail open: never block execution on an unexpected error.
console.warn("[rtk] unexpected error in tool_call handler; passing through command", err)
return
}
})
}
5 changes: 3 additions & 2 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`.

Owns: `rtk init` installation flows (4 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
Owns: `rtk init` installation flows (5 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.

Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`).

Expand All @@ -22,14 +22,15 @@ LLM agent integration layer that installs, validates, and executes command-rewri
`rtk init` supports these installation flows:

| Mode | Command | Creates | Patches |
|------|---------|---------|---------|
|------|---------|---------|----------|
| Default (global) | `rtk init -g` | Hook, SHA-256 hash, RTK.md | settings.json, CLAUDE.md |
| Hook only | `rtk init -g --hook-only` | Hook, SHA-256 hash | settings.json |
| Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md |
| Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- |
| Cline | `rtk init --agent cline` | `.clinerules` | -- |
| Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md |
| Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json |
| Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- |
| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` |


Expand Down
7 changes: 7 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub const OPENCODE_PLUGIN_FILE: &str = "rtk.ts";
pub const CURSOR_DIR: &str = ".cursor";
pub const CODEX_DIR: &str = ".codex";
pub const GEMINI_DIR: &str = ".gemini";

pub const PI_DIR: &str = ".pi/agent";
pub const PI_LOCAL_DIR: &str = ".pi";
pub const PI_EXTENSIONS_SUBDIR: &str = "extensions";
pub const PI_PLUGIN_FILE: &str = "rtk.ts";
pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR";

pub const HERMES_DIR: &str = ".hermes";
pub const HERMES_PLUGINS_SUBDIR: &str = "plugins";
pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite";
Expand Down
Loading
Loading