From 1da5793b9293cca5fca3e316bda18ed02443f2e2 Mon Sep 17 00:00:00 2001 From: gitbluf Date: Wed, 6 May 2026 15:43:04 +0200 Subject: [PATCH 01/13] feat(hooks): add Pi coding agent integration --- hooks/README.md | 10 +- hooks/pi/README.md | 37 +++ hooks/pi/rtk-awareness.md | 37 +++ hooks/pi/rtk.ts | 132 +++++++++ src/hooks/constants.rs | 9 + src/hooks/init.rs | 550 +++++++++++++++++++++++++++++++++++++- src/main.rs | 144 ++++++---- 7 files changed, 867 insertions(+), 52 deletions(-) create mode 100644 hooks/pi/README.md create mode 100644 hooks/pi/rtk-awareness.md create mode 100644 hooks/pi/rtk.ts diff --git a/hooks/README.md b/hooks/README.md index 6a6744281..c15d9a357 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 7 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode). +Owns: per-agent hook scripts and configuration files for 8 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, 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`). @@ -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/` ## Supported Agents @@ -54,6 +55,7 @@ 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 | ## JSON Formats by Agent @@ -217,7 +219,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 plugin in agent's plugin system | Medium — agent manages loading | OpenCode | +| **Plugin** | TypeScript/JS plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Pi | | **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | ### Eligibility @@ -233,3 +235,7 @@ RTK supports AI coding assistants that developers actually use day-to-day. To ad If an agent's API changes and the hook breaks, the integration should be updated promptly. If the agent becomes unmaintained or the hook can't be fixed, the integration may be deprecated with a release note. +### Worked Example + +See [`docs/specs/pi-hook-integration.md`](../docs/specs/pi-hook-integration.md) for a complete integration SPEC — covering design, component inventory, exit-code mapping, install layout, error handling, and testing strategy. Use it as a template when speccing new agent integrations. + diff --git a/hooks/pi/README.md b/hooks/pi/README.md new file mode 100644 index 000000000..ceb7dc01f --- /dev/null +++ b/hooks/pi/README.md @@ -0,0 +1,37 @@ +# Pi Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## 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` as a subprocess; mutates `event.input.command` in-place if rewrite differs +- Returns `{ block: true, reason }` on deny (exit code 2); all other error paths return `undefined` +- 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 --pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --pi --global` +- `rtk-awareness.md` is embedded into `AGENTS.md` by `rtk init --pi` + +## 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 code 3 (ask) is treated as allow — Pi has no per-tool confirmation UI +- No `zx` library required; uses `node:child_process` `spawn` directly +- See [`docs/specs/pi-hook-integration.md`](../../docs/specs/pi-hook-integration.md) for the full design spec diff --git a/hooks/pi/rtk-awareness.md b/hooks/pi/rtk-awareness.md new file mode 100644 index 000000000..7bfe0dac6 --- /dev/null +++ b/hooks/pi/rtk-awareness.md @@ -0,0 +1,37 @@ +# RTK — Rust Token Killer + +**Usage**: Token-optimized CLI proxy. Commands are automatically rewritten +by the RTK extension before they execute — no changes to your workflow needed. + +## Meta Commands + +```bash +rtk gain # Show token savings analytics +rtk gain --history # Show recent command savings history +rtk proxy # Run raw command without filtering (bypass RTK) +``` + +## Verification + +```bash +rtk --version # Should show: rtk X.Y.Z +rtk gain # Should work (not "command not found") +which rtk # Verify correct binary in PATH +``` + +⚠️ **Name collision**: Two packages share the name `rtk`. If `rtk gain` +fails, you may have `reachingforthejack/rtk` (Rust Type Kit) installed +instead. See the RTK README for installation instructions. + +## How It Works + +The RTK extension intercepts every bash tool call and transparently rewrites +commands to their token-optimized equivalents before execution: + +``` +git status → rtk git status (~70% fewer tokens) +cargo test → rtk cargo test (~90% fewer tokens) +npm run build → rtk npm run build (~80% fewer tokens) +``` + +Rewrites are invisible — the LLM sees only the filtered output. diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts new file mode 100644 index 000000000..8fec20512 --- /dev/null +++ b/hooks/pi/rtk.ts @@ -0,0 +1,132 @@ +// rtk-hook-version: 1 +// 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, allow +// 1 No RTK equivalent → pass through unchanged +// 2 Deny rule matched → block execution +// 3 + stdout Ask rule matched → mutate command, allow (Pi has no confirm UI) + +import { spawn } from "node:child_process" +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" +import { isToolCallEventType } from "@mariozechner/pi-coding-agent" + +// Run a command, return { stdout, exitCode } or null on spawn error / timeout. +function exec(cmd: string, args: string[], timeoutMs = 2000): Promise<{ stdout: string; exitCode: number } | null> { + return new Promise((resolve) => { + let stdout = "" + let settled = false + + const child = spawn(cmd, args, { env: process.env }) + + const timer = setTimeout(() => { + if (!settled) { + settled = true + child.kill() + resolve(null) + } + }, timeoutMs) + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString() + }) + + child.on("close", (code: number | null) => { + if (!settled) { + settled = true + clearTimeout(timer) + resolve({ stdout, exitCode: code ?? 1 }) + } + }) + + child.on("error", () => { + if (!settled) { + settled = true + clearTimeout(timer) + resolve(null) + } + }) + }) +} + +// 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)] +} + +export default async function (pi: ExtensionAPI) { + // Load-time check: probe rtk by running --version. This confirms the binary + // is in PATH and gives us the version for the semver guard in one spawn. + const ver = await exec("rtk", ["--version"]) + if (!ver || ver.exitCode !== 0) { + console.warn("[rtk] rtk binary not found in PATH — extension disabled") + return + } + + // Load-time version guard: rtk rewrite was introduced in 0.23.0. Without + // this check the extension still degrades gracefully — exec() returns null + // on any subprocess failure, which falls through to pass-through behaviour. + // The guard exists purely to surface a clear, actionable warning + // ("your rtk is too old, upgrade") rather than leaving the user wondering + // why rewrites silently stopped working after a version rollback. + // stdout format: "rtk X.Y.Z" + const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, "")) + if (parsed) { + const [major, minor] = parsed + if (major === 0 && minor < 23) { + console.warn(`[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`) + return + } + } + + pi.on("tool_call", async (event) => { + // Only intercept bash tool calls. + if (!isToolCallEventType("bash", event)) return + + const cmd = event.input.command + if (!cmd) return + + if (cmd.startsWith("rtk ")) return + if (process.env.RTK_DISABLED === "1") return + + // Delegate all rewrite + permission logic to the RTK. + const result = await exec("rtk", ["rewrite", cmd]) + if (!result) return // spawn error or timeout — pass through + + const rewritten = result.stdout.trim() + + switch (result.exitCode) { + case 0: + // Rewrite found — mutate the command in-place. + if (rewritten && rewritten !== cmd) { + event.input.command = rewritten + } + return + + case 1: + // No RTK equivalent — pass through unchanged. + return + + case 2: + // Deny rule matched — block execution. + return { block: true, reason: `RTK: '${cmd}' is blocked — see rtk gain for details` } + + case 3: + // Ask rule matched — rewrite and allow (Pi has no per-tool confirm UI). + if (rewritten && rewritten !== cmd) { + event.input.command = rewritten + } + return + + default: + return + } + }) +} diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 1d9f33ccd..0aa435537 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -21,3 +21,12 @@ 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"; + +/// Default Pi config subdirectory under $HOME +pub const PI_DIR: &str = ".pi/agent"; +/// Subdirectory inside the Pi config dir where extensions live +pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; +/// Filename for the installed RTK extension +pub const PI_PLUGIN_FILE: &str = "rtk.ts"; +/// Environment variable that overrides the Pi config directory +pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c6bd05c2b..51662a3c9 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1,6 +1,8 @@ //! Sets up RTK hooks so AI coding agents automatically route commands through RTK. use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -12,16 +14,21 @@ use crate::hooks::constants::{ use super::constants::{ BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + GEMINI_HOOK_FILE, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, + PI_EXTENSIONS_SUBDIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; 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_PLUGIN: &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"); +const RTK_SLIM_PI: &str = include_str!("../../hooks/pi/rtk-awareness.md"); /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. @@ -541,8 +548,15 @@ fn remove_hook_from_settings(verbose: u8) -> Result { Ok(removed) } -/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. -pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { +/// Full uninstall for Claude, Gemini, Codex, Cursor, or Pi artifacts. +pub fn uninstall( + global: bool, + gemini: bool, + codex: bool, + cursor: bool, + pi: bool, + verbose: u8, +) -> Result<()> { if codex { return uninstall_codex(global, verbose); } @@ -565,6 +579,35 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: return Ok(()); } + if pi { + let plugin_path = pi_plugin_path_for_scope(global)?; + let mut removed = Vec::new(); + + if plugin_path.exists() { + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove Pi extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed Pi extension: {}", plugin_path.display()); + } + removed.push(plugin_path); + } + + let agents_md = pi_agents_md_path_for_scope(global)?; + remove_pi_awareness(&agents_md, verbose).context("Failed to remove Pi awareness block")?; + + if !removed.is_empty() { + println!("RTK uninstalled (Pi):"); + for path in &removed { + println!(" - {}", path.display()); + } + println!("\nRestart pi to apply changes."); + } else { + println!("RTK Pi extension was not installed (nothing to remove)"); + } + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -1826,6 +1869,232 @@ fn resolve_opencode_dir() -> Result { resolve_home_subdir(CONFIG_DIR).map(|p| p.join(OPENCODE_SUBDIR)) } +// ─── Pi coding agent support ────────────────────────────────────────── + +/// Resolve Pi config directory, honouring `PI_CODING_AGENT_DIR` override. +fn resolve_pi_dir() -> Result { + if let Ok(dir) = std::env::var(PI_CODING_AGENT_DIR_ENV) { + if !dir.is_empty() { + return Ok(PathBuf::from(dir)); + } + } + resolve_home_subdir(PI_DIR) +} + +/// Return the path to the installed Pi extension file. +fn pi_plugin_path(pi_dir: &Path) -> PathBuf { + pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE) +} + +/// Return the Pi extension install path for the given scope. +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` +/// global=false → `./.pi/extensions/rtk.ts` +fn pi_plugin_path_for_scope(global: bool) -> Result { + if global { + Ok(pi_plugin_path(&resolve_pi_dir()?)) + } else { + Ok(PathBuf::from(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE)) + } +} + +/// Return the AGENTS.md path for the given Pi install scope. +/// global=true → `$PI_CODING_AGENT_DIR/AGENTS.md` +/// global=false → `./AGENTS.md` +fn pi_agents_md_path_for_scope(global: bool) -> Result { + if global { + Ok(resolve_pi_dir()?.join(AGENTS_MD)) + } else { + Ok(PathBuf::from(AGENTS_MD)) + } +} + +/// Write the Pi extension file if missing or outdated. Returns true if written. +fn ensure_pi_plugin_installed(path: &Path, verbose: u8) -> Result { + write_if_changed(path, PI_PLUGIN, "Pi extension", verbose) +} + +/// Sentinel constants for the Pi awareness block in AGENTS.md. +const PI_AWARENESS_START: &str = ""; +const PI_AWARENESS_END: &str = ""; + +lazy_static! { + /// Matches the full sentinel block including any preceding newline so + /// replacement leaves no double-blank gaps. + static ref PI_SENTINEL_RE: Regex = + Regex::new(r"(?s)\n?.*?").unwrap(); +} + +/// Build the full sentinel-wrapped awareness block. +fn pi_awareness_block() -> String { + format!( + "{}\n{}\n{}", + PI_AWARENESS_START, + RTK_SLIM_PI.trim(), + PI_AWARENESS_END + ) +} + +/// Append or update the RTK awareness block in a Pi AGENTS.md file. +/// +/// Uses sentinel comments for idempotent install / version-bump updates: +/// - Sentinels absent → append block with a blank-line separator +/// - Sentinels present, content identical → no-op +/// - Sentinels present, content differs → replace block in-place +fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + let block = pi_awareness_block(); + + if agents_md_path.exists() { + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if existing.contains(&block) { + if verbose > 0 { + eprintln!( + "Pi awareness already up to date: {}", + agents_md_path.display() + ); + } + return Ok(()); + } + + let updated = if PI_SENTINEL_RE.is_match(&existing) { + // Sentinels present but content differs (version bump) — replace in-place. + PI_SENTINEL_RE + .replace(&existing, format!("\n{}", block).as_str()) + .into_owned() + } else { + // No sentinels yet — append with a blank-line separator. + format!("{}\n\n{}", existing.trim_end(), block) + }; + + atomic_write(agents_md_path, &updated) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Updated Pi awareness: {}", agents_md_path.display()); + } + } else { + // File absent — create it containing only the awareness block. + atomic_write(agents_md_path, &block) + .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!("Created Pi awareness: {}", agents_md_path.display()); + } + } + + Ok(()) +} + +/// Remove the RTK awareness sentinel block from an AGENTS.md file. +/// If the sentinels are absent this is a no-op. +fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { + if !agents_md_path.exists() { + return Ok(()); + } + + let existing = fs::read_to_string(agents_md_path) + .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; + + if !PI_SENTINEL_RE.is_match(&existing) { + return Ok(()); + } + + let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); + atomic_write(agents_md_path, updated.trim_end()) + .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; + + if verbose > 0 { + eprintln!( + "Removed Pi awareness block from: {}", + agents_md_path.display() + ); + } + + Ok(()) +} + +/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` +/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` +/// +/// AGENTS.md injection is skipped in global mode when the Pi config directory +/// did not previously exist (the user has not yet run `pi`). The extension is +/// still written so the user gets immediate coverage once they do initialise Pi. +pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { + let mut should_install_awareness = true; + + let plugin_path = if global { + let pi_dir = resolve_pi_dir()?; + // Capture existence *before* we create subdirectories. + let pi_dir_preexisting = pi_dir.exists(); + + let path = pi_plugin_path(&pi_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Pi extensions directory: {}", + parent.display() + ) + })?; + } + + if !pi_dir_preexisting { + should_install_awareness = false; + } + + path + } else { + let path = pi_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create local Pi extensions directory: {}", + parent.display() + ) + })?; + } + path + }; + + let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; + let agents_md_path = pi_agents_md_path_for_scope(global)?; + + if should_install_awareness { + install_pi_awareness(&agents_md_path, verbose)?; + print_pi_result(&plugin_path, Some(&agents_md_path), installed); + } else { + eprintln!( + "[rtk] Pi config directory not found ({}).\n\ + Extension installed but skipping AGENTS.md injection.\n\ + Create the directory or run `pi` once to initialise it.", + resolve_pi_dir()?.display() + ); + print_pi_result(&plugin_path, None, installed); + } + + Ok(()) +} + +fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { + let status = if installed { + "installed" + } else { + "already up to date" + }; + println!("RTK Pi extension {}:", status); + println!(" Extension: {}", plugin_path.display()); + if let Some(p) = agents_md_path { + println!(" AGENTS.md: {}", p.display()); + } + println!(); + println!("Pi will load the extension automatically on next start."); + println!("Verify: pi -e {} --no-session", plugin_path.display()); +} + /// Return OpenCode plugin path: ~/.config/opencode/plugins/rtk.ts fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { opencode_dir.join(PLUGIN_SUBDIR).join(OPENCODE_PLUGIN_FILE) @@ -3740,6 +4009,9 @@ mod tests { use std::sync::Mutex; static CLAUDE_DIR_LOCK: Mutex<()> = Mutex::new(()); + static PI_DIR_LOCK: Mutex<()> = Mutex::new(()); + /// Serialises all tests that mutate the process-wide working directory. + static CWD_LOCK: Mutex<()> = Mutex::new(()); fn with_claude_dir_override(tmp: &TempDir, f: F) { let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -3755,6 +4027,20 @@ mod tests { } } + fn with_pi_dir_override(tmp: &TempDir, f: F) { + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let pi_dir = tmp.path().join("pi_agent"); + fs::create_dir_all(&pi_dir).unwrap(); + + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &pi_dir); + f(&pi_dir); + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); @@ -3782,7 +4068,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, 0, false).unwrap(); - uninstall(true, false, false, false, 0).unwrap(); + uninstall(true, false, false, false, false, 0).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -3832,6 +4118,7 @@ mod tests { #[test] fn test_local_init_no_hook() { let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); @@ -3956,4 +4243,259 @@ mod tests { "RTK end marker must be removed" ); } + + // ─── Pi integration tests ─────────────────────────────────────────── + + #[test] + fn test_run_pi_mode_global_installs_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "global Pi extension must be created"); + + let content = fs::read_to_string(&plugin).unwrap(); + assert!( + content.contains("rtk rewrite"), + "extension must delegate to rtk rewrite" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_installs_plugin() { + let tmp = TempDir::new().unwrap(); + let _cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + + let result = run_pi_mode(false, 0); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!(plugin.exists(), "local Pi extension must be created"); + } + + #[test] + fn test_run_pi_mode_global_injects_awareness() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, 0).unwrap(); + + let agents_md = pi_dir.join(AGENTS_MD); + assert!(agents_md.exists(), "AGENTS.md must be created"); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert!( + content.contains(PI_AWARENESS_START), + "AGENTS.md must contain sentinel start" + ); + assert!( + content.contains(PI_AWARENESS_END), + "AGENTS.md must contain sentinel end" + ); + }); + } + + #[test] + fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + let tmp = TempDir::new().unwrap(); + let absent_dir = tmp.path().join("no_such_pi_dir"); + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &absent_dir); + + let result = run_pi_mode(true, 0); + + match orig { + Some(v) => std::env::set_var(PI_CODING_AGENT_DIR_ENV, v), + None => std::env::remove_var(PI_CODING_AGENT_DIR_ENV), + } + + result.unwrap(); + + let plugin = absent_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must be written even when dir was absent" + ); + + let agents_md = absent_dir.join(AGENTS_MD); + assert!( + !agents_md.exists(), + "AGENTS.md must not be created when Pi dir was absent" + ); + } + + #[test] + fn test_install_pi_awareness_appends_to_existing_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + fs::write(&path, "# Existing content\n").unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# Existing content")); + assert!(content.contains(PI_AWARENESS_START)); + assert!(content.contains(PI_AWARENESS_END)); + } + + #[test] + fn test_install_pi_awareness_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_first = fs::read_to_string(&path).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + let content_after_second = fs::read_to_string(&path).unwrap(); + + assert_eq!( + content_after_first, content_after_second, + "second install must be a no-op" + ); + assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); + } + + #[test] + fn test_install_pi_awareness_replaces_stale_block() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join(AGENTS_MD); + + let stale = + format!("# Existing\n\n\nstale content\n"); + fs::write(&path, &stale).unwrap(); + + install_pi_awareness(&path, 0).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!( + !content.contains("stale content"), + "old block must be replaced" + ); + assert!( + content.contains(PI_AWARENESS_START), + "new sentinel must be present" + ); + assert_eq!(content.matches(""; -const PI_AWARENESS_END: &str = ""; - -lazy_static! { - /// Matches the full sentinel block including any preceding newline so - /// replacement leaves no double-blank gaps. - static ref PI_SENTINEL_RE: Regex = - Regex::new(r"(?s)\n?.*?").unwrap(); -} - -/// Build the full sentinel-wrapped awareness block. -fn pi_awareness_block() -> String { - format!( - "{}\n{}\n{}", - PI_AWARENESS_START, - RTK_SLIM_PI.trim(), - PI_AWARENESS_END - ) -} - -/// Append or update the RTK awareness block in a Pi AGENTS.md file. -/// -/// Uses sentinel comments for idempotent install / version-bump updates: -/// - Sentinels absent → append block with a blank-line separator -/// - Sentinels present, content identical → no-op -/// - Sentinels present, content differs → replace block in-place -fn install_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result<()> { - let block = pi_awareness_block(); - - if agents_md_path.exists() { - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if existing.contains(&block) { - if verbose > 0 { - eprintln!( - "Pi awareness already up to date: {}", - agents_md_path.display() - ); - } - return Ok(()); - } - - let updated = if PI_SENTINEL_RE.is_match(&existing) { - // Sentinels present but content differs (version bump) — replace in-place. - PI_SENTINEL_RE - .replace(&existing, format!("\n{}", block).as_str()) - .into_owned() - } else { - // No sentinels yet — append with a blank-line separator. - format!("{}\n\n{}", existing.trim_end(), block) - }; - - atomic_write(agents_md_path, &updated) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Updated Pi awareness: {}", agents_md_path.display()); - } - } else { - // File absent — create it containing only the awareness block. - atomic_write(agents_md_path, &block) - .with_context(|| format!("Failed to create {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!("Created Pi awareness: {}", agents_md_path.display()); - } - } - - Ok(()) -} - -/// Remove the RTK awareness sentinel block from an AGENTS.md file. -/// Returns true if a block was found and removed, false if absent or file missing. -fn remove_pi_awareness(agents_md_path: &Path, verbose: u8) -> Result { - if !agents_md_path.exists() { - return Ok(false); - } - - let existing = fs::read_to_string(agents_md_path) - .with_context(|| format!("Failed to read {}", agents_md_path.display()))?; - - if !PI_SENTINEL_RE.is_match(&existing) { - return Ok(false); - } - - let updated = PI_SENTINEL_RE.replace(&existing, "").into_owned(); - atomic_write(agents_md_path, updated.trim_end()) - .with_context(|| format!("Failed to write {}", agents_md_path.display()))?; - - if verbose > 0 { - eprintln!( - "Removed Pi awareness block from: {}", - agents_md_path.display() - ); - } - - Ok(true) -} - -/// Install the Pi extension and inject the awareness block into AGENTS.md. +/// Install the Pi extension (hook-only; no AGENTS.md injection). /// -/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` + `$PI_CODING_AGENT_DIR/AGENTS.md` -/// global=false → `.pi/extensions/rtk.ts` + `./AGENTS.md` -/// -/// AGENTS.md injection is skipped in global mode when the Pi config directory -/// did not previously exist (the user has not yet run `pi`). The extension is -/// still written so the user gets immediate coverage once they do initialise Pi. +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` +/// global=false → `.pi/extensions/rtk.ts` pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { - let mut should_install_awareness = true; - let plugin_path = if global { let pi_dir = resolve_pi_dir()?; - // Capture existence *before* we create subdirectories. - let pi_dir_preexisting = pi_dir.exists(); - let path = pi_plugin_path(&pi_dir); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { @@ -2045,11 +1914,6 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { ) })?; } - - if !pi_dir_preexisting { - should_install_awareness = false; - } - path } else { let path = pi_plugin_path_for_scope(false)?; @@ -2065,25 +1929,12 @@ pub fn run_pi_mode(global: bool, verbose: u8) -> Result<()> { }; let installed = ensure_pi_plugin_installed(&plugin_path, verbose)?; - let agents_md_path = pi_agents_md_path_for_scope(global)?; - - if should_install_awareness { - install_pi_awareness(&agents_md_path, verbose)?; - print_pi_result(&plugin_path, Some(&agents_md_path), installed); - } else { - eprintln!( - "[rtk] Pi config directory not found ({}).\n\ - Extension installed but skipping AGENTS.md injection.\n\ - Create the directory or run `pi` once to initialise it.", - resolve_pi_dir()?.display() - ); - print_pi_result(&plugin_path, None, installed); - } + print_pi_result(&plugin_path, installed); Ok(()) } -fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: bool) { +fn print_pi_result(plugin_path: &Path, installed: bool) { let status = if installed { "installed" } else { @@ -2091,9 +1942,6 @@ fn print_pi_result(plugin_path: &Path, agents_md_path: Option<&Path>, installed: }; println!("RTK Pi extension {}:", status); println!(" Extension: {}", plugin_path.display()); - if let Some(p) = agents_md_path { - println!(" AGENTS.md: {}", p.display()); - } println!(); println!("Pi will load the extension automatically on next start."); println!("Verify: pi -e {} --no-session", plugin_path.display()); @@ -4287,28 +4135,18 @@ mod tests { } #[test] - fn test_run_pi_mode_global_injects_awareness() { + fn test_run_pi_mode_global_does_not_create_agents_md() { let tmp = TempDir::new().unwrap(); with_pi_dir_override(&tmp, |pi_dir| { run_pi_mode(true, 0).unwrap(); let agents_md = pi_dir.join(AGENTS_MD); - assert!(agents_md.exists(), "AGENTS.md must be created"); - - let content = fs::read_to_string(&agents_md).unwrap(); - assert!( - content.contains(PI_AWARENESS_START), - "AGENTS.md must contain sentinel start" - ); - assert!( - content.contains(PI_AWARENESS_END), - "AGENTS.md must contain sentinel end" - ); + assert!(!agents_md.exists(), "AGENTS.md must not be created"); }); } #[test] - fn test_run_pi_mode_global_skips_awareness_when_dir_absent() { + fn test_run_pi_mode_global_creates_plugin_when_dir_absent() { let tmp = TempDir::new().unwrap(); let absent_dir = tmp.path().join("no_such_pi_dir"); let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -4331,135 +4169,26 @@ mod tests { ); let agents_md = absent_dir.join(AGENTS_MD); - assert!( - !agents_md.exists(), - "AGENTS.md must not be created when Pi dir was absent" - ); - } - - #[test] - fn test_install_pi_awareness_appends_to_existing_file() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - fs::write(&path, "# Existing content\n").unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("# Existing content")); - assert!(content.contains(PI_AWARENESS_START)); - assert!(content.contains(PI_AWARENESS_END)); - } - - #[test] - fn test_install_pi_awareness_idempotent() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_first = fs::read_to_string(&path).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - let content_after_second = fs::read_to_string(&path).unwrap(); - - assert_eq!( - content_after_first, content_after_second, - "second install must be a no-op" - ); - assert_eq!(content_after_second.matches(PI_AWARENESS_START).count(), 1); - } - - #[test] - fn test_install_pi_awareness_replaces_stale_block() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join(AGENTS_MD); - - let stale = - format!("# Existing\n\n\nstale content\n"); - fs::write(&path, &stale).unwrap(); - - install_pi_awareness(&path, 0).unwrap(); - - let content = fs::read_to_string(&path).unwrap(); - assert!( - !content.contains("stale content"), - "old block must be replaced" - ); - assert!( - content.contains(PI_AWARENESS_START), - "new sentinel must be present" - ); - assert_eq!(content.matches("