diff --git a/README.md b/README.md index 492589ef8..bfd1c64ac 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init --agent hermes # Hermes +rtk init --agent omp # oh-my-pi (alias: oh-my-pi) # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -352,7 +353,7 @@ rtk git status ## Supported AI Tools -RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -367,6 +368,7 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | +| **oh-my-pi** | `rtk init --agent omp` | Extension TS (`tool_call` event) for `omp` binary | | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | diff --git a/hooks/README.md b/hooks/README.md index 55b2149dd..eac00cd57 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 9 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi). +Owns: per-agent hook scripts and configuration files for 10 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, oh-my-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`). @@ -41,6 +41,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` +- **[`omp/`](omp/README.md)** — TypeScript extension for `oh-my-pi` (`@oh-my-pi/pi-coding-agent`, binary `omp`); `tool_call` event, `~/.omp/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation ## Supported Agents @@ -57,6 +58,7 @@ Each agent subdirectory has its own README with hook-specific details: | Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes | +| oh-my-pi | Extension TS (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent @@ -241,7 +243,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, Pi | +| **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes, Pi, oh-my-pi | | **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | ### Eligibility diff --git a/hooks/omp/README.md b/hooks/omp/README.md new file mode 100644 index 000000000..d23cd6ae9 --- /dev/null +++ b/hooks/omp/README.md @@ -0,0 +1,63 @@ +# oh-my-pi Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +This integration is for `oh-my-pi` (`@oh-my-pi/pi-coding-agent`, binary `omp`). For `@earendil-works/pi-coding-agent` (binary `pi`), see [`../pi/`](../pi/). + +## Design Intent + +RTK's oh-my-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 oh-my-pi extensions. + +## Specifics + +- TypeScript extension using oh-my-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.40.0`; warns and registers no-op if too old or missing +- Installed to `.omp/extensions/rtk.ts` by `rtk init --agent omp` (project-local) or `~/.omp/agent/extensions/rtk.ts` by `rtk init --agent omp --global` + +## Uninstall + +```bash +# Remove project-local install (run from the project root) +rtk init --uninstall --agent omp +# → removes .omp/extensions/rtk.ts + +# Remove global install +rtk init --uninstall --agent omp --global +# → removes ~/.omp/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 +omp -e ./hooks/omp/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 omp -e ./hooks/omp/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.39.0" +# → extension logs a warning at startup and registers a no-op; omp 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 oh-my-pi's extension API +- oh-my-pi reuses the same `PI_CODING_AGENT_DIR` environment variable as the upstream pi integration; the default base directory differs (`.omp/agent` vs `.pi/agent`) diff --git a/hooks/omp/rtk.ts b/hooks/omp/rtk.ts new file mode 100644 index 000000000..5d2a3c575 --- /dev/null +++ b/hooks/omp/rtk.ts @@ -0,0 +1,82 @@ +// RTK oh-my-pi extension - rewrites bash commands to use rtk for token savings. +// For oh-my-pi (`@oh-my-pi/pi-coding-agent`, binary: `omp`). +// Distinct from the @earendil-works/pi-coding-agent integration in hooks/pi/. +// Requires: rtk >= 0.40.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 "@oh-my-pi/pi-coding-agent" +import { isToolCallEventType } from "@oh-my-pi/pi-coding-agent" + +const REWRITE_TIMEOUT_MS = 2_000 +const MIN_SUPPORTED_RTK_MINOR = 40 + +// 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 the minimum supported version. + 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.${MIN_SUPPORTED_RTK_MINOR}.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/constants.rs b/src/hooks/constants.rs index 23d2e1089..def6e5d0a 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -34,6 +34,15 @@ 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"; +// oh-my-pi (`@oh-my-pi/pi-coding-agent`, binary `omp`). +// Distinct from the PI_* constants above which target `@earendil-works/pi-coding-agent`. +// Both integrations share the PI_CODING_AGENT_DIR override env (the oh-my-pi runtime +// reads the same variable); only the default dirname differs. +pub const OMP_DIR: &str = ".omp/agent"; +pub const OMP_LOCAL_DIR: &str = ".omp"; +pub const OMP_EXTENSIONS_SUBDIR: &str = "extensions"; +pub const OMP_PLUGIN_FILE: &str = "rtk.ts"; + 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 0b1855719..8e62586a0 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -15,9 +15,10 @@ 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, - 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, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, OMP_DIR, + OMP_EXTENSIONS_SUBDIR, OMP_LOCAL_DIR, OMP_PLUGIN_FILE, 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; @@ -27,6 +28,9 @@ 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 oh-my-pi extension (auto-rewrite) +const OMP_PLUGIN: &str = include_str!("../../hooks/omp/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"); @@ -2900,6 +2904,149 @@ fn print_pi_result(plugin_path: &Path, installed: bool) { println!("Verify: pi -e {} --no-session", plugin_path.display()); } +// ─── oh-my-pi support ────────────────────────────────────────── + +/// Resolve oh-my-pi config directory. +/// +/// Honours `PI_CODING_AGENT_DIR` when set (the oh-my-pi runtime reads the same +/// override as the @earendil-works pi integration). Falls back to `~/.omp/agent`. +fn resolve_omp_dir() -> Result { + if let Ok(dir) = std::env::var(PI_CODING_AGENT_DIR_ENV) { + if !dir.is_empty() { + return Ok(PathBuf::from(dir)); + } + } + let home = dirs::home_dir().context("Could not determine home directory")?; + Ok(home.join(OMP_DIR)) +} + +/// Return the path to the installed oh-my-pi extension file. +fn omp_plugin_path(omp_dir: &Path) -> PathBuf { + omp_dir.join(OMP_EXTENSIONS_SUBDIR).join(OMP_PLUGIN_FILE) +} + +/// Return the oh-my-pi extension install path for the given scope. +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` (default `~/.omp/agent/extensions/rtk.ts`) +/// global=false → `./.omp/extensions/rtk.ts` +fn omp_plugin_path_for_scope(global: bool) -> Result { + if global { + Ok(omp_plugin_path(&resolve_omp_dir()?)) + } else { + Ok(PathBuf::from(OMP_LOCAL_DIR) + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE)) + } +} + +/// Write the oh-my-pi extension file if missing or outdated. Returns true if written. +fn ensure_omp_plugin_installed(path: &Path, ctx: InitContext) -> Result { + write_if_changed(path, OMP_PLUGIN, "oh-my-pi extension", ctx) +} + +/// Create the oh-my-pi extensions directory, or in dry-run mode, print a message +/// only if the directory does not yet exist. +fn ensure_omp_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 oh-my-pi extension for the given scope. +pub fn uninstall_omp(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + let plugin_path = omp_plugin_path_for_scope(global)?; + let mut removed: Vec = Vec::new(); + + if plugin_path.exists() { + if dry_run { + println!( + "[dry-run] would remove oh-my-pi extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- oh-my-pi uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!( + "Failed to remove oh-my-pi extension: {}", + plugin_path.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed oh-my-pi extension: {}", plugin_path.display()); + } + removed.push(format!("oh-my-pi extension: {}", plugin_path.display())); + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (oh-my-pi):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart omp to apply changes."); + } else { + println!("RTK oh-my-pi extension was not installed (nothing to remove)"); + } + Ok(()) +} + +/// Install the oh-my-pi extension (hook-only; no AGENTS.md injection). +/// +/// global=true → `$PI_CODING_AGENT_DIR/extensions/rtk.ts` (default `~/.omp/agent/extensions/rtk.ts`) +/// global=false → `.omp/extensions/rtk.ts` +pub fn run_omp_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { + verbose: _, + dry_run, + } = ctx; + let plugin_path = if global { + let omp_dir = resolve_omp_dir()?; + let path = omp_plugin_path(&omp_dir); + if let Some(parent) = path.parent() { + ensure_omp_extensions_dir(parent, "oh-my-pi extensions directory", ctx)?; + } + path + } else { + let path = omp_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + ensure_omp_extensions_dir(parent, "local oh-my-pi extensions directory", ctx)?; + } + path + }; + + let installed = ensure_omp_plugin_installed(&plugin_path, ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + print_omp_result(&plugin_path, installed); + } + + Ok(()) +} + +fn print_omp_result(plugin_path: &Path, installed: bool) { + let status = if installed { + "installed" + } else { + "already up to date" + }; + println!("RTK oh-my-pi extension {}:", status); + println!(" Extension: {}", plugin_path.display()); + println!(); + println!("omp will load the extension automatically on next start."); + println!("Verify: omp -e {}", 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) @@ -5792,6 +5939,20 @@ mod tests { } } + fn with_omp_dir_override(tmp: &TempDir, f: F) { + let _guard = PI_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let omp_dir = tmp.path().join("omp_agent"); + fs::create_dir_all(&omp_dir).unwrap(); + + let orig = std::env::var_os(PI_CODING_AGENT_DIR_ENV); + std::env::set_var(PI_CODING_AGENT_DIR_ENV, &omp_dir); + f(&omp_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(); @@ -6338,6 +6499,251 @@ mod tests { ); } + // ─── oh-my-pi integration tests ───────────────────────────────────── + + #[test] + fn test_run_omp_mode_global_installs_plugin() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + run_omp_mode(true, InitContext::default()).unwrap(); + + let plugin = omp_dir.join(OMP_EXTENSIONS_SUBDIR).join(OMP_PLUGIN_FILE); + assert!(plugin.exists(), "global oh-my-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_omp_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_omp_mode(false, InitContext::default()); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + let plugin = tmp + .path() + .join(".omp") + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE); + assert!(plugin.exists(), "local oh-my-pi extension must be created"); + } + + #[test] + fn test_run_omp_mode_global_does_not_create_agents_md() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + run_omp_mode(true, InitContext::default()).unwrap(); + + let agents_md = omp_dir.join(AGENTS_MD); + assert!(!agents_md.exists(), "AGENTS.md must not be created"); + }); + } + + #[test] + fn test_run_omp_mode_global_creates_plugin_when_dir_absent() { + let tmp = TempDir::new().unwrap(); + let absent_dir = tmp.path().join("no_such_omp_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_omp_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(OMP_EXTENSIONS_SUBDIR).join(OMP_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_omp_global_uninstall_removes_plugin() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + run_omp_mode(true, InitContext::default()).unwrap(); + + let plugin = omp_dir.join(OMP_EXTENSIONS_SUBDIR).join(OMP_PLUGIN_FILE); + assert!(plugin.exists()); + + uninstall_omp(true, InitContext::default()).unwrap(); + + assert!(!plugin.exists(), "plugin must be removed"); + }); + } + + #[test] + fn test_omp_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_omp_mode(false, InitContext::default()).unwrap(); + let result = uninstall_omp(false, InitContext::default()); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + let plugin = tmp + .path() + .join(".omp") + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE); + assert!(!plugin.exists(), "local plugin must be removed"); + } + + #[test] + fn test_omp_plugin_path_for_scope_global() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + let path = omp_plugin_path_for_scope(true).unwrap(); + assert_eq!( + path, + omp_dir.join(OMP_EXTENSIONS_SUBDIR).join(OMP_PLUGIN_FILE) + ); + }); + } + + #[test] + fn test_omp_plugin_path_for_scope_local() { + let path = omp_plugin_path_for_scope(false).unwrap(); + assert_eq!( + path, + PathBuf::from(OMP_LOCAL_DIR) + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE) + ); + } + + #[test] + fn test_run_omp_mode_global_dry_run_writes_nothing() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + run_omp_mode( + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + !omp_dir.join(OMP_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create the oh-my-pi extensions directory" + ); + assert!( + !omp_dir + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE) + .exists(), + "dry-run must not create the oh-my-pi extension file" + ); + }); + } + + #[test] + fn test_run_omp_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_omp_mode( + false, + InitContext { + verbose: 0, + dry_run: true, + }, + ); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + + assert!( + !tmp.path().join(".omp").join(OMP_EXTENSIONS_SUBDIR).exists(), + "dry-run must not create .omp/extensions/" + ); + } + + #[test] + fn test_omp_global_uninstall_dry_run_keeps_plugin() { + let tmp = TempDir::new().unwrap(); + with_omp_dir_override(&tmp, |omp_dir| { + run_omp_mode(true, InitContext::default()).unwrap(); + let plugin = omp_dir.join(OMP_EXTENSIONS_SUBDIR).join(OMP_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + uninstall_omp( + true, + InitContext { + verbose: 0, + dry_run: true, + }, + ) + .unwrap(); + + assert!( + plugin.exists(), + "dry-run uninstall must not remove the oh-my-pi extension" + ); + }); + } + + #[test] + fn test_omp_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_omp_mode(false, InitContext::default()).unwrap(); + let plugin = tmp + .path() + .join(".omp") + .join(OMP_EXTENSIONS_SUBDIR) + .join(OMP_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must exist before uninstall dry-run" + ); + + let result = uninstall_omp( + false, + 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 oh-my-pi extension" + ); + } + // ─── Copilot tests ─────────────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index 992f865a2..3b05d4ddf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,9 @@ pub enum AgentTarget { Antigravity, /// Pi coding agent Pi, + /// oh-my-pi coding agent (binary: omp) + #[value(name = "omp", alias = "oh-my-pi")] + OhMyPi, /// Hermes CLI Hermes, } @@ -1392,6 +1395,8 @@ where { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) + } else if agent == Some(AgentTarget::OhMyPi) { + hooks::init::uninstall_omp(global, ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); let pi = agent == Some(AgentTarget::Pi); @@ -1865,6 +1870,8 @@ fn run_cli() -> Result { } } else if agent == Some(AgentTarget::Pi) { hooks::init::run_pi_mode(global, ctx)? + } else if agent == Some(AgentTarget::OhMyPi) { + hooks::init::run_omp_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { if global { anyhow::bail!("Kilo Code is project-scoped. Use: rtk init --agent kilocode"); @@ -3272,4 +3279,53 @@ mod tests { _ => panic!("Expected Init command"), } } + + #[test] + fn test_init_agent_omp_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "omp"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!( + agent, + Some(AgentTarget::OhMyPi), + "--agent omp must set OhMyPi variant" + ); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_agent_oh_my_pi_alias_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--agent", "oh-my-pi"]).unwrap(); + match cli.command { + Commands::Init { agent, .. } => { + assert_eq!( + agent, + Some(AgentTarget::OhMyPi), + "--agent oh-my-pi alias must set OhMyPi variant" + ); + } + _ => panic!("Expected Init command"), + } + } + + #[test] + fn test_init_uninstall_agent_omp_parses() { + let cli = Cli::try_parse_from(["rtk", "init", "--uninstall", "--agent", "omp", "--global"]) + .unwrap(); + match cli.command { + Commands::Init { + agent, + uninstall, + global, + .. + } => { + assert!(uninstall); + assert_eq!(agent, Some(AgentTarget::OhMyPi)); + assert!(global); + } + _ => panic!("Expected Init command"), + } + } }