Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
|------|---------|--------|
Expand All @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down
84 changes: 84 additions & 0 deletions hooks/pi-rtk.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
}
134 changes: 133 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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)?,
Expand Down Expand Up @@ -521,7 +532,14 @@ fn remove_hook_from_settings(verbose: u8) -> Result<bool> {
}

/// 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);
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -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)");
Expand Down Expand Up @@ -1573,6 +1614,82 @@ fn remove_opencode_plugin(verbose: u8) -> Result<Vec<PathBuf>> {
Ok(removed)
}

/// Resolve pi agent directory (~/.pi/agent)
fn resolve_pi_agent_dir() -> Result<PathBuf> {
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<PathBuf> {
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<bool> {
write_if_changed(path, PI_PLUGIN, "pi extension", verbose)
}

/// Remove pi extension file
fn remove_pi_plugin(verbose: u8) -> Result<Vec<PathBuf>> {
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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -2536,6 +2666,7 @@ More notes
false,
false,
false,
false,
true,
PatchMode::Auto,
0,
Expand All @@ -2558,6 +2689,7 @@ More notes
false,
false,
false,
false,
true,
PatchMode::Skip,
0,
Expand Down
9 changes: 7 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub enum AgentTarget {
Windsurf,
/// Cline / Roo Code (VS Code)
Cline,
/// pi coding agent
Pi,
}

#[derive(Parser)]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -1724,6 +1728,7 @@ fn main() -> Result<()> {
install_cursor,
install_windsurf,
install_cline,
install_pi,
claude_md,
hook_only,
codex,
Expand Down