From 8a23aa8e0134a680133816ffb5552fa9b7850228 Mon Sep 17 00:00:00 2001 From: David Marshall Date: Wed, 13 May 2026 09:07:51 -0500 Subject: [PATCH 1/2] feat(omp): add Oh My Pi hook integration with rewrite, discover, and uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OMP hook (hooks/omp/rtk.ts): - Intercepts bash tool_call events, delegates to `rtk rewrite` - Exit code 0 (allow), 1 (no equivalent), 2 (deny — silent passthrough), 3 (ask) - Thin spawner wrapper enables test injection without real subprocess - 2-second spawn timeout with cleanup - Fail-open: missing rtk, spawn errors, unexpected exits all passthrough Test suite (hooks/omp/tests/rtk.test.ts): - 13 behavioral tests covering all exit codes, error paths, edge cases - Mirrors Hermes test pattern with fake subprocess injection Discover integration (src/discover/): - AgentIntegrationStatus extended with omp_hook_installed detection - detect_from_home checks ~/.omp/agent/hooks/pre/rtk.ts - append_agent_notes reports OMP alongside Cursor and Hermes hook_check integration (src/hooks/hook_check.rs): - OMP hook path added to other_integration_installed detection - Tests cover OMP hook presence in empty-dir guard Init support (src/hooks/init.rs, src/main.rs): - `rtk init --agent omp` / `rtk init -g --agent omp` for project/global - OMP extension file installation with collision/idempotency checks - `rtk init --uninstall` handles OMP cleanup via uninstall_init_dispatch - run_omp_mode, install_omp_extension_file, uninstall_omp functions - OMP_GLOBAL_HOOK_PATH / OMP_PROJECT_HOOK_PATH constants Documentation: - README, supported-agents guide, TECHNICAL.md updated for OMP - Hook README documents installation paths and verdict behavior Signed-off-by: David Marshall --- Cargo.lock | 2 +- README.md | 5 +- docs/contributing/TECHNICAL.md | 2 + .../guide/getting-started/supported-agents.md | 10 + hooks/README.md | 2 + hooks/omp/README.md | 13 + hooks/omp/rtk.ts | 86 ++++ hooks/omp/tests/rtk.test.ts | 209 ++++++++++ src/discover/report.rs | 28 +- src/hooks/README.md | 1 + src/hooks/constants.rs | 3 + src/hooks/hook_check.rs | 19 + src/hooks/init.rs | 376 +++++++++++++++++- src/main.rs | 17 +- 14 files changed, 761 insertions(+), 12 deletions(-) create mode 100644 hooks/omp/README.md create mode 100644 hooks/omp/rtk.ts create mode 100644 hooks/omp/tests/rtk.test.ts diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8..79fa30e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/README.md b/README.md index a0db81d25..2049af735 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ rtk gain # Should show token savings stats rtk init -g # Claude Code / Copilot (default) rtk init -g --gemini # Gemini CLI rtk init -g --codex # Codex (OpenAI) +rtk init --agent omp # Oh My Pi (OMP, project hook) +rtk init -g --agent omp # Oh My Pi (OMP, global hook) rtk init -g --agent cursor # Cursor rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code @@ -351,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 13 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. | Tool | Install | Method | |------|---------|--------| @@ -361,6 +363,7 @@ RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rt | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | | **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Oh My Pi (OMP)** | `rtk init --agent omp` / `rtk init -g --agent omp` | OMP hook (`tool_call`) — `./.omp/hooks/pre/rtk.ts` or `~/.omp/agent/hooks/pre/rtk.ts` | | **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index ddadf8d73..43f5a0963 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -316,6 +316,7 @@ Start here, then drill down into each README for file-level details. | [`cline/`](../hooks/cline/README.md) | Cline / Roo Code | Rules file (prompt-level, no programmatic hook) | | [`windsurf/`](../hooks/windsurf/README.md) | Windsurf / Cascade | Rules file (workspace-scoped) | | [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Awareness document, AGENTS.md integration | +| [`omp/`](../hooks/omp/README.md) | Oh My Pi | TypeScript hook module, project/user `.omp/hooks/pre/` or `~/.omp/agent/hooks/pre/` | | [`opencode/`](../hooks/opencode/README.md) | OpenCode | TypeScript plugin, zx library, in-place mutation | --- @@ -334,6 +335,7 @@ RTK supports the following LLM agents through hook integrations: | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | | Windsurf | Rules file | Prompt-level guidance | N/A (prompt) | | Codex CLI | Awareness doc | AGENTS.md integration | N/A (prompt) | +| Oh My Pi | Hook module | OMP `tool_call` event | Yes (`event.input`) | | OpenCode | TS plugin | `tool.execute.before` event | Yes (in-place mutation) | > **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, integrity verification, and the rewrite command. diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 561f9de15..85f94e63a 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -36,6 +36,7 @@ Agent runs "cargo test" | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | +| Oh My Pi (OMP) | TypeScript hook (`tool_call`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | Codex CLI | AGENTS.md instructions | N/A | @@ -123,6 +124,15 @@ rtk init --windsurf # creates .windsurfrules in current project rtk init --codex # creates AGENTS.md or patches existing one ``` +### Oh My Pi + +```bash +rtk init --agent omp # creates ./.omp/hooks/pre/rtk.ts +rtk init -g --agent omp # creates ~/.omp/agent/hooks/pre/rtk.ts +``` + +Oh My Pi loads project hooks from `.omp/hooks/pre/` and user hooks from `~/.omp/agent/hooks/pre/`. RTK installs a dedicated `rtk.ts` hook that intercepts `bash` tool calls and delegates rewrite decisions to `rtk rewrite`. + ### Kilo Code ```bash diff --git a/hooks/README.md b/hooks/README.md index 0879de9bb..a116ae029 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -39,6 +39,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation - **[`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 +- **[`omp/`](omp/README.md)** — TypeScript hook, OMP `tool_call` rewrite via `./.omp/hooks/pre/rtk.ts` or `~/.omp/agent/hooks/pre/rtk.ts` - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation @@ -54,6 +55,7 @@ Each agent subdirectory has its own README with hook-specific details: | Cline / Roo Code | Custom instructions (rules file) | Prompt-level guidance | N/A | | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | | Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | +| Oh My Pi (OMP) | TypeScript hook (`tool_call`) | In-place mutation | Yes (`event.input`) | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | diff --git a/hooks/omp/README.md b/hooks/omp/README.md new file mode 100644 index 000000000..938024786 --- /dev/null +++ b/hooks/omp/README.md @@ -0,0 +1,13 @@ +# Oh My Pi Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## Specifics + +- TypeScript hook module (HookAPI), not a shell hook or rules file +- Installs to `./.omp/hooks/pre/rtk.ts` with `rtk init --agent omp`, or to `~/.omp/agent/hooks/pre/rtk.ts` with `rtk init -g --agent omp` +- Intercepts OMP `tool_call` events for the `bash` tool and delegates rewrite decisions to `rtk rewrite` +- Uses `ctx.ui.confirm()` to prompt the user when `rtk rewrite` returns an "ask" permission verdict (exit code 3) +- Deny verdicts (exit code 2) pass through unchanged (host tool handles denial) +- Fail-open: if `rtk` is unavailable or `rtk rewrite` fails, commands run raw unchanged +- Multi-hook chaining: OMP dispatches `tool_call` handlers sequentially. Downstream handlers observe the RTK-rewritten `event.input.command` when RTK rewrites it diff --git a/hooks/omp/rtk.ts b/hooks/omp/rtk.ts new file mode 100644 index 000000000..cf3fdfc46 --- /dev/null +++ b/hooks/omp/rtk.ts @@ -0,0 +1,86 @@ +// RTK - Rust Token Killer +// OMP hook: rewrite bash tool calls through `rtk rewrite`. +// +// Installed to .omp/hooks/pre/rtk.ts (project) or +// ~/.omp/agent/hooks/pre/rtk.ts (global). +// OMP auto-discovers and loads hooks from these paths. +// +// Fail-open: if rtk is unavailable or rewrite fails, commands run raw. + +import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; +import { $which } from "@oh-my-pi/pi-utils"; + +type Verdict = "allow" | "ask"; + +export interface RewriteResult { + rewritten: string; + verdict: Verdict; +} + +/** Thin wrapper over Bun.spawn for test injection. */ +export const spawner = { + spawn: (cmd: string[], opts: Parameters[1]) => Bun.spawn(cmd, opts), +}; + +export async function rewrite(command: string): Promise { + // `rtk rewrite` exit codes: + // 0 = rewrite allowed (auto-apply) + // 1 = no RTK equivalent (passthrough) + // 2 = deny rule matched (passthrough — host tool handles denial) + // 3 = ask rule matched (prompt user before applying) + let timeout: ReturnType | undefined; + try { + const proc = spawner.spawn(["rtk", "rewrite", command], { + stdout: "pipe", stderr: "pipe", + }); + timeout = setTimeout(() => proc.kill(), 2_000); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const rewritten = stdout.trim(); + + if (exitCode === 1 || exitCode === 2) return null; + if (exitCode === 0 || exitCode === 3) { + if (rewritten && rewritten !== command) { + return { rewritten, verdict: exitCode === 3 ? "ask" : "allow" }; + } + return null; + } + + const details = `rtk rewrite failed with exit ${exitCode}`; + const errDetail = stderr.trim(); + warn(errDetail ? `${details}: ${errDetail}` : details); + } catch (e) { + warn(`rtk rewrite error: ${e instanceof Error ? e.message : String(e)}`); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + return null; +} + +function warn(message: string): void { + console.error(`rtk: omp hook warning: ${message}`); +} + +export default function (pi: HookAPI): void { + const hasRtk = Boolean($which("rtk")); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return; + if (!hasRtk) return; + const result = await rewrite(event.input.command as string); + if (!result) return; + + if (result.verdict === "ask" && ctx.hasUI) { + const ok = await ctx.ui.confirm( + "RTK rewrite", + `Rewrote:\n ${event.input.command}\nto:\n ${result.rewritten}\n\nUse rewritten command?`, + ); + if (!ok) return; // user declined — passthrough original + } + + event.input.command = result.rewritten; + }); +} diff --git a/hooks/omp/tests/rtk.test.ts b/hooks/omp/tests/rtk.test.ts new file mode 100644 index 000000000..cb850e15b --- /dev/null +++ b/hooks/omp/tests/rtk.test.ts @@ -0,0 +1,209 @@ +/** + * Behavioral tests for the OMP RTK hook. + * + * Mirrors the Hermes plugin test suite (hooks/hermes/tests/test_rtk_rewrite_plugin.py). + * Run with: bun test hooks/omp/tests/ + */ + +import { describe, test, expect, mock, beforeAll, beforeEach, afterEach, spyOn } from "bun:test"; + +// ── Mocks for external packages (not installed in dev) ───────────── + +mock.module("@oh-my-pi/pi-utils", () => ({ + $which: () => "/usr/bin/rtk", +})); + +mock.module("@oh-my-pi/pi-coding-agent/extensibility/hooks", () => ({})); + +// ── Fake subprocess ─────────────────────────────────────────────── + +function fakeSubprocess(exitCode: number, stdout: string, stderr: string) { + const encoder = new TextEncoder(); + return { + exited: Promise.resolve(exitCode), + stdout: new ReadableStream({ + start(controller: ReadableStreamDefaultController) { + controller.enqueue(encoder.encode(stdout)); + controller.close(); + }, + }), + stderr: new ReadableStream({ + start(controller: ReadableStreamDefaultController) { + controller.enqueue(encoder.encode(stderr)); + controller.close(); + }, + }), + kill: mock(() => {}), + }; +} + +// ── Test suite ──────────────────────────────────────────────────── + +describe("OMP RTK hook", () => { + let mod: typeof import("../rtk.ts"); + let consoleErrorSpy: ReturnType; + let origSpawn: (...args: any[]) => any; + + beforeAll(async () => { + mod = await import("../rtk.ts"); + origSpawn = mod.spawner.spawn; + }); + + beforeEach(() => { + consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + mod.spawner.spawn = origSpawn; + consoleErrorSpy.mockRestore(); + }); + + // ── Helpers ───────────────────────────────────────────────── + + function setSpawn(exitCode: number, stdout: string, stderr: string) { + mod.spawner.spawn = () => fakeSubprocess(exitCode, stdout, stderr); + } + + function setSpawnError(message: string) { + mod.spawner.spawn = () => { + throw new Error(message); + }; + } + + // ── Registration ──────────────────────────────────────────── + + test("registers tool_call handler", () => { + const listeners = new Map void>(); + const api = { + on(event: string, handler: (...args: any[]) => void) { + listeners.set(event, handler); + }, + }; + mod.default(api); + + expect(listeners.has("tool_call")).toBe(true); + }); + + // ── Rewrite success ───────────────────────────────────────── + + test("rewrite success (exit 0) returns allow verdict", async () => { + setSpawn(0, "rtk git status\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).not.toBeNull(); + expect(result!.rewritten).toBe("rtk git status"); + expect(result!.verdict).toBe("allow"); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test("rewrite exit code 3 returns ask verdict", async () => { + setSpawn(3, "rtk git status\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).not.toBeNull(); + expect(result!.rewritten).toBe("rtk git status"); + expect(result!.verdict).toBe("ask"); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test("rewrite exit 0 with unchanged output returns null", async () => { + setSpawn(0, "git status\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + // ── Passthrough exit codes ────────────────────────────────── + + test("exit code 1 (no rtk equivalent) returns null silently", async () => { + setSpawn(1, "", "unused stderr"); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test("exit code 2 (deny rule) returns null silently", async () => { + setSpawn(2, "", "unused stderr"); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + // ── Error handling ────────────────────────────────────────── + + test("unexpected exit code warns with stderr detail", async () => { + setSpawn(4, "rtk git status\n", "bad news"); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "rtk: omp hook warning: rtk rewrite failed with exit 4: bad news", + ); + }); + + test("unexpected exit code without stderr warns with code only", async () => { + setSpawn(5, "rtk git status\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "rtk: omp hook warning: rtk rewrite failed with exit 5", + ); + }); + + test("spawn error warns and returns null", async () => { + setSpawnError("spawn ENOENT"); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "rtk: omp hook warning: rtk rewrite error: spawn ENOENT", + ); + }); + + // ── Empty / unchanged output ──────────────────────────────── + + test("empty stdout returns null for exit 0", async () => { + setSpawn(0, "", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + }); + + test("whitespace-only stdout returns null for exit 0", async () => { + setSpawn(0, "\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + }); + + test("exit 3 with empty stdout returns null without warning", async () => { + setSpawn(3, "", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test("exit 3 with unchanged stdout returns null", async () => { + setSpawn(3, "git status\n", ""); + + const result = await mod.rewrite("git status"); + + expect(result).toBeNull(); + }); +}); diff --git a/src/discover/report.rs b/src/discover/report.rs index c2c523e24..749cd8c00 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -2,7 +2,7 @@ use crate::hooks::constants::{ CURSOR_DIR, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, - HOOKS_SUBDIR, REWRITE_HOOK_FILE, + HOOKS_SUBDIR, OMP_GLOBAL_HOOK_PATH, REWRITE_HOOK_FILE, }; use serde::Serialize; use std::path::Path; @@ -52,6 +52,7 @@ pub struct UnsupportedEntry { pub struct AgentIntegrationStatus { pub cursor_hook_installed: bool, pub hermes_plugin_installed: bool, + pub omp_hook_installed: bool, } impl AgentIntegrationStatus { @@ -74,6 +75,7 @@ impl AgentIntegrationStatus { .join(HERMES_PLUGIN_NAME) .join(HERMES_PLUGIN_MANIFEST_FILE) .is_file(), + omp_hook_installed: home.join(OMP_GLOBAL_HOOK_PATH).exists(), } } } @@ -221,6 +223,10 @@ fn append_agent_notes(out: &mut String, status: AgentIntegrationStatus) { if status.hermes_plugin_installed { out.push_str("\nNote: Hermes plugin is installed; Hermes sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); } + + if status.omp_hook_installed { + out.push_str("\nNote: Oh My Pi (OMP) hook is installed; OMP sessions are tracked via `rtk gain` (discover scans Claude Code only)\n"); + } } /// Format report as JSON. @@ -356,12 +362,32 @@ mod tests { ); } + #[test] + fn test_format_text_reports_omp_hook_installed() { + let mut report = make_report(1, 0); + report.agent_status.omp_hook_installed = true; + report.unsupported.push(UnsupportedEntry { + base_command: "foo".to_string(), + count: 1, + example: "foo bar".to_string(), + }); + + let output = format_text(&report, 10, false); + + assert!( + output.contains("Oh My Pi (OMP) hook is installed"), + "Expected OMP installed note in output but got:\n{}", + output + ); + } + #[test] fn test_format_json_includes_agent_status() { let mut report = make_report(0, 0); report.agent_status = AgentIntegrationStatus { cursor_hook_installed: true, hermes_plugin_installed: true, + ..AgentIntegrationStatus::default() }; let output = format_json(&report); diff --git a/src/hooks/README.md b/src/hooks/README.md index 01a0213cb..4a40700a3 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -28,6 +28,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | +| Oh My Pi | `rtk init --agent omp` / `rtk init -g --agent omp` | `.omp/hooks/pre/rtk.ts` or `~/.omp/agent/hooks/pre/rtk.ts` | -- | | Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | 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..0e4f4ebe8 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -26,3 +26,6 @@ pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; + +pub const OMP_GLOBAL_HOOK_PATH: &str = ".omp/agent/hooks/pre/rtk.ts"; +pub const OMP_PROJECT_HOOK_PATH: &str = ".omp/hooks/pre/rtk.ts"; diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 4dd26d82b..f2e7ae505 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -157,6 +157,7 @@ mod tests { use crate::hooks::constants::{ CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, + OMP_GLOBAL_HOOK_PATH, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; @@ -177,6 +178,7 @@ mod tests { .join(HERMES_PLUGINS_SUBDIR) .join(HERMES_PLUGIN_NAME) .join(HERMES_PLUGIN_MANIFEST_FILE), + home.join(OMP_GLOBAL_HOOK_PATH), ]; paths.iter().any(|p| p.exists()) } @@ -284,6 +286,15 @@ mod tests { assert!(other_integration_installed(tmp.path())); } + #[test] + fn test_other_integration_omp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join(OMP_GLOBAL_HOOK_PATH); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"hook").unwrap(); + assert!(other_integration_installed(tmp.path())); + } + #[test] fn test_other_integration_empty_dirs_not_enough() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -297,6 +308,14 @@ mod tests { .join(HERMES_PLUGIN_NAME), ) .unwrap(); + std::fs::create_dir_all( + tmp.path() + .join(".omp") + .join("agent") + .join("hooks") + .join("pre"), + ) + .unwrap(); assert!(!other_integration_installed(tmp.path())); } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 21da7c748..2766506c9 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -14,13 +14,15 @@ 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, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, + OMP_GLOBAL_HOOK_PATH, OMP_PROJECT_HOOK_PATH, 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"); +const OMP_HOOK: &str = include_str!("../../hooks/omp/rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); @@ -606,6 +608,7 @@ pub fn uninstall( gemini: bool, codex: bool, cursor: bool, + omp: bool, ctx: InitContext, ) -> Result<()> { let InitContext { verbose, dry_run } = ctx; @@ -617,6 +620,10 @@ pub fn uninstall( return Ok(()); } + if omp { + return uninstall_omp(global, ctx); + } + if cursor { if !global { anyhow::bail!("Cursor uninstall only works with --global flag"); @@ -824,6 +831,57 @@ pub fn uninstall( Ok(()) } +fn uninstall_omp(global: bool, ctx: InitContext) -> Result<()> { + let base_dir = if global { + dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")? + } else { + std::env::current_dir()? + }; + uninstall_omp_at(&base_dir, global, ctx) +} + +fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<()> { + let InitContext { verbose, dry_run } = ctx; + let hook_path = omp_hook_path(base_dir, global); + + let content = match fs::read_to_string(&hook_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("RTK was not installed for Oh My Pi (nothing to remove)"); + return Ok(()); + } + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to read OMP hook: {}", hook_path.display())); + } + }; + + if omp_hook_matches_stock(&content) { + if dry_run { + println!("[dry-run] Would remove OMP hook: {}", hook_path.display()); + } else { + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove OMP hook: {}", hook_path.display()))?; + if verbose > 0 { + eprintln!("Removed OMP hook: {}", hook_path.display()); + } + } + println!("RTK uninstalled for Oh My Pi:"); + println!(" - Hook: {}", hook_path.display()); + return Ok(()); + } + + if omp_hook_contains_rtk(&content) { + anyhow::bail!( + "OMP hook at {} contains RTK content that does not match the stock hook. Remove the file manually.", + hook_path.display() + ); + } + + println!("RTK was not installed for Oh My Pi (nothing to remove)"); + Ok(()) +} + fn uninstall_codex(global: bool, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; if !global { @@ -2284,6 +2342,103 @@ fn normalized_yaml_scalar(value: &str) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } +// ─── Oh My Pi (OMP) support ──────────────────────────────── + +const OMP_HOOK_MARKER: &str = "// RTK - Rust Token Killer"; + +fn omp_hook_contains_rtk(existing: &str) -> bool { + existing.contains(OMP_HOOK_MARKER) +} + +fn omp_hook_matches_stock(existing: &str) -> bool { + existing.trim() == OMP_HOOK.trim() +} + +fn omp_hook_path(base_dir: &Path, global: bool) -> PathBuf { + if global { + base_dir.join(OMP_GLOBAL_HOOK_PATH) + } else { + base_dir.join(OMP_PROJECT_HOOK_PATH) + } +} + +fn install_omp_hook_file(hook_path: &Path, ctx: InitContext) -> Result { + let existing = match fs::read_to_string(hook_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(e) + .with_context(|| format!("Failed to read OMP hook: {}", hook_path.display())); + } + }; + + if omp_hook_matches_stock(&existing) { + return Ok(false); + } + if omp_hook_contains_rtk(&existing) { + anyhow::bail!( + "OMP hook at {} contains RTK content that does not match the stock hook. Update or remove the file manually, then re-run the command.", + hook_path.display() + ); + } + if !existing.trim().is_empty() { + anyhow::bail!( + "OMP hook file at {} already exists. Move, merge, or delete it manually, then re-run the command.", + hook_path.display() + ); + } + + if let Some(parent) = hook_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + if ctx.dry_run { + println!("[dry-run] Would create directory: {}", parent.display()); + } else { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + } + + write_if_changed(hook_path, OMP_HOOK, "OMP hook", ctx) +} + +fn run_omp_mode_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<()> { + let hook_path = omp_hook_path(base_dir, global); + let changed = install_omp_hook_file(&hook_path, ctx)?; + + let (scope, scope_target) = if global { + ("(global)", "every project") + } else { + ("in this project", "this project") + }; + + if changed && ctx.dry_run { + println!("\n[dry-run] Would configure RTK for Oh My Pi {scope}.\n"); + println!(" Hook: {} (would be created)", hook_path.display()); + } else { + let (header, note) = if changed { + ("configured", "installed") + } else { + ("already configured", "already present") + }; + println!("\nRTK {header} for Oh My Pi {scope}.\n"); + println!(" Hook: {} ({note})", hook_path.display()); + println!(" OMP will now rewrite supported bash tool calls through RTK in {scope_target}."); + } + println!(" Restart OMP. Test with: git status\n"); + + Ok(()) +} +pub fn run_omp_mode(global: bool, ctx: InitContext) -> Result<()> { + if global { + let home = dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")?; + run_omp_mode_at(&home, global, ctx) + } else { + run_omp_mode_at(&std::env::current_dir()?, global, ctx) + } +} + fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -3088,14 +3243,60 @@ fn remove_cursor_hook_from_json(root: &mut serde_json::Value) -> bool { } /// Show current rtk configuration -pub fn show_config(codex: bool) -> Result<()> { +pub fn show_config(codex: bool, omp: bool) -> Result<()> { if codex { return show_codex_config(); } - + if omp { + return show_omp_config(); + } show_claude_config() } +fn print_omp_hook_status(label: &str, hook_path: &Path) -> Result<()> { + if hook_path.exists() { + let content = fs::read_to_string(hook_path)?; + if omp_hook_matches_stock(&content) { + println!("[ok] {}: {}", label, hook_path.display()); + } else if omp_hook_contains_rtk(&content) { + println!( + "[warn] {}: {} contains RTK content but differs from the stock OMP hook", + label, + hook_path.display() + ); + } else { + println!( + "[--] {}: {} exists but rtk is not configured", + label, + hook_path.display() + ); + } + } else { + println!("[--] {}: {} (not found)", label, hook_path.display()); + } + + Ok(()) +} + +fn show_omp_config() -> Result<()> { + let home = dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")?; + let cwd = std::env::current_dir()?; + let global_hook = omp_hook_path(&home, true); + let project_hook = omp_hook_path(&cwd, false); + + println!("rtk Configuration (Oh My Pi):\n"); + print_omp_hook_status("Global hook", &global_hook)?; + print_omp_hook_status("Project hook", &project_hook)?; + + println!("\nUsage:"); + println!(" rtk init --agent omp # Configure ./.omp/hooks/pre/rtk.ts"); + println!(" rtk init -g --agent omp # Configure ~/.omp/agent/hooks/pre/rtk.ts"); + println!(" rtk init --agent omp --uninstall # Remove project OMP RTK hook"); + println!(" rtk init -g --agent omp --uninstall # Remove global OMP RTK hook"); + + Ok(()) +} + fn show_claude_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; let hook_path = claude_dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE); @@ -5394,7 +5595,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 = @@ -5521,7 +5722,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!( @@ -5634,4 +5835,169 @@ mod tests { "RTK end marker must be removed" ); } + + // ─── OMP hook tests ──────────────────────────────────────── + + #[test] + fn test_omp_hook_install_and_idempotent() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + + let changed = install_omp_hook_file(&hook_path, ctx).unwrap(); + assert!(changed); + let content = fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content, OMP_HOOK); + + let changed_again = install_omp_hook_file(&hook_path, ctx).unwrap(); + assert!(!changed_again); + let content_again = fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content_again, OMP_HOOK); + } + + #[test] + fn test_omp_hook_rejects_stale_rtk_content() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + fs::write(&hook_path, "// RTK - Rust Token Killer\n// stale").unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + + let err = install_omp_hook_file(&hook_path, ctx).unwrap_err(); + assert!( + err.to_string().contains("does not match the stock hook"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_omp_hook_rejects_unmanaged_file() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + fs::write(&hook_path, "export default function userHook() {}\n").unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + + let err = install_omp_hook_file(&hook_path, ctx).unwrap_err(); + assert!( + err.to_string().contains("already exists"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_omp_mode_creates_global_hook() { + let temp = TempDir::new().unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + run_omp_mode_at(temp.path(), true, ctx).unwrap(); + + let hook_path = temp.path().join(".omp/agent/hooks/pre/rtk.ts"); + assert!(hook_path.exists(), "OMP hook should be created"); + let content = fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content, OMP_HOOK); + } + + #[test] + fn test_omp_mode_creates_project_hook() { + let temp = TempDir::new().unwrap(); + let ctx = InitContext { + verbose: 0, + dry_run: false, + }; + run_omp_mode_at(temp.path(), false, ctx).unwrap(); + + let hook_path = temp.path().join(".omp/hooks/pre/rtk.ts"); + assert!(hook_path.exists(), "OMP hook should be created"); + let content = fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content, OMP_HOOK); + } + + #[test] + fn test_omp_marker_is_first_line_of_embedded() { + assert!( + OMP_HOOK.starts_with(OMP_HOOK_MARKER), + "OMP_HOOK_MARKER must match the first line of hooks/omp/rtk.ts; update one so they agree" + ); + } + + #[test] + fn test_omp_uninstall_removes_stock_hook() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + fs::write(&hook_path, OMP_HOOK).unwrap(); + + uninstall_omp_at(temp.path(), false, InitContext::default()).unwrap(); + assert!(!hook_path.exists(), "Stock OMP hook must be removed"); + } + + #[test] + fn test_omp_uninstall_dry_run_keeps_stock_hook() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + fs::write(&hook_path, OMP_HOOK).unwrap(); + + let dry = InitContext { + dry_run: true, + ..Default::default() + }; + uninstall_omp_at(temp.path(), false, dry).unwrap(); + assert!( + hook_path.exists(), + "Dry-run uninstall must not remove the file" + ); + } + + #[test] + fn test_omp_uninstall_preserves_unmanaged_file() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + let foreign = "export default function userHook() {}\n"; + fs::write(&hook_path, foreign).unwrap(); + + uninstall_omp_at(temp.path(), false, InitContext::default()).unwrap(); + assert_eq!( + fs::read_to_string(&hook_path).unwrap(), + foreign, + "Unmanaged file must be left untouched" + ); + } + + #[test] + fn test_omp_uninstall_rejects_stale_rtk_content() { + let temp = TempDir::new().unwrap(); + let hook_path = omp_hook_path(temp.path(), false); + fs::create_dir_all(hook_path.parent().unwrap()).unwrap(); + fs::write(&hook_path, format!("{OMP_HOOK_MARKER}\n// stale")).unwrap(); + + let err = uninstall_omp_at(temp.path(), false, InitContext::default()).unwrap_err(); + assert!( + err.to_string().contains("does not match the stock hook"), + "unexpected error: {err}" + ); + assert!( + hook_path.exists(), + "Stale RTK file must be preserved for manual review" + ); + } + + #[test] + fn test_omp_uninstall_when_not_installed_is_noop() { + let temp = TempDir::new().unwrap(); + uninstall_omp_at(temp.path(), false, InitContext::default()).unwrap(); + } } diff --git a/src/main.rs b/src/main.rs index d6d0aa4f3..20b331633 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,8 @@ pub enum AgentTarget { Antigravity, /// Hermes CLI Hermes, + /// Oh My Pi (OMP) + Omp, } #[derive(Parser)] @@ -1355,19 +1357,20 @@ fn uninstall_init_dispatch( global: bool, gemini: bool, codex: bool, + omp: bool, ctx: hooks::init::InitContext, uninstall_hermes: UninstallHermes, uninstall_standard: UninstallStandard, ) -> 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) + uninstall_standard(global, gemini, codex, cursor, omp, ctx) } } @@ -1797,13 +1800,16 @@ fn run_cli() -> Result { dry_run, }; if show { - hooks::init::show_config(codex)?; + let omp = agent == Some(AgentTarget::Omp); + hooks::init::show_config(codex, omp)?; } else if uninstall { + let omp = agent == Some(AgentTarget::Omp); uninstall_init_dispatch( agent, global, gemini, codex, + omp, ctx, hooks::init::uninstall_hermes, hooks::init::uninstall, @@ -1833,6 +1839,8 @@ fn run_cli() -> Result { hooks::init::run_antigravity_mode(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; + } else if agent == Some(AgentTarget::Omp) { + hooks::init::run_omp_mode(global, ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2665,6 +2673,7 @@ mod tests { true, false, false, + false, ctx, |ctx| { hermes_called.set(true); @@ -2672,7 +2681,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, - |_, _, _, _, _| { + |_, _, _, _, _, _| { standard_called.set(true); Ok(()) }, From 6e36bb36febc1c063ddab6310b470d35b7d5fab9 Mon Sep 17 00:00:00 2001 From: David Marshall Date: Wed, 13 May 2026 09:48:53 -0500 Subject: [PATCH 2/2] refactor(omp): align uninstall_omp with Hermes/Codex pattern + injection cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uninstall_omp now returns Result<()> and owns the user-facing summary, while uninstall_omp_at returns Result> silently — matching the split established by uninstall_hermes_at and uninstall_codex_at (PR #1614). Removes the discarded return value at the caller and the duplicated println! channel inside _at. Also: complete dependency injection in resolve_omp_agent_dir_from_env so the PI_CONFIG_DIR branch is unit-testable; drop the misleading underscore prefix on rtkMissingWarned (TS convention reserves _ for unused bindings); restore the blank line between tests in discover::report. Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/omp/rtk.ts | 19 +++++++++- src/discover/report.rs | 13 +++++++ src/hooks/hook_check.rs | 3 +- src/hooks/init.rs | 80 +++++++++++++++++++++++++++++++---------- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/hooks/omp/rtk.ts b/hooks/omp/rtk.ts index cf3fdfc46..644618ece 100644 --- a/hooks/omp/rtk.ts +++ b/hooks/omp/rtk.ts @@ -29,16 +29,26 @@ export async function rewrite(command: string): Promise { // 2 = deny rule matched (passthrough — host tool handles denial) // 3 = ask rule matched (prompt user before applying) let timeout: ReturnType | undefined; + let timedOut = false; try { const proc = spawner.spawn(["rtk", "rewrite", command], { stdout: "pipe", stderr: "pipe", }); - timeout = setTimeout(() => proc.kill(), 2_000); + timeout = setTimeout(() => { + timedOut = true; + proc.kill(); + }, 2_000); const [exitCode, stdout, stderr] = await Promise.all([ proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); + + if (timedOut) { + warn("rtk rewrite timed out"); + return null; + } + const rewritten = stdout.trim(); if (exitCode === 1 || exitCode === 2) return null; @@ -64,9 +74,16 @@ function warn(message: string): void { console.error(`rtk: omp hook warning: ${message}`); } +let rtkMissingWarned = false; + export default function (pi: HookAPI): void { const hasRtk = Boolean($which("rtk")); + if (!hasRtk && !rtkMissingWarned) { + rtkMissingWarned = true; + warn("rtk binary not found in PATH; OMP hook not registered"); + } + pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return; if (!hasRtk) return; diff --git a/src/discover/report.rs b/src/discover/report.rs index 749cd8c00..935297fb8 100644 --- a/src/discover/report.rs +++ b/src/discover/report.rs @@ -362,6 +362,19 @@ mod tests { ); } + #[test] + fn test_agent_status_detects_omp_hook_file() { + let temp_home = tempfile::tempdir().unwrap(); + let hook = temp_home.path().join(OMP_GLOBAL_HOOK_PATH); + std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); + std::fs::write(&hook, "// RTK - Rust Token Killer\n").unwrap(); + + let status = AgentIntegrationStatus::detect_from_home(temp_home.path()); + + assert!(status.omp_hook_installed); + assert!(!status.cursor_hook_installed); + } + #[test] fn test_format_text_reports_omp_hook_installed() { let mut report = make_report(1, 0); diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index f2e7ae505..457d07dd0 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -157,8 +157,7 @@ mod tests { use crate::hooks::constants::{ CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, - OMP_GLOBAL_HOOK_PATH, - OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, + OMP_GLOBAL_HOOK_PATH, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR, }; fn other_integration_installed(home: &std::path::Path) -> bool { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 2766506c9..6baf6a1df 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -15,8 +15,8 @@ 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, - OMP_GLOBAL_HOOK_PATH, OMP_PROJECT_HOOK_PATH, PRE_TOOL_USE_KEY, - REWRITE_HOOK_FILE, SETTINGS_JSON, + OMP_GLOBAL_HOOK_PATH, OMP_PROJECT_HOOK_PATH, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, + SETTINGS_JSON, }; use super::integrity; @@ -832,24 +832,43 @@ pub fn uninstall( } fn uninstall_omp(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; let base_dir = if global { - dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")? + resolve_omp_agent_dir()? } else { std::env::current_dir()? }; - uninstall_omp_at(&base_dir, global, ctx) + let removed = uninstall_omp_at(&base_dir, global, ctx)?; + + if removed.is_empty() { + println!("RTK was not installed for Oh My Pi (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Oh My Pi:" + } else { + "RTK uninstalled for Oh My Pi:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) } -fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<()> { +fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result> { let InitContext { verbose, dry_run } = ctx; let hook_path = omp_hook_path(base_dir, global); + let mut removed = Vec::new(); let content = match fs::read_to_string(&hook_path) { Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - println!("RTK was not installed for Oh My Pi (nothing to remove)"); - return Ok(()); - } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(removed), Err(e) => { return Err(e) .with_context(|| format!("Failed to read OMP hook: {}", hook_path.display())); @@ -858,7 +877,7 @@ fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<( if omp_hook_matches_stock(&content) { if dry_run { - println!("[dry-run] Would remove OMP hook: {}", hook_path.display()); + println!("[dry-run] would remove OMP hook: {}", hook_path.display()); } else { fs::remove_file(&hook_path) .with_context(|| format!("Failed to remove OMP hook: {}", hook_path.display()))?; @@ -866,9 +885,8 @@ fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<( eprintln!("Removed OMP hook: {}", hook_path.display()); } } - println!("RTK uninstalled for Oh My Pi:"); - println!(" - Hook: {}", hook_path.display()); - return Ok(()); + removed.push(format!("Hook: {}", hook_path.display())); + return Ok(removed); } if omp_hook_contains_rtk(&content) { @@ -878,8 +896,7 @@ fn uninstall_omp_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<( ); } - println!("RTK was not installed for Oh My Pi (nothing to remove)"); - Ok(()) + Ok(removed) } fn uninstall_codex(global: bool, ctx: InitContext) -> Result<()> { @@ -2432,8 +2449,8 @@ fn run_omp_mode_at(base_dir: &Path, global: bool, ctx: InitContext) -> Result<() } pub fn run_omp_mode(global: bool, ctx: InitContext) -> Result<()> { if global { - let home = dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")?; - run_omp_mode_at(&home, global, ctx) + let agent_dir = resolve_omp_agent_dir()?; + run_omp_mode_at(&agent_dir, global, ctx) } else { run_omp_mode_at(&std::env::current_dir()?, global, ctx) } @@ -2868,6 +2885,31 @@ fn resolve_hermes_home_from_env( .map(|home| home.join(HERMES_DIR)) .context("Cannot determine Hermes home directory. Set $HERMES_HOME or $HOME.") } +fn resolve_omp_agent_dir() -> Result { + resolve_omp_agent_dir_from_env( + dirs::home_dir(), + std::env::var_os("PI_CODING_AGENT_DIR"), + std::env::var_os("PI_CONFIG_DIR"), + ) +} + +fn resolve_omp_agent_dir_from_env( + home_dir: Option, + omp_agent_dir: Option, + config_dir: Option, +) -> Result { + if let Some(path) = omp_agent_dir.filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(path)); + } + let config_dir = config_dir.filter(|v| !v.is_empty()); + let config_subdir: &Path = config_dir + .as_deref() + .map(Path::new) + .unwrap_or_else(|| Path::new(".omp")); + home_dir + .map(|home| home.join(config_subdir).join("agent")) + .context("Cannot determine OMP agent directory. Set $PI_CODING_AGENT_DIR or $HOME.") +} fn codex_rtk_md_ref(codex_dir: &Path) -> String { format!("@{}", codex_dir.join(RTK_MD).display()) @@ -3279,9 +3321,9 @@ fn print_omp_hook_status(label: &str, hook_path: &Path) -> Result<()> { } fn show_omp_config() -> Result<()> { - let home = dirs::home_dir().context("Cannot determine home directory. Is $HOME set?")?; + let agent_dir = resolve_omp_agent_dir()?; let cwd = std::env::current_dir()?; - let global_hook = omp_hook_path(&home, true); + let global_hook = omp_hook_path(&agent_dir, true); let project_hook = omp_hook_path(&cwd, false); println!("rtk Configuration (Oh My Pi):\n");