From 452c22f2dcd1e0c324aadf860113a5b3a5e805d2 Mon Sep 17 00:00:00 2001 From: yhm404 Date: Wed, 25 Mar 2026 18:32:35 +0800 Subject: [PATCH] Add pi agent integration Signed-off-by: yhm404 --- README.md | 12 ++++- hooks/pi-rtk.ts | 84 ++++++++++++++++++++++++++++++ src/init.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 9 +++- 4 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 hooks/pi-rtk.ts diff --git a/README.md b/README.md index 6073d5eb..4a7de539 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ rtk init -g --codex # Codex (OpenAI) rtk init -g --agent cursor # Cursor rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code +rtk init -g --agent pi # pi coding agent # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -296,7 +297,7 @@ After install, **restart Claude Code**. ## Supported AI Tools -RTK supports 9 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. +RTK supports 10 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. | Tool | Install | Method | |------|---------|--------| @@ -309,6 +310,7 @@ RTK supports 9 AI coding tools. Each integration transparently rewrites shell co | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | +| **pi** | `rtk init -g --agent pi` | Extension TS (bash override) | ### Claude Code (default) @@ -384,6 +386,14 @@ openclaw plugins install ./openclaw Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`. +### pi + +```bash +rtk init -g --agent pi +``` + +Creates `~/.pi/agent/extensions/rtk-bash/index.ts`. The extension overrides pi's `bash` tool and delegates to `rtk rewrite` before execution. Run `/reload` in pi (or restart pi) after installation. + ### Commands Rewritten | Raw Command | Rewritten To | diff --git a/hooks/pi-rtk.ts b/hooks/pi-rtk.ts new file mode 100644 index 00000000..f8a04221 --- /dev/null +++ b/hooks/pi-rtk.ts @@ -0,0 +1,84 @@ +import { createBashTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { spawnSync } from "node:child_process"; + +// 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. + +type RewriteResult = { + command: string; + rewritten: boolean; +}; + +const RTK_BIN = process.env.PI_RTK_BIN || "rtk"; +const RTK_TIMEOUT_MS = Number.parseInt(process.env.PI_RTK_TIMEOUT_MS || "800", 10); + +let rtkAvailabilityChecked = false; +let rtkAvailable = false; + +function ensureRtkAvailable(env: NodeJS.ProcessEnv): boolean { + if (rtkAvailabilityChecked) return rtkAvailable; + + const probe = spawnSync(RTK_BIN, ["--help"], { + encoding: "utf8", + env, + timeout: Math.min(RTK_TIMEOUT_MS, 1000), + }); + + rtkAvailabilityChecked = true; + rtkAvailable = probe.status === 0 || !probe.error; + return rtkAvailable; +} + +function rewriteCommand(command: string, cwd: string, env: NodeJS.ProcessEnv): RewriteResult { + if (!command.trim() || !ensureRtkAvailable(env)) { + return { command, rewritten: false }; + } + + const result = spawnSync(RTK_BIN, ["rewrite", command], { + cwd, + encoding: "utf8", + env, + timeout: RTK_TIMEOUT_MS, + }); + + if (result.error || result.status !== 0) { + return { command, rewritten: false }; + } + + const rewritten = (result.stdout || "").trim(); + if (!rewritten || rewritten === command) { + return { command, rewritten: false }; + } + + return { command: rewritten, rewritten: true }; +} + +export default function (pi: ExtensionAPI) { + const bashTool = createBashTool(process.cwd(), { + spawnHook: ({ command, cwd, env }) => { + const rewrite = rewriteCommand(command, cwd, env); + return { + command: rewrite.command, + cwd, + env: { + ...env, + PI_RTK_ACTIVE: rewrite.rewritten ? "1" : "0", + }, + }; + }, + }); + + pi.registerTool({ + ...bashTool, + label: "bash (rtk)", + description: + "Execute bash commands, first attempting RTK rewrite with automatic fallback to the original command.", + execute: async (toolCallId, params, signal, onUpdate) => { + return bashTool.execute(toolCallId, params, signal, onUpdate); + }, + }); +} diff --git a/src/init.rs b/src/init.rs index 494bef34..3595b249 100644 --- a/src/init.rs +++ b/src/init.rs @@ -14,6 +14,7 @@ const CURSOR_REWRITE_HOOK: &str = include_str!("../hooks/cursor-rtk-rewrite.sh") // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../hooks/opencode-rtk.ts"); +const PI_PLUGIN: &str = include_str!("../hooks/pi-rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md"); @@ -212,6 +213,7 @@ pub fn run( install_cursor: bool, install_windsurf: bool, install_cline: bool, + install_pi: bool, claude_md: bool, hook_only: bool, codex: bool, @@ -251,6 +253,10 @@ pub fn run( anyhow::bail!("Windsurf support is global-only. Use: rtk init -g --agent windsurf"); } + if install_pi && !global { + anyhow::bail!("pi extension is global-only. Use: rtk init -g --agent pi"); + } + // Windsurf-only mode if install_windsurf { return run_windsurf_mode(verbose); @@ -261,6 +267,11 @@ pub fn run( return run_cline_mode(verbose); } + // pi-only mode + if install_pi { + return run_pi_mode(verbose); + } + // Mode selection (Claude Code / OpenCode) match (install_claude, install_opencode, claude_md, hook_only) { (false, true, _, _) => run_opencode_only_mode(verbose)?, @@ -521,7 +532,14 @@ fn remove_hook_from_settings(verbose: u8) -> Result { } /// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts. -pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> { +pub fn uninstall( + global: bool, + gemini: bool, + codex: bool, + cursor: bool, + pi: bool, + verbose: u8, +) -> Result<()> { if codex { return uninstall_codex(global, verbose); } @@ -544,6 +562,23 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: return Ok(()); } + if pi { + if !global { + anyhow::bail!("pi uninstall only works with --global flag"); + } + let pi_removed = remove_pi_plugin(verbose).context("Failed to remove pi extension")?; + if !pi_removed.is_empty() { + println!("RTK uninstalled (pi):"); + for item in &pi_removed { + println!(" - {}", item.display()); + } + println!("\nRestart pi or run /reload to apply changes."); + } else { + println!("RTK pi support 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"); } @@ -626,6 +661,12 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: let cursor_removed = remove_cursor_hooks(verbose)?; removed.extend(cursor_removed); + // 7. Remove pi extension + let pi_removed = remove_pi_plugin(verbose)?; + 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)"); @@ -1573,6 +1614,82 @@ fn remove_opencode_plugin(verbose: u8) -> Result> { Ok(removed) } +/// Resolve pi agent directory (~/.pi/agent) +fn resolve_pi_agent_dir() -> Result { + dirs::home_dir() + .map(|h| h.join(".pi").join("agent")) + .context("Cannot determine home directory. Is $HOME set?") +} + +/// Return pi extension path: ~/.pi/agent/extensions/rtk-bash/index.ts +fn pi_plugin_path(agent_dir: &Path) -> PathBuf { + agent_dir + .join("extensions") + .join("rtk-bash") + .join("index.ts") +} + +/// Prepare pi extension directory and return install path +fn prepare_pi_plugin_path() -> Result { + let agent_dir = resolve_pi_agent_dir()?; + let path = pi_plugin_path(&agent_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create pi extension directory: {}", + parent.display() + ) + })?; + } + Ok(path) +} + +/// Write pi extension file if missing or outdated +fn ensure_pi_plugin_installed(path: &Path, verbose: u8) -> Result { + write_if_changed(path, PI_PLUGIN, "pi extension", verbose) +} + +/// Remove pi extension file +fn remove_pi_plugin(verbose: u8) -> Result> { + let agent_dir = resolve_pi_agent_dir()?; + let path = pi_plugin_path(&agent_dir); + let mut removed = Vec::new(); + + if path.exists() { + 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.clone()); + } + + if let Some(parent) = path.parent() { + if parent.exists() && fs::read_dir(parent)?.next().is_none() { + let _ = fs::remove_dir(parent); + } + } + + Ok(removed) +} + +fn run_pi_mode(verbose: u8) -> Result<()> { + let path = prepare_pi_plugin_path()?; + let changed = ensure_pi_plugin_installed(&path, verbose)?; + + let status = if changed { + "installed/updated" + } else { + "already up to date" + }; + println!("\npi extension {} (global).\n", status); + println!(" Extension: {}", path.display()); + println!(" Reload pi with: /reload"); + println!(" Test with: git status\n"); + + Ok(()) +} + // ─── Cursor Agent support ───────────────────────────────────────────── /// Resolve ~/.cursor directory @@ -1952,6 +2069,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 plugin = pi_plugin_path(&pi_agent_dir); + if plugin.exists() { + println!("[ok] pi: extension installed ({})", plugin.display()); + } else { + println!("[--] pi: extension not found"); + } + } else { + println!("[--] pi: home dir not found"); + } + // Check Cursor hooks if let Ok(cursor_dir) = resolve_cursor_dir() { let cursor_hook = cursor_dir.join("hooks").join("rtk-rewrite.sh"); @@ -2026,6 +2155,7 @@ fn show_claude_config() -> Result<()> { println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); println!(" rtk init -g --codex # Configure ~/.codex/AGENTS.md + ~/.codex/RTK.md"); println!(" rtk init -g --opencode # OpenCode plugin only"); + println!(" rtk init -g --agent pi # Install pi extension"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); Ok(()) @@ -2536,6 +2666,7 @@ More notes false, false, false, + false, true, PatchMode::Auto, 0, @@ -2558,6 +2689,7 @@ More notes false, false, false, + false, true, PatchMode::Skip, 0, diff --git a/src/main.rs b/src/main.rs index 0ff5124c..d6884f0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,8 @@ pub enum AgentTarget { Windsurf, /// Cline / Roo Code (VS Code) Cline, + /// pi coding agent + Pi, } #[derive(Parser)] @@ -1693,7 +1695,8 @@ fn main() -> Result<()> { init::show_config(codex)?; } else if uninstall { let cursor = agent == Some(AgentTarget::Cursor); - init::uninstall(global, gemini, codex, cursor, cli.verbose)?; + let pi = agent == Some(AgentTarget::Pi); + init::uninstall(global, gemini, codex, cursor, pi, cli.verbose)?; } else if gemini { let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1705,10 +1708,11 @@ fn main() -> Result<()> { init::run_gemini(global, hook_only, patch_mode, cli.verbose)?; } else { let install_opencode = opencode; - let install_claude = !opencode; + let install_claude = !opencode && agent != Some(AgentTarget::Pi); let install_cursor = agent == Some(AgentTarget::Cursor); let install_windsurf = agent == Some(AgentTarget::Windsurf); let install_cline = agent == Some(AgentTarget::Cline); + let install_pi = agent == Some(AgentTarget::Pi); let patch_mode = if auto_patch { init::PatchMode::Auto @@ -1724,6 +1728,7 @@ fn main() -> Result<()> { install_cursor, install_windsurf, install_cline, + install_pi, claude_md, hook_only, codex,