diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 561f9de15..b7fb920de 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -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 | @@ -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 @@ -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 ` | -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 diff --git a/hooks/README.md b/hooks/README.md index 0879de9bb..55b2149dd 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 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`). @@ -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 @@ -55,6 +56,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 | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -62,6 +64,7 @@ Each agent subdirectory has its own README with hook-specific details: ### Claude Code (Shell Hook) **Input** (stdin): + ```json { "tool_name": "Bash", @@ -70,6 +73,7 @@ Each agent subdirectory has its own README with hook-specific details: ``` **Output** (stdout, when rewritten): + ```json { "hookSpecificOutput": { @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -136,6 +145,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ``` **Output** (when rewritten): + ```json { "decision": "allow", @@ -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() @@ -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 diff --git a/hooks/pi/README.md b/hooks/pi/README.md new file mode 100644 index 000000000..1f0c8e00a --- /dev/null +++ b/hooks/pi/README.md @@ -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 diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts new file mode 100644 index 000000000..32cb2b5f1 --- /dev/null +++ b/hooks/pi/rtk.ts @@ -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 { + 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 + } + }) +} diff --git a/src/hooks/README.md b/src/hooks/README.md index 01a0213cb..a0c76b76d 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 (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/`). @@ -22,7 +22,7 @@ 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 | @@ -30,6 +30,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | 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` | diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 85340510c..506e88cdf 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -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"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9af21dc57..189f5de55 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -14,14 +14,18 @@ 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, - REWRITE_HOOK_FILE, SETTINGS_JSON, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, 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"); @@ -600,12 +604,13 @@ fn remove_hook_from_settings(ctx: InitContext) -> Result { Ok(removed) } -/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. +/// Full uninstall for Claude, Gemini, Codex, Cursor, or Pi artifacts. pub fn uninstall( global: bool, gemini: bool, codex: bool, cursor: bool, + pi: bool, ctx: InitContext, ) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -644,6 +649,11 @@ pub fn uninstall( return Ok(()); } + if pi { + uninstall_pi(global, ctx)?; + return Ok(()); + } + if !global { anyhow::bail!("Uninstall only works with --global flag. For local projects, manually remove RTK from CLAUDE.md"); } @@ -2751,6 +2761,144 @@ 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_LOCAL_DIR) + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE)) + } +} + +/// Write the Pi extension file if missing or outdated. Returns true if written. +fn ensure_pi_plugin_installed(path: &Path, ctx: InitContext) -> Result { + write_if_changed(path, PI_PLUGIN, "Pi extension", ctx) +} + +/// Create the Pi extensions directory, or in dry-run mode, print a message only if +/// the directory does not yet exist (avoids reporting no-op changes). +fn ensure_pi_extensions_dir(parent: &Path, name: &str, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + if dry_run { + if !parent.exists() { + println!("[dry-run] would create {}: {}", name, parent.display()); + } + } else { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}: {}", name, parent.display()))?; + } + Ok(()) +} + +/// Uninstall Pi extension for the given scope. +/// Mirrors `uninstall_codex` / `uninstall_hermes`: extracted from the dispatcher +/// so it can be tested and reasoned about independently. +fn uninstall_pi(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + let plugin_path = pi_plugin_path_for_scope(global)?; + let mut removed: Vec = Vec::new(); + + if plugin_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- Pi uninstall removes only the RTK-managed extension file. + 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(format!("Pi extension: {}", plugin_path.display())); + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (Pi):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart pi to apply changes."); + } else { + println!("RTK Pi extension was not installed (nothing to remove)"); + } + Ok(()) +} + +/// Install the Pi extension (hook-only; no AGENTS.md injection). +/// +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` +/// global=false → `.pi/extensions/rtk.ts` +pub fn run_pi_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { + verbose: _, + dry_run, + } = ctx; + let plugin_path = if global { + let pi_dir = resolve_pi_dir()?; + let path = pi_plugin_path(&pi_dir); + if let Some(parent) = path.parent() { + ensure_pi_extensions_dir(parent, "Pi extensions directory", ctx)?; + } + path + } else { + let path = pi_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + ensure_pi_extensions_dir(parent, "local Pi extensions directory", ctx)?; + } + path + }; + + let installed = ensure_pi_plugin_installed(&plugin_path, ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + print_pi_result(&plugin_path, installed); + } + + Ok(()) +} + +fn print_pi_result(plugin_path: &Path, installed: bool) { + let status = if installed { + "installed" + } else { + "already up to date" + }; + println!("RTK Pi extension {}:", status); + println!(" Extension: {}", plugin_path.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) @@ -5396,6 +5544,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()); @@ -5411,6 +5562,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(); @@ -5438,7 +5603,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - uninstall(true, false, false, false, InitContext::default()).unwrap(); + uninstall(true, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = @@ -5488,6 +5653,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(); @@ -5565,7 +5731,7 @@ mod tests { dry_run: true, ..Default::default() }; - uninstall(true, false, false, false, dry).unwrap(); + uninstall(true, false, false, false, false, dry).unwrap(); // Files must still exist with identical content assert!( @@ -5679,6 +5845,285 @@ mod tests { ); } + #[test] + fn test_claude_md_mode_refuses_malformed_block() { + // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed + // CLAUDE.md previously emitted a warning and exited 0, silently + // skipping the OpenCode plugin step. The shared `write_rtk_block` + // dispatcher now bails for both paths. + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let claude_md = claude_dir.join(CLAUDE_MD); + let malformed = format!( + "# Existing notes\n\n{}\nincomplete RTK block\n", + RTK_BLOCK_START + ); + fs::write(&claude_md, &malformed).unwrap(); + + let result = run_claude_md_mode(true, false, InitContext::default()); + + assert!( + result.is_err(), + "Malformed CLAUDE.md must cause a hard error, not silent skip" + ); + + let after = fs::read_to_string(&claude_md).unwrap(); + assert_eq!(after, malformed, "File must not be modified when malformed"); + }); + } + + // ─── 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, InitContext::default()).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, InitContext::default()); + 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_does_not_create_agents_md() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + + let agents_md = pi_dir.join(AGENTS_MD); + assert!(!agents_md.exists(), "AGENTS.md must not be created"); + }); + } + + #[test] + 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()); + 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, InitContext::default()); + + 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"); + } + + #[test] + fn test_pi_global_uninstall_removes_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!(plugin.exists()); + + uninstall(true, false, false, false, true, InitContext::default()).unwrap(); + + assert!(!plugin.exists(), "plugin must be removed"); + }); + } + + #[test] + fn test_pi_local_uninstall_removes_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(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let result = uninstall(false, false, false, false, true, InitContext::default()); + 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 plugin must be removed"); + } + + #[test] + fn test_pi_plugin_path_for_scope_global() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + let path = pi_plugin_path_for_scope(true).unwrap(); + assert_eq!(path, pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE)); + }); + } + + #[test] + fn test_pi_plugin_path_for_scope_local() { + let path = pi_plugin_path_for_scope(false).unwrap(); + assert_eq!( + path, + PathBuf::from(PI_LOCAL_DIR) + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE) + ); + } + + #[test] + fn test_run_pi_mode_global_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode( + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + !pi_dir.join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create the Pi extensions directory" + ); + assert!( + !pi_dir + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE) + .exists(), + "dry-run must not create the Pi extension file" + ); + }); + } + + #[test] + fn test_run_pi_mode_local_dry_run_writes_nothing() { + 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, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + !tmp.path().join(".pi").join(PI_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create .pi/extensions/" + ); + } + + #[test] + fn test_pi_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_pi_dir_override(&tmp, |pi_dir| { + run_pi_mode(true, InitContext::default()).unwrap(); + let plugin = pi_dir.join(PI_EXTENSIONS_SUBDIR).join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall( + true, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the Pi extension" + ); + }); + } + + #[test] + fn test_pi_local_uninstall_dry_run_keeps_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(); + + run_pi_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".pi") + .join(PI_EXTENSIONS_SUBDIR) + .join(PI_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall( + false, + false, + false, + false, + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the local Pi extension" + ); + } + + // ─── Copilot tests ─────────────────────────────────────────────── + #[test] fn test_copilot_init_preserves_existing_instructions() { let temp = TempDir::new().unwrap(); @@ -5855,31 +6300,4 @@ mod tests { hook_path.display() ); } - - #[test] - fn test_claude_md_mode_refuses_malformed_block() { - // Mirrors `test_copilot_init_refuses_malformed_block`: a malformed - // CLAUDE.md previously emitted a warning and exited 0, silently - // skipping the OpenCode plugin step. The shared `write_rtk_block` - // dispatcher now bails for both paths. - let tmp = TempDir::new().unwrap(); - with_claude_dir_override(&tmp, |claude_dir| { - let claude_md = claude_dir.join(CLAUDE_MD); - let malformed = format!( - "# Existing notes\n\n{}\nincomplete RTK block\n", - RTK_BLOCK_START - ); - fs::write(&claude_md, &malformed).unwrap(); - - let result = run_claude_md_mode(true, false, InitContext::default()); - - assert!( - result.is_err(), - "Malformed CLAUDE.md must cause a hard error, not silent skip" - ); - - let after = fs::read_to_string(&claude_md).unwrap(); - assert_eq!(after, malformed, "File must not be modified when malformed"); - }); - } } diff --git a/src/main.rs b/src/main.rs index c1a897190..22e6cbca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,8 @@ pub enum AgentTarget { Kilocode, /// Google Antigravity Antigravity, + /// Pi coding agent + Pi, /// Hermes CLI Hermes, } @@ -374,7 +376,6 @@ enum Commands { /// Install GitHub Copilot integration (VS Code + CLI) #[arg(long)] copilot: bool, - /// Preview changes without writing any files (combine with -v to show content) #[arg(long = "dry-run", conflicts_with = "show")] dry_run: bool, @@ -1376,13 +1377,14 @@ fn uninstall_init_dispatch( ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, - UninstallStandard: FnOnce(bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, + UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); - uninstall_standard(global, gemini, codex, cursor, ctx) + let pi = agent == Some(AgentTarget::Pi); + uninstall_standard(global, gemini, codex, cursor, pi, ctx) } } @@ -1840,6 +1842,8 @@ fn run_cli() -> Result { hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { hooks::init::run_copilot(ctx)?; + } else if agent == Some(AgentTarget::Pi) { + hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); @@ -2705,7 +2709,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _| { + |_, _, _, _, _, _| { standard_called.set(true); Ok(()) }, @@ -3172,4 +3176,45 @@ mod tests { _ => panic!("Expected Commands::Npx for unknown tool"), } } + + #[test] + fn test_init_pi_flag_rejected() { + // --pi has been removed; --agent pi is the canonical form + let result = Cli::try_parse_from(["rtk", "init", "--pi"]); + assert!(result.is_err(), "--pi must be rejected as unknown argument"); + } + + #[test] + fn test_init_agent_pi_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "pi"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!( + agent, + Some(AgentTarget::Pi), + "--agent pi must set Pi variant" + ); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_uninstall_agent_pi_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "pi", "--global"]) + .unwrap(); + match cli.command { + Commands::Init { + uninstall, + agent, + global, + .. + } => { + assert!(uninstall); + assert_eq!(agent, Some(AgentTarget::Pi)); + assert!(global); + } + _ => panic!("Expected Init command"), + } + } }