From 34c9b7c63b40c096491cdecb1f90ee901542ec22 Mon Sep 17 00:00:00 2001 From: Connor Date: Thu, 21 May 2026 10:37:06 -0700 Subject: [PATCH] feat(pi): Add Pi coding agent integration Introduces a TypeScript extension for the Pi coding agent, enabling RTK to automatically rewrite bash `tool_call` commands. This integration includes: - `rtk init --agent pi` for streamlined installation and uninstallation. - A fail-open TypeScript extension that delegates rewrite decisions to `rtk rewrite`. - Comprehensive documentation updates across READMEs and supported agent guides. - Core Rust backend logic to manage the extension's lifecycle. --- .loom/audit/20260521-pi-extension-audit.md | 84 +++++++++ .../20260521-pi-extension-validation.md | 86 +++++++++ .loom/tickets/20260521-pi-extension.md | 65 +++++++ README.md | 8 +- .../guide/getting-started/supported-agents.md | 17 +- hooks/README.md | 18 +- hooks/pi/README.md | 37 ++++ hooks/pi/rtk.ts | 52 ++++++ src/hooks/README.md | 3 +- src/hooks/constants.rs | 6 + src/hooks/init.rs | 175 +++++++++++++++++- src/main.rs | 100 ++++++++-- 12 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 .loom/audit/20260521-pi-extension-audit.md create mode 100644 .loom/evidence/20260521-pi-extension-validation.md create mode 100644 .loom/tickets/20260521-pi-extension.md create mode 100644 hooks/pi/README.md create mode 100644 hooks/pi/rtk.ts diff --git a/.loom/audit/20260521-pi-extension-audit.md b/.loom/audit/20260521-pi-extension-audit.md new file mode 100644 index 000000000..24935e674 --- /dev/null +++ b/.loom/audit/20260521-pi-extension-audit.md @@ -0,0 +1,84 @@ +# Pi Extension Integration Audit + +ID: audit:20260521-pi-extension-audit +Type: Audit +Status: recorded +Created: 2026-05-21 +Updated: 2026-05-21 +Audited: 2026-05-21 +Target: ticket:20260521-pi-extension + +## Summary + +A bounded Ralph review audited the RTK Pi extension integration, acceptance evidence, install behavior, fail-open behavior, and docs. The final follow-up audit found no material findings within scope and gave a `clear` verdict, with residual risk limited to lack of live Pi `tool_call` end-to-end observation and the need to reload/restart Pi. + +## Target + +The target was `ticket:20260521-pi-extension` and the current uncommitted diff in `/Users/crlough/Code/personal/rtk` adding first-class Pi support: + +- `hooks/pi/rtk.ts` +- `hooks/pi/README.md` +- `src/main.rs` +- `src/hooks/init.rs` +- `src/hooks/constants.rs` +- `README.md` +- `docs/guide/getting-started/supported-agents.md` +- `hooks/README.md` +- `src/hooks/README.md` +- `.loom/tickets/20260521-pi-extension.md` +- `.loom/evidence/20260521-pi-extension-validation.md` + +## Audit Scope And Lenses + +The final Ralph review challenged the current final state after prior audit follow-up fixes. Lenses used: + +- acceptance and scope: whether `ACC-001` through `ACC-004` are supported now; +- evidence exactness and freshness; +- implementation quality and fail-open behavior; +- security/trust boundary; +- docs and follow-through. + +Out of scope: live Pi UI/runtime end-to-end execution, every RTK rewrite pattern, and upstream Pi API changes after the audited documentation/source state. + +## Context And Evidence Reviewed + +- Ralph review run: headless Pi review launched from `ticket:20260521-pi-extension` and `evidence:20260521-pi-extension-validation`, with read-only scope and explicit lenses. +- `git status --short`, `git diff --stat`, and current untracked `hooks/pi/*` - current source state under review. +- `hooks/pi/rtk.ts` - Pi extension implementation and fail-open behavior. +- `hooks/pi/README.md` - Pi integration installation and failure-behavior docs. +- `src/main.rs`, `src/hooks/init.rs`, `src/hooks/constants.rs` - CLI dispatch, install/uninstall path, path resolution, and tests. +- `README.md`, `docs/guide/getting-started/supported-agents.md`, `hooks/README.md`, `src/hooks/README.md` - public and internal docs. +- `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/README.md` and `docs/extensions.md` - Pi extension location and `tool_call` mutation behavior. +- `/Users/crlough/.pi/agent/extensions/rtk.ts` - installed current-user extension; Ralph reported it exists and matches `hooks/pi/rtk.ts` by `cmp`/SHA-256. +- `evidence:20260521-pi-extension-validation` - records `cargo fmt --all --check`, `cargo clippy --all-targets`, `cargo test --all`, isolated install, and current-user install/match observations. + +## Findings + +None - no material findings within audited scope. + +The Ralph review specifically rechecked the prior blockers and reported them resolved: + +- installed current-user Pi extension now exists and matches the repo artifact; +- fail-open overclaim is resolved by subprocess-level fallback plus top-level `try/catch` around the `tool_call` handler; +- Rust quality gate evidence now includes `cargo fmt --all --check`, `cargo clippy --all-targets`, and `cargo test --all`. + +## Verdict + +`clear` - within the audited scope, the Pi extension integration satisfies `ACC-001` through `ACC-004`, and the final review did not identify material blockers. This verdict does not itself close or accept the ticket; it supports the ticket owner making a closure/acceptance decision from the ticket, evidence, and audit records. + +## Required Follow-up + +No material audit follow-up is required before ticket closure. + +Ticket closure should still state the live-runtime limitation explicitly: the installed extension requires Pi restart or `/reload` before it is active in a running session. + +## Residual Risk + +- No actual Pi end-to-end `tool_call` execution was observed; validation is source inspection plus helper/install checks. +- Pi must restart or run `/reload` before the installed extension is active in a running session. +- Extension behavior depends on the local `rtk` binary found on Pi's PATH and Pi's documented extension API remaining compatible. + +## Related Records + +- `ticket:20260521-pi-extension` - owns acceptance, finding disposition, and closure state. +- `evidence:20260521-pi-extension-validation` - records the validation observations consumed by this audit. diff --git a/.loom/evidence/20260521-pi-extension-validation.md b/.loom/evidence/20260521-pi-extension-validation.md new file mode 100644 index 000000000..504f9e77d --- /dev/null +++ b/.loom/evidence/20260521-pi-extension-validation.md @@ -0,0 +1,86 @@ +# Pi Extension Validation + +ID: evidence:20260521-pi-extension-validation +Type: Evidence Dossier +Status: recorded +Created: 2026-05-21 +Updated: 2026-05-21 +Observed: 2026-05-21 + +## Summary + +Validation observations for `ticket:20260521-pi-extension`: the Pi extension artifact was installed for the current user, the rewrite helper behavior handles RTK's exit-3 rewrite output, documentation/source references for Pi support are present, a Rust toolchain was installed, formatting/clippy/tests pass, and the new `rtk init --agent pi` path installs the extension into both isolated and current-user Pi config directories. + +## Observations + +- Observation: The current user's Pi extension file exists and matches the repo artifact at install time. + - Procedure/source: Ran `mkdir -p "$HOME/.pi/agent/extensions" && cp hooks/pi/rtk.ts "$HOME/.pi/agent/extensions/rtk.ts" && cmp -s hooks/pi/rtk.ts "$HOME/.pi/agent/extensions/rtk.ts" && ls -l "$HOME/.pi/agent/extensions/rtk.ts"` from `/Users/crlough/Code/personal/rtk`. + - Actual result: `cmp` exited successfully and `ls` showed `/Users/crlough/.pi/agent/extensions/rtk.ts` with size `1728` bytes. + +- Observation: Existing installed RTK can rewrite `git status`, returning the rewritten command on stdout with exit code `3`. + - Procedure/source: Ran `rtk rewrite "git status"; printf 'exit=%s\n' "$?"`. + - Actual result: stdout contained `rtk git status` and `exit=3`. + +- Observation: The Pi extension's Node rewrite-helper behavior returns the rewrite despite RTK exit code `3`. + - Procedure/source: Ran a Node snippet matching `hooks/pi/rtk.ts`'s `execFile("rtk", ["rewrite", command])` catch path for `git status`. + - Actual result: stdout was `rtk git status`. + +- Observation: Source and docs contain Pi integration references. + - Procedure/source: Ran `rg -n "Pi|--agent pi|hooks/pi|PI_CODING_AGENT_DIR|rtk.ts" README.md docs/guide/getting-started/supported-agents.md hooks/README.md src/hooks/README.md src/main.rs src/hooks/init.rs src/hooks/constants.rs hooks/pi`. + - Actual result: matches were found in the new Pi artifact/docs and in `src/main.rs`, `src/hooks/init.rs`, and `src/hooks/constants.rs`. + +- Observation: Rust formatter/tests initially could not run in this environment. + - Procedure/source: Ran `cargo fmt` and checked for Rust toolchain commands. + - Actual result: `/bin/bash: cargo: command not found`; `which cargo || which rustc || ls ~/.cargo/bin` produced no toolchain path. + +- Observation: Rust toolchain is now installed. + - Procedure/source: Confirmed Xcode Command Line Tools were present, ran the rustup installer, then added missing components with `rustup component add rustfmt clippy`. + - Actual result: `rustc 1.95.0 (59807616e 2026-04-14)`, `cargo 1.95.0 (f2d3ce0bd 2026-03-21)`, `rustfmt 1.9.0-stable (59807616e1 2026-04-14)`, and `clippy 0.1.95 (59807616e1 2026-04-14)` are available after sourcing `$HOME/.cargo/env`. + +- Observation: Rust formatting is clean after applying formatter output. + - Procedure/source: Ran `cargo fmt --all`, then `cargo fmt --all --check` from `/Users/crlough/Code/personal/rtk`. + - Actual result: `cargo fmt --all --check` exited `0` with no output. + +- Observation: Full Rust clippy gate passes. + - Procedure/source: Ran `cargo clippy --all-targets` from `/Users/crlough/Code/personal/rtk`. + - Actual result: `clippy_exit=0`; tail output included `Finished dev profile [unoptimized + debuginfo] target(s) in 0.41s`. + +- Observation: Full Rust test suite passes. + - Procedure/source: Ran `cargo test --all` from `/Users/crlough/Code/personal/rtk`. + - Actual result: `test_all_exit=0`; tail output included `test result: ok. 1919 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out; finished in 1.74s`. + +- Observation: The implemented `rtk init --agent pi` install path writes the extension into an isolated Pi config directory and the installed file matches the repo artifact. + - Procedure/source: Ran `TMP_PI_DIR=$(mktemp -d); PI_CODING_AGENT_DIR="$TMP_PI_DIR" cargo run --quiet -- init --agent pi; cmp -s hooks/pi/rtk.ts "$TMP_PI_DIR/extensions/rtk.ts"; ls -l "$TMP_PI_DIR/extensions/rtk.ts"; PI_CODING_AGENT_DIR="$TMP_PI_DIR" cargo run --quiet -- init --agent pi`. + - Actual result: `cargo run` reported `Pi extension installed`; `cmp` exited successfully; `ls` showed `$TMP_PI_DIR/extensions/rtk.ts` with size `1728` bytes; second run completed successfully. + +- Observation: The implemented `rtk init --agent pi` install path was run for the current user after audit follow-up, and the installed file matches the repo artifact. + - Procedure/source: Ran `cargo run --quiet -- init --agent pi; cmp -s hooks/pi/rtk.ts /Users/crlough/.pi/agent/extensions/rtk.ts; ls -l /Users/crlough/.pi/agent/extensions/rtk.ts`. + - Actual result: `cargo run` reported `Pi extension installed`; `cmp` exited successfully; `ls` showed `/Users/crlough/.pi/agent/extensions/rtk.ts` with size `1958` bytes. + +## Artifacts + +- `hooks/pi/rtk.ts` - repo Pi extension artifact installed for the current user. +- `/Users/crlough/.pi/agent/extensions/rtk.ts` - installed Pi extension file, matching `hooks/pi/rtk.ts` at observation time. +- Command excerpt: `rtk rewrite "git status"` produced `rtk git status` with exit code `3`, which the extension catch path handles. +- Command excerpt: `cargo fmt --all --check` exited `0` with no output. +- Command excerpt: `cargo clippy --all-targets` exited `0`. +- Command excerpt: `cargo test --all` ended with `test result: ok. 1919 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out; finished in 1.74s`. +- Command excerpt: isolated `PI_CODING_AGENT_DIR="$TMP_PI_DIR" cargo run --quiet -- init --agent pi` wrote `$TMP_PI_DIR/extensions/rtk.ts`, and `cmp -s hooks/pi/rtk.ts "$TMP_PI_DIR/extensions/rtk.ts"` succeeded. +- Command excerpt: current-user `cargo run --quiet -- init --agent pi` wrote `/Users/crlough/.pi/agent/extensions/rtk.ts`, and `cmp -s hooks/pi/rtk.ts /Users/crlough/.pi/agent/extensions/rtk.ts` succeeded. + +## What This Shows + +- `ticket:20260521-pi-extension#ACC-001` - supports - the extension artifact targets Pi `bash` tool calls and the observed helper logic rewrites `git status` through `rtk rewrite`, including RTK's exit-code-3 path; full Rust tests also pass after adding the integration code. +- `ticket:20260521-pi-extension#ACC-002` - supports - source inspection, Rust tests, formatting, and an isolated `PI_CODING_AGENT_DIR` `cargo run -- init --agent pi` install confirmed the path writes the expected extension file and handles a repeat run. +- `ticket:20260521-pi-extension#ACC-003` - supports - README, supported-agent guide, hook docs, and Pi README all contain Pi install/behavior documentation. +- `ticket:20260521-pi-extension#ACC-004` - supports - the extension is installed at `/Users/crlough/.pi/agent/extensions/rtk.ts` and matched the repo artifact when copied. + +## What This Does Not Show + +- Does not prove Pi has reloaded the extension in the currently running session; restart Pi or run `/reload` to activate the installed file. +- Does not exercise an actual Pi `tool_call` event end-to-end; it only verifies the installed artifact and rewrite helper behavior. +- Does not prove behavior for every RTK rewrite pattern or every shell edge case. + +## Related Records + +- `ticket:20260521-pi-extension` - owns the executable work and acceptance criteria this dossier supports. diff --git a/.loom/tickets/20260521-pi-extension.md b/.loom/tickets/20260521-pi-extension.md new file mode 100644 index 000000000..419225de9 --- /dev/null +++ b/.loom/tickets/20260521-pi-extension.md @@ -0,0 +1,65 @@ +# Add Pi Extension Integration + +ID: ticket:20260521-pi-extension +Type: Ticket +Status: closed +Created: 2026-05-21 +Updated: 2026-05-21 +Risk: medium - adds a new agent integration path and writes into the user's Pi config, but the runtime extension is narrow, fail-open, and locally verifiable. + +## Summary + +Add first-class RTK support for Pi by shipping a Pi extension that intercepts Pi `bash` tool calls, delegates rewrite decisions to `rtk rewrite`, and mutates the command before execution when RTK has a compact equivalent. The single closure claim is: RTK can install a Pi extension with `rtk init --agent pi`, and the installed extension rewrites Pi bash commands through the existing RTK rewrite registry without blocking command execution when RTK is unavailable or rewrite fails. + +## Related Records + +- `README.md` - public quick-start and supported-agent table must mention Pi. +- `docs/guide/getting-started/supported-agents.md` - durable user-facing supported-agent guide must include Pi install behavior. +- `hooks/README.md` - hook/plugin architecture record must describe the Pi extension and fail-open behavior. +- `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/README.md` - Pi extension locations and capabilities; Pi auto-discovers `~/.pi/agent/extensions/*.ts`. +- `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent/docs/extensions.md` - Pi `tool_call` handlers can mutate built-in `bash` tool input before execution. + +## Scope + +May change RTK source, docs, and hook artifacts needed for a Pi integration: + +- add a `hooks/pi/` TypeScript extension artifact and README; +- add `--agent pi` CLI dispatch and installation logic that writes the extension under the Pi agent config directory; +- update supported-agent documentation and tests; +- install the extension for the current user after implementation. + +Must not change RTK command filtering behavior, the rewrite registry semantics, Pi itself, or unrelated agent integrations. The extension must be a thin delegate to `rtk rewrite`, fail open on missing binary/errors/timeouts, and only mutate Pi `bash` tool calls. + +Evidence posture: compile/test the touched Rust installation logic where practical, inspect installed extension location, and smoke-test the extension's rewrite helper behavior by invoking `rtk rewrite` or running the install path. Review posture: separate audit would add limited value for this local integration slice if tests and inspection cover dispatch, install path, docs, and fail-open extension behavior. + +## Acceptance + +- ACC-001: The repo contains a Pi extension artifact that Pi can load from its extension directory, and it rewrites only `bash` tool commands by calling `rtk rewrite` while failing open on errors. + - Evidence: source inspection plus a lightweight test or type/format-compatible implementation review. + - Audit: verify no unrelated tool mutation and no blocking behavior on rewrite failure. + +- ACC-002: `rtk init --agent pi` installs the Pi extension idempotently into the Pi agent config directory, respecting `PI_CODING_AGENT_DIR` when set and dry-run behavior when requested. + - Evidence: Rust tests for path resolution/install/idempotence/dry-run or an equivalent command run in an isolated config directory. + - Audit: inspect that install writes only the expected extension path. + +- ACC-003: User-facing docs list Pi as a supported plugin integration and show the install command. + - Evidence: grep/read updated README and supported-agent docs. + - Audit: docs match implemented command and behavior. + +- ACC-004: The Pi extension is installed for the current user. + - Evidence: installed `~/.pi/agent/extensions/rtk.ts` exists and matches the repo artifact or install output reports it. + - Audit: note if Pi must be restarted/reloaded for activation. + +## Current State + +Closed. Implementation, local install, Rust validation, and audit are complete. The repo now contains a Pi TypeScript extension artifact, `--agent pi` CLI/install dispatch, docs, and Rust tests/source coverage for the install path. The extension is installed at `/Users/crlough/.pi/agent/extensions/rtk.ts` and matched the repo artifact after the final source install. Validation evidence is recorded in `evidence:20260521-pi-extension-validation`: Rust toolchain installed, `cargo fmt --all --check` passes, `cargo clippy --all-targets` passes, `cargo test --all` passes, isolated `PI_CODING_AGENT_DIR` install works, and current-user install works. Audit `audit:20260521-pi-extension-audit` returned `clear` with no material findings. Residual risk: no live Pi end-to-end `tool_call` execution was observed, and the user must restart Pi or run `/reload` for the installed extension to activate. + +## Journal + +- 2026-05-21: Created ticket with Status `open` from the operator request to add and install RTK Pi support. +- 2026-05-21: Set Status to `active`; current session is executing the bounded Ralph implementation slice in the main rtk worktree. +- 2026-05-21: Added Pi extension artifact, CLI install/uninstall dispatch, docs, and tests/source support; manually installed the extension to `/Users/crlough/.pi/agent/extensions/rtk.ts` because the currently installed `rtk` binary predates `--agent pi`. +- 2026-05-21: Recorded validation dossier `evidence:20260521-pi-extension-validation`. `cargo fmt` and Rust tests were not run because no Rust toolchain was available in this environment. Set Status to `review` pending Rust-toolchain verification or external review. +- 2026-05-21: Installed Rust with rustup after confirming Xcode Command Line Tools were present. Added `rustfmt` and `clippy` components. Ran `cargo fmt`, `cargo fmt --check` (pass), `cargo test` (pass: 1919 passed, 0 failed, 6 ignored), and isolated `PI_CODING_AGENT_DIR` `cargo run -- init --agent pi` with matching installed extension output. Updated `evidence:20260521-pi-extension-validation` with the results. +- 2026-05-21: Ran a bounded Ralph audit pass. Initial findings identified stale current-user install evidence, a fail-open overclaim, and missing clippy/`--all` quality-gate evidence. Fixed the extension with a top-level handler `try/catch`, reinstalled for current user with `cargo run -- init --agent pi`, ran `cargo fmt --all --check`, `cargo clippy --all-targets`, and `cargo test --all`, and updated evidence. +- 2026-05-21: Ran final bounded Ralph audit follow-up and recorded `audit:20260521-pi-extension-audit`; verdict `clear`, no material findings within audited scope. Closed ticket with residual risk limited to no live Pi end-to-end `tool_call` observation and requiring Pi restart or `/reload` for activation. diff --git a/README.md b/README.md index f8d65efe5..7b61d3911 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,13 @@ rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init --agent hermes # Hermes +rtk init --agent pi # Pi # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status ``` -Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. +Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes and Pi, use their plugin/extension API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. @@ -351,7 +352,7 @@ rtk git status ## Supported AI Tools -RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports major AI coding tools across hooks, plugins/extensions, and rules files. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -366,11 +367,12 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | +| **Pi** | `rtk init --agent pi` | TypeScript extension (bash tool command mutation via `rtk rewrite`) | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | -For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. +For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). The Hermes plugin source and tests live in `hooks/hermes/`; installed Hermes runtime files still live under `~/.hermes/plugins/rtk-rewrite/`. The Pi extension source lives in `hooks/pi/`; installed Pi runtime files live under `~/.pi/agent/extensions/` or `$PI_CODING_AGENT_DIR/extensions/`. ## Configuration diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 561f9de15..ad06016a0 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Kilo Code, and Antigravity sidebar: order: 3 --- @@ -36,6 +36,7 @@ Agent runs "cargo test" | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | +| Pi | TypeScript extension (`tool_call` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -103,6 +104,16 @@ Creates `~/.hermes/plugins/rtk-rewrite/` and enables it through `plugins.enabled The plugin fails open. If `rtk` is missing at load time, the hook is not registered. If `rtk rewrite` errors, the tool is not `terminal`, the payload has no string `command`, or the plugin raises an exception, Hermes runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. +### Pi + +```bash +rtk init --agent pi +``` + +Creates `~/.pi/agent/extensions/rtk.ts` by default, or `$PI_CODING_AGENT_DIR/extensions/rtk.ts` when `PI_CODING_AGENT_DIR` is set. The extension listens for Pi `bash` tool calls, mutates the `command` in place, and delegates all rewrite decisions to `rtk rewrite`. Restart Pi or run `/reload` after installing. + +The extension fails open. If `rtk` is missing, `rtk rewrite` errors, the tool is not `bash`, the payload has no string `command`, or the extension raises an exception, Pi runs the original command unchanged. The same `rtk rewrite` limitations apply: already-prefixed `rtk` commands, compound shell commands, heredocs, and commands without filters are not rewritten. + ### Cline / Roo Code ```bash @@ -148,10 +159,10 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | Tier | Mechanism | How rewrites work | |------|-----------|------------------| | **Full hook** | Shell script or Rust binary, intercepts via agent API | Transparent — agent never sees the raw command | -| **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | +| **Plugin / extension** | TypeScript, JavaScript, or Python in agent's plugin or extension system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | -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) and plugin/extension integrations that support mutation (OpenCode, OpenClaw, Hermes, Pi) are transparent — the command is rewritten before the agent sees it. ## Windows support diff --git a/hooks/README.md b/hooks/README.md index 0879de9bb..4aa4e57fc 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -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 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Kilo Code, Antigravity). 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`). @@ -41,6 +41,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`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 - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation +- **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, in-place bash command mutation ## Supported Agents @@ -56,6 +57,7 @@ Each agent subdirectory has its own README with hook-specific details: | Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | +| Pi | TypeScript extension (`tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -169,6 +171,18 @@ if result.returncode in {0, 3} and rewritten and rewritten != command: args["command"] = rewritten ``` +### Pi (TypeScript Extension) + +Mutates the built-in `bash` tool's `event.input.command` in-place via the `tool_call` event: + +```typescript +const { stdout } = await execFileAsync("rtk", ["rewrite", command], { timeout: 2000 }) +const rewritten = stdout.trim() +if (rewritten && rewritten !== command) { + event.input.command = rewritten +} +``` + ## Command Rewrite Registry The registry (`src/discover/registry.rs`) handles command patterns across these categories: @@ -230,7 +244,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 / extension** | TypeScript/JS/Python plugin or extension in agent's 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 diff --git a/hooks/pi/README.md b/hooks/pi/README.md new file mode 100644 index 000000000..f2c238fa5 --- /dev/null +++ b/hooks/pi/README.md @@ -0,0 +1,37 @@ +# Pi Extension + +RTK's Pi integration is a TypeScript extension installed to Pi's extension directory. + +## Install + +```bash +rtk init --agent pi +``` + +By default this writes: + +```text +~/.pi/agent/extensions/rtk.ts +``` + +If `PI_CODING_AGENT_DIR` is set, RTK installs under that directory instead: + +```text +$PI_CODING_AGENT_DIR/extensions/rtk.ts +``` + +Restart Pi or run `/reload` after installing. + +## How it works + +The extension listens for Pi `tool_call` events, targets only the built-in `bash` tool, and mutates `event.input.command` in place after delegating to: + +```bash +rtk rewrite "" +``` + +For example, Pi's raw `git status` tool call becomes `rtk git status` before execution. All command matching stays in the RTK binary. + +## Failure behavior + +The extension fails open. If `rtk` is unavailable, `rtk rewrite` times out, or any error occurs, the original command is left unchanged so Pi can continue normally. diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts new file mode 100644 index 000000000..4b895227c --- /dev/null +++ b/hooks/pi/rtk.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; + +const execFileAsync = promisify(execFile); +const REWRITE_TIMEOUT_MS = 2_000; +const MAX_REWRITE_OUTPUT_BYTES = 1024 * 1024; + +async function rewriteCommand(command: string, signal?: AbortSignal): Promise { + try { + const { stdout } = await execFileAsync("rtk", ["rewrite", command], { + timeout: REWRITE_TIMEOUT_MS, + maxBuffer: MAX_REWRITE_OUTPUT_BYTES, + signal, + }); + const rewritten = stdout.trim(); + return rewritten || command; + } catch (error) { + // `rtk rewrite` can return 3 when the underlying permission verdict is "ask". + // Pi does not use RTK's permission verdicts, but stdout still contains the safe rewrite. + const maybeError = error as { code?: unknown; stdout?: unknown }; + if (maybeError.code === 3 && typeof maybeError.stdout === "string") { + const rewritten = maybeError.stdout.trim(); + if (rewritten) return rewritten; + } + + // Fail open: a missing RTK binary, timeout, bad input, or rewrite error must never + // prevent Pi from running the user's original bash command. + return command; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("tool_call", async (event, ctx) => { + try { + if (!isToolCallEventType("bash", event)) return; + + const command = event.input.command; + if (typeof command !== "string" || command.trim() === "") return; + + const rewritten = await rewriteCommand(command, ctx.signal); + if (rewritten && rewritten !== command) { + event.input.command = rewritten; + } + } catch { + // Fail open for unexpected extension/runtime errors too. Pi should run the + // original command rather than block the bash tool because RTK integration failed. + return; + } + }); +} diff --git a/src/hooks/README.md b/src/hooks/README.md index 01a0213cb..3f55e215e 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -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 for hook, plugin, extension, and rules-file agents, 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/`). @@ -31,6 +31,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | +| Pi | `rtk init --agent pi` | TypeScript extension in `~/.pi/agent/extensions/` or `$PI_CODING_AGENT_DIR/extensions/` | -- | ## Integrity Verification diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 85340510c..da274e08a 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -26,3 +26,9 @@ pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; + +pub const PI_DIR: &str = ".pi"; +pub const PI_AGENT_SUBDIR: &str = "agent"; +pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; +pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; +pub const PI_EXTENSION_FILE: &str = "rtk.ts"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9af21dc57..8a78e6605 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -14,7 +14,8 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PI_AGENT_SUBDIR, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_EXTENSION_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -22,6 +23,9 @@ use super::integrity; // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); +// Embedded Pi extension (auto-rewrite) +const PI_EXTENSION: &str = include_str!("../../hooks/pi/rtk.ts"); + // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); @@ -795,6 +799,12 @@ pub fn uninstall( let cursor_removed = remove_cursor_hooks(ctx)?; removed.extend(cursor_removed); + // 7. Remove Pi extension + let pi_removed = remove_pi_extension(ctx)?; + for path in pi_removed { + removed.push(format!("Pi extension: {}", path.display())); + } + // Report results if removed.is_empty() { println!("RTK was not installed (nothing to remove)"); @@ -2804,6 +2814,66 @@ fn remove_opencode_plugin(ctx: InitContext) -> Result> { Ok(removed) } +fn resolve_pi_agent_dir() -> Result { + resolve_pi_agent_dir_from(std::env::var_os(PI_CODING_AGENT_DIR_ENV), dirs::home_dir()) +} + +fn resolve_pi_agent_dir_from( + pi_coding_agent_dir: Option, + home_dir: Option, +) -> Result { + if let Some(path) = pi_coding_agent_dir.filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + + home_dir + .map(|home| home.join(PI_DIR).join(PI_AGENT_SUBDIR)) + .context("Cannot determine Pi agent directory. Set $PI_CODING_AGENT_DIR or $HOME.") +} + +fn pi_extension_path(pi_agent_dir: &Path) -> PathBuf { + pi_agent_dir + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_EXTENSION_FILE) +} + +fn ensure_pi_extension_installed(path: &Path, ctx: InitContext) -> Result { + let InitContext { dry_run, .. } = ctx; + if !dry_run { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Pi extension directory: {}", + parent.display() + ) + })?; + } + } + write_if_changed(path, PI_EXTENSION, "Pi extension", ctx) +} + +fn remove_pi_extension(ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let pi_agent_dir = resolve_pi_agent_dir()?; + let path = pi_extension_path(&pi_agent_dir); + let mut removed = Vec::new(); + + if path.exists() { + if dry_run { + println!("[dry-run] would remove Pi extension: {}", path.display()); + } else { + fs::remove_file(&path) + .with_context(|| format!("Failed to remove Pi extension: {}", path.display()))?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", path.display()); + } + } + removed.push(path); + } + + Ok(removed) +} + // ─── Cursor Agent support ───────────────────────────────────────────── fn resolve_cursor_dir() -> Result { @@ -3288,6 +3358,18 @@ fn show_claude_config() -> Result<()> { println!("[--] OpenCode: config dir not found"); } + // Check Pi extension + if let Ok(pi_agent_dir) = resolve_pi_agent_dir() { + let extension = pi_extension_path(&pi_agent_dir); + if extension.exists() { + println!("[ok] Pi: extension installed ({})", extension.display()); + } else { + println!("[--] Pi: extension not found"); + } + } else { + println!("[--] Pi: agent config dir not found"); + } + // Check Cursor hooks if let Ok(cursor_dir) = resolve_cursor_dir() { let cursor_hook = cursor_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); @@ -3424,6 +3506,49 @@ fn run_opencode_only_mode(ctx: InitContext) -> Result<()> { Ok(()) } +pub fn run_pi_mode(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let pi_agent_dir = resolve_pi_agent_dir()?; + let extension_path = pi_extension_path(&pi_agent_dir); + ensure_pi_extension_installed(&extension_path, ctx)?; + if dry_run { + print_dry_run_footer(); + } else { + println!("\nPi extension installed.\n"); + println!(" Extension: {}", extension_path.display()); + println!(" Restart Pi or run /reload. Test with: git status\n"); + } + Ok(()) +} + +pub fn uninstall_pi(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let removed = remove_pi_extension(ctx)?; + + if removed.is_empty() { + println!("RTK Pi support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Pi:" + } else { + "RTK uninstalled for Pi:" + }; + println!("{}", header); + for path in &removed { + println!(" - Pi extension: {}", path.display()); + } + if !dry_run { + println!("\nRestart Pi or run /reload to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) +} + // ─── Gemini CLI support ─────────────────────────────────────────── /// Gemini hook wrapper script — delegates to `rtk hook gemini` @@ -3884,6 +4009,54 @@ mod tests { assert!(!plugin_path.exists()); } + #[test] + fn test_pi_agent_dir_prefers_env_override() { + let temp = TempDir::new().unwrap(); + let resolved = + resolve_pi_agent_dir_from(Some(temp.path().as_os_str().to_os_string()), None).unwrap(); + assert_eq!(resolved, temp.path()); + } + + #[test] + fn test_pi_agent_dir_defaults_to_home_pi_agent() { + let temp = TempDir::new().unwrap(); + let resolved = resolve_pi_agent_dir_from(None, Some(temp.path().to_path_buf())).unwrap(); + assert_eq!(resolved, temp.path().join(".pi").join("agent")); + } + + #[test] + fn test_pi_extension_install_and_update() { + let temp = TempDir::new().unwrap(); + let extension_path = pi_extension_path(temp.path()); + + let changed = + ensure_pi_extension_installed(&extension_path, InitContext::default()).unwrap(); + assert!(changed); + let content = fs::read_to_string(&extension_path).unwrap(); + assert_eq!(content, PI_EXTENSION); + + fs::write(&extension_path, "// old").unwrap(); + let changed_again = + ensure_pi_extension_installed(&extension_path, InitContext::default()).unwrap(); + assert!(changed_again); + let content_updated = fs::read_to_string(&extension_path).unwrap(); + assert_eq!(content_updated, PI_EXTENSION); + } + + #[test] + fn test_pi_extension_dry_run_writes_nothing() { + let temp = TempDir::new().unwrap(); + let extension_path = pi_extension_path(temp.path()); + let ctx = InitContext { + dry_run: true, + ..InitContext::default() + }; + + let changed = ensure_pi_extension_installed(&extension_path, ctx).unwrap(); + assert!(changed); + assert!(!extension_path.exists()); + } + #[test] fn test_migration_warns_on_missing_end_marker() { let input = format!("{} v2 -->\nOLD STUFF\nNo end marker", RTK_BLOCK_START); diff --git a/src/main.rs b/src/main.rs index c1a897190..539c77135 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,8 @@ pub enum AgentTarget { Antigravity, /// Hermes CLI Hermes, + /// Pi coding agent + Pi, } #[derive(Parser)] @@ -1365,24 +1367,33 @@ fn main() { std::process::exit(code); } -fn uninstall_init_dispatch( +#[derive(Clone, Copy)] +struct InitUninstallArgs { agent: Option, global: bool, gemini: bool, codex: bool, ctx: hooks::init::InitContext, +} + +fn uninstall_init_dispatch( + args: InitUninstallArgs, uninstall_hermes: UninstallHermes, + uninstall_pi: UninstallPi, uninstall_standard: UninstallStandard, ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, + UninstallPi: FnOnce(hooks::init::InitContext) -> Result<()>, UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { - if agent == Some(AgentTarget::Hermes) { - uninstall_hermes(ctx) + if args.agent == Some(AgentTarget::Hermes) { + uninstall_hermes(args.ctx) + } else if args.agent == Some(AgentTarget::Pi) { + uninstall_pi(args.ctx) } else { - let cursor = agent == Some(AgentTarget::Cursor); - uninstall_standard(global, gemini, codex, cursor, ctx) + let cursor = args.agent == Some(AgentTarget::Cursor); + uninstall_standard(args.global, args.gemini, args.codex, cursor, args.ctx) } } @@ -1821,12 +1832,15 @@ fn run_cli() -> Result { hooks::init::show_config(codex)?; } else if uninstall { uninstall_init_dispatch( - agent, - global, - gemini, - codex, - ctx, + InitUninstallArgs { + agent, + global, + gemini, + codex, + ctx, + }, hooks::init::uninstall_hermes, + hooks::init::uninstall_pi, hooks::init::uninstall, )?; } else if gemini { @@ -1854,6 +1868,8 @@ fn run_cli() -> Result { hooks::init::run_antigravity_mode(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; + } else if agent == Some(AgentTarget::Pi) { + hooks::init::run_pi_mode(ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2658,6 +2674,17 @@ mod tests { } } + #[test] + fn test_try_parse_init_agent_pi() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "pi"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!(agent, Some(AgentTarget::Pi)); + } + _ => panic!("Expected Init command"), + } + } + #[test] fn test_try_parse_kubectl_get_alias() { let cli = Cli::try_parse_from(["rtk", "kubectl", "get", "pods", "-n", "default"]).unwrap(); @@ -2687,6 +2714,7 @@ mod tests { #[test] fn test_init_uninstall_dispatch_routes_hermes_to_hermes_cleanup() { let hermes_called = Cell::new(false); + let pi_called = Cell::new(false); let standard_called = Cell::new(false); let ctx = hooks::init::InitContext { verbose: 2, @@ -2694,17 +2722,23 @@ mod tests { }; let result = uninstall_init_dispatch( - Some(AgentTarget::Hermes), - true, - false, - false, - ctx, + InitUninstallArgs { + agent: Some(AgentTarget::Hermes), + global: true, + gemini: false, + codex: false, + ctx, + }, |ctx| { hermes_called.set(true); assert_eq!(ctx.verbose, 2); assert!(ctx.dry_run); Ok(()) }, + |_| { + pi_called.set(true); + Ok(()) + }, |_, _, _, _, _| { standard_called.set(true); Ok(()) @@ -2713,6 +2747,42 @@ mod tests { assert!(result.is_ok()); assert!(hermes_called.get()); + assert!(!pi_called.get()); + assert!(!standard_called.get()); + } + + #[test] + fn test_init_uninstall_dispatch_routes_pi_to_pi_cleanup() { + let hermes_called = Cell::new(false); + let pi_called = Cell::new(false); + let standard_called = Cell::new(false); + let ctx = hooks::init::InitContext::default(); + + let result = uninstall_init_dispatch( + InitUninstallArgs { + agent: Some(AgentTarget::Pi), + global: true, + gemini: false, + codex: false, + ctx, + }, + |_| { + hermes_called.set(true); + Ok(()) + }, + |_| { + pi_called.set(true); + Ok(()) + }, + |_, _, _, _, _| { + standard_called.set(true); + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert!(!hermes_called.get()); + assert!(pi_called.get()); assert!(!standard_called.get()); }