From dbb30e07c9c9eb91a61dee09cad58739d6d91f6b Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 22 Jul 2026 22:51:11 +0800 Subject: [PATCH 1/5] feat(hooks): add OMP (Oh My Pi) agent support Add `rtk init --agent omp` to install RTK transparent rewrite support for OMP (Oh My Pi) coding agent. Same architecture as the Pi extension: TypeScript hook loaded from `hooks/pre/`, intercepts `tool_call` events, calls `rtk rewrite` for command mutation. Install paths: - global: $OMP_AGENT_DIR/hooks/pre/rtk.ts (or ~/.omp/agent/hooks/pre/) - project: .omp/hooks/pre/rtk.ts Changes: - Add OMP constants (OMP_DIR, OMP_PLUGIN_FILE, OMP_AGENT_DIR_ENV, etc.) - Add run_omp_mode/uninstall_omp/resolve_omp_dir to init.rs - Add Omp variant to AgentTarget enum + CLI routing in main.rs - Add hooks/omp/rtk.ts extension (rewriteCommand, signal, killed guard) - Add hooks/omp/README.md - Add 14 integration tests (12 init.rs + 2 main.rs CLI parse) - Update README.md, hooks/README.md, supported-agents.md Signed-off-by: DavidHLP --- README.md | 5 +- .../guide/getting-started/supported-agents.md | 26 +- hooks/README.md | 4 +- hooks/omp/README.md | 67 +++ hooks/omp/rtk.ts | 122 ++++++ src/hooks/constants.rs | 6 + src/hooks/init.rs | 382 +++++++++++++++++- src/main.rs | 40 ++ 8 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 hooks/omp/README.md create mode 100644 hooks/omp/rtk.ts diff --git a/README.md b/README.md index 8dbc8981f0..2ce97ed61d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init --agent kimi # Kimi AI rtk init -g --agent pi # Pi +rtk init -g --agent omp # OMP (Oh My Pi) rtk init --agent hermes # Hermes rtk init -g --agent droid # Factory Droid @@ -369,8 +370,7 @@ rtk init -g | `rtk gain` / analytics | Full | Full | ## Supported AI Tools - -RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 16 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 | |------|---------|--------| @@ -386,6 +386,7 @@ RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rt | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | | **Pi** | `rtk init -g --agent pi` (global) | TypeScript extension (tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | +| **OMP (Oh My Pi)** | `rtk init -g --agent omp` (or per-project) | TypeScript extension (tool_call event) | | **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/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 1c594d1c5a..2a0655378e 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,6 +1,6 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, and Factory Droid +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, OMP, Kilo Code, Antigravity, and Factory Droid sidebar: order: 3 --- @@ -36,6 +36,7 @@ Agent runs "cargo test" | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Pi | TypeScript extension (`tool_call` event) | Yes | +| OMP (Oh My Pi) | TypeScript extension (`tool_call` event) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | | Factory Droid | Shell hook (`PreToolUse`, matcher `Execute`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | @@ -120,6 +121,27 @@ rtk init --uninstall --agent pi --global Removes only the installed Pi extension file. +### OMP (Oh My Pi) + +```bash +# Project-local (default) +rtk init --agent omp + +# Global — all projects +rtk init --agent omp --global +``` + +Creates `.omp/hooks/pre/rtk.ts` (local) or `~/.omp/agent/hooks/pre/rtk.ts` (global). OMP auto-discovers extensions from the `hooks/pre/` directory on startup. The extension uses `rtk rewrite` for in-place command mutation — same architecture as the Pi extension. + +Uninstall: + +```bash +rtk init --uninstall --agent omp +rtk init --uninstall --agent omp --global +``` + +Removes only the installed OMP extension file. + ### OpenClaw ```bash @@ -207,7 +229,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | -Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. +Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi, OMP) use in-place mutation via the agent's TypeScript extension API. ## Windows support diff --git a/hooks/README.md b/hooks/README.md index d14fbd4273..62e34275a1 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, OMP). 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, `tool_call` event, in-place mutation, `~/.omp/agent/hooks/pre/` - **[`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 | +| OMP | TypeScript extension (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | ## JSON Formats by Agent diff --git a/hooks/omp/README.md b/hooks/omp/README.md new file mode 100644 index 0000000000..11bc909f63 --- /dev/null +++ b/hooks/omp/README.md @@ -0,0 +1,67 @@ +# OMP (Oh My Pi) Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## Design Intent + +RTK's OMP 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. This separation keeps +RTK's hook fast, predictable, and composable with other OMP extensions. + +## Specifics + +- TypeScript extension using OMP's `ExtensionAPI` (loaded via the `hooks/pre/` discovery path) +- Subscribes to `tool_call` event, narrows to `bash` tool via `toolName` check +- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs +- All error paths return `undefined` (pass through); RTK never blocks execution +- Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing +- Installed to `.omp/hooks/pre/rtk.ts` (project-local) or `~/.omp/agent/hooks/pre/rtk.ts` (global) + +## Architecture + +OMP's extension runner loads `hooks/pre/*.ts` files as extension modules at startup. The +`ExtensionToolWrapper` passes tool input by reference, so mutating `event.input.command` +inside the handler directly modifies the params that OMP executes — the command is rewritten +transparently before the bash tool runs. + +This is the same architecture as the Pi extension. OMP and Pi share a common extension API +lineage (Earendil Works). + +## Uninstall + +```bash +# Remove project-local install (run from the project root) +rtk init --uninstall --agent omp +# → removes .omp/hooks/pre/rtk.ts + +# Remove global install +rtk init --uninstall --agent omp --global +# → removes ~/.omp/agent/hooks/pre/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 loads extensions from hooks/pre/ automatically) + +# 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 -p --mode text --no-session "Run: git status" +# → commands pass through unchanged; no rewrites in rtk gain --history +``` + +## 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 OMP's extension API +- Local interfaces (no `import type`) for maximum portability across OMP versions diff --git a/hooks/omp/rtk.ts b/hooks/omp/rtk.ts new file mode 100644 index 0000000000..0d3508df60 --- /dev/null +++ b/hooks/omp/rtk.ts @@ -0,0 +1,122 @@ +// RTK OMP 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. +// +// Same architecture as the Pi extension. Uses OMP's `tool_call` event to +// intercept bash commands BEFORE execution, calling `rtk rewrite` to obtain +// the token-optimized equivalent, then mutating `event.input.command` in-place. +// +// 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 + +// Minimal local interfaces — no import dependency, works across OMP versions. +interface ExecResult { + stdout: string; + stderr: string; + code: number; + killed: boolean; +} +interface ToolCallEvent { + type: "tool_call"; + toolName: string; + toolCallId: string; + input: Record; +} +interface ExtensionContext { + cwd: string; + signal?: AbortSignal; +} +interface ExtensionAPI { + on( + event: "tool_call", + handler: ( + event: ToolCallEvent, + ctx: ExtensionContext, + ) => + | Promise<{ block?: boolean; reason?: string } | void> + | { block?: boolean; reason?: string } + | void, + ): void; + exec( + command: string, + args: string[], + options?: { cwd?: string; timeout?: number; signal?: AbortSignal }, + ): Promise; + logger: { warn(msg: string): void; error(msg: string): void }; +} + +const REWRITE_TIMEOUT_MS = 2_000; +const MIN_SUPPORTED_RTK_MINOR = 23; + +// Parse "X.Y.Z" semver, return [major, minor, patch] or null. +function parseSemver(raw: string): [number, number, number] | null { + const m = raw.trim().match(/(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; +} + +// Calls `rtk rewrite`; returns the rewritten command or null (pass through). +async function rewriteCommand( + pi: ExtensionAPI, + cmd: string, + signal?: AbortSignal, +): Promise { + const result = await pi.exec("rtk", ["rewrite", cmd], { + timeout: REWRITE_TIMEOUT_MS, + signal, + }); + if (result.killed) return null; + if (result.code !== 0 && result.code !== 3) return null; + return result.stdout.trim() || null; +} + +export default async function (pi: ExtensionAPI): Promise { + // 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) { + pi.logger.warn("[rtk] rtk binary not found in PATH — extension disabled"); + return; + } + + const parsed = parseSemver(ver.stdout.replace(/^rtk\s+/, "")); + if (parsed) { + const [major, minor] = parsed; + if (major === 0 && minor < MIN_SUPPORTED_RTK_MINOR) { + pi.logger.warn( + `[rtk] rtk ${ver.stdout.trim()} is too old (need >= 0.23.0) — extension disabled`, + ); + return; + } + } + + pi.on("tool_call", async (event, ctx) => { + try { + if (event.toolName !== "bash") return; + + const command = event.input?.command; + if (typeof command !== "string" || command.trim() === "") return; + + // Skip already-rewritten or disabled. + if (command.trimStart().startsWith("rtk ")) return; + if (process.env.RTK_DISABLED === "1") return; + + // Delegate to RTK. + const rewritten = await rewriteCommand(pi, command, ctx?.signal); + if (rewritten && rewritten !== command) { + event.input.command = rewritten; + } + } catch (err) { + // Fail open: never block execution on an unexpected error. + pi.logger.warn( + `[rtk] unexpected error in tool_call handler; passing through command: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Return undefined = transparent passthrough (no block). + }); +} diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 4caaf94473..f63928a0fa 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -36,6 +36,12 @@ pub const PI_EXTENSIONS_SUBDIR: &str = "extensions"; pub const PI_PLUGIN_FILE: &str = "rtk.ts"; pub const PI_CODING_AGENT_DIR_ENV: &str = "PI_CODING_AGENT_DIR"; +pub const OMP_DIR: &str = ".omp/agent"; +pub const OMP_LOCAL_DIR: &str = ".omp"; +pub const OMP_HOOKS_PRE_SUBDIR: &str = "hooks/pre"; +pub const OMP_PLUGIN_FILE: &str = "rtk.ts"; +pub const OMP_AGENT_DIR_ENV: &str = "OMP_AGENT_DIR"; + /// Factory Droid config directory, joined onto the resolved home directory. pub const DROID_DIR: &str = ".factory"; /// Canonical Droid hooks file (Droid's own /hooks UI reads and writes this). diff --git a/src/hooks/init.rs b/src/hooks/init.rs index b71c6288c7..b3a3bce66f 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -17,8 +17,9 @@ use super::constants::{ DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, 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, + HOOKS_SUBDIR, OMP_AGENT_DIR_ENV, OMP_DIR, OMP_HOOKS_PRE_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; use super::is_claude_hook_command; @@ -29,6 +30,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 OMP 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"); @@ -3502,6 +3506,120 @@ fn print_pi_result(plugin_path: &Path, installed: bool) { println!("Verify: pi -e {} --no-session", plugin_path.display()); } +// ─── OMP (Oh My Pi) coding agent support ────────────────────────────── + +/// Resolve OMP config directory, honouring `OMP_AGENT_DIR` override. +fn resolve_omp_dir() -> Result { + if let Ok(dir) = std::env::var(OMP_AGENT_DIR_ENV) { + if !dir.is_empty() { + return Ok(PathBuf::from(dir)); + } + } + resolve_home_subdir(OMP_DIR) +} + +/// Return the OMP extension install path for the given scope. +/// global=true → `$OMP_AGENT_DIR/hooks/pre/rtk.ts` +/// global=false → `.omp/hooks/pre/rtk.ts` +fn omp_plugin_path_for_scope(global: bool) -> Result { + if global { + let omp_dir = resolve_omp_dir()?; + Ok(omp_dir.join(OMP_HOOKS_PRE_SUBDIR).join(OMP_PLUGIN_FILE)) + } else { + Ok(PathBuf::from(OMP_LOCAL_DIR) + .join(OMP_HOOKS_PRE_SUBDIR) + .join(OMP_PLUGIN_FILE)) + } +} + +/// Write the OMP 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, "OMP extension", ctx) +} + +/// Uninstall OMP 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 OMP extension: {}", + plugin_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- OMP uninstall removes only the RTK-managed extension file. + fs::remove_file(&plugin_path).with_context(|| { + format!("Failed to remove OMP extension: {}", plugin_path.display()) + })?; + if verbose > 0 { + eprintln!("Removed OMP extension: {}", plugin_path.display()); + } + removed.push(format!("OMP extension: {}", plugin_path.display())); + } + } + + if dry_run { + print_dry_run_footer(); + } else if !removed.is_empty() { + println!("RTK uninstalled (OMP):"); + for item in &removed { + println!(" - {}", item); + } + println!("\nRestart OMP to apply changes."); + } else { + println!("RTK OMP extension was not installed (nothing to remove)"); + } + Ok(()) +} + +/// Install the OMP extension (hook-only; no AGENTS.md injection). +/// +/// global=true → `$OMP_AGENT_DIR/hooks/pre/rtk.ts` +/// global=false → `.omp/hooks/pre/rtk.ts` +pub fn run_omp_mode(global: bool, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let plugin_path = if global { + let omp_dir = resolve_omp_dir()?; + let path = omp_dir.join(OMP_HOOKS_PRE_SUBDIR).join(OMP_PLUGIN_FILE); + if let Some(parent) = path.parent() { + ensure_pi_extensions_dir(parent, "OMP hooks/pre directory", ctx)?; + } + path + } else { + let path = omp_plugin_path_for_scope(false)?; + if let Some(parent) = path.parent() { + ensure_pi_extensions_dir(parent, "local OMP hooks/pre 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 OMP extension {}:", status); + println!(" Extension: {}", plugin_path.display()); + println!(); + println!("OMP will load the extension automatically on next start."); + println!("Verify: omp -e {} --no-session", plugin_path.display()); +} + /// Return OpenCode plugin path: ~/.config/opencode/plugins/rtk.ts fn opencode_plugin_path(opencode_dir: &Path) -> PathBuf { opencode_dir.join(PLUGIN_SUBDIR).join(OPENCODE_PLUGIN_FILE) @@ -6824,6 +6942,7 @@ mod tests { use std::sync::Mutex; static CLAUDE_DIR_LOCK: Mutex<()> = Mutex::new(()); static PI_DIR_LOCK: Mutex<()> = Mutex::new(()); + static OMP_DIR_LOCK: Mutex<()> = Mutex::new(()); /// Serialises all tests that mutate the process-wide working directory. static CWD_LOCK: Mutex<()> = Mutex::new(()); @@ -6855,6 +6974,20 @@ mod tests { } } + fn with_omp_dir_override(tmp: &TempDir, f: F) { + let _guard = OMP_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(OMP_AGENT_DIR_ENV); + std::env::set_var(OMP_AGENT_DIR_ENV, &omp_dir); + f(&omp_dir); + match orig { + Some(v) => std::env::set_var(OMP_AGENT_DIR_ENV, v), + None => std::env::remove_var(OMP_AGENT_DIR_ENV), + } + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); @@ -7401,6 +7534,251 @@ mod tests { ); } + // ─── OMP 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_HOOKS_PRE_SUBDIR).join(OMP_PLUGIN_FILE); + assert!(plugin.exists(), "global OMP 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_LOCAL_DIR) + .join(OMP_HOOKS_PRE_SUBDIR) + .join(OMP_PLUGIN_FILE); + assert!(plugin.exists(), "local OMP 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 = OMP_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let orig = std::env::var_os(OMP_AGENT_DIR_ENV); + std::env::set_var(OMP_AGENT_DIR_ENV, &absent_dir); + + let result = run_omp_mode(true, InitContext::default()); + + match orig { + Some(v) => std::env::set_var(OMP_AGENT_DIR_ENV, v), + None => std::env::remove_var(OMP_AGENT_DIR_ENV), + } + + result.unwrap(); + + let plugin = absent_dir.join(OMP_HOOKS_PRE_SUBDIR).join(OMP_PLUGIN_FILE); + assert!( + plugin.exists(), + "plugin must be written even when dir was absent" + ); + } + + #[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_HOOKS_PRE_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_LOCAL_DIR) + .join(OMP_HOOKS_PRE_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_HOOKS_PRE_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_HOOKS_PRE_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_HOOKS_PRE_SUBDIR).exists(), + "dry-run must not create the OMP hooks/pre directory" + ); + assert!( + !omp_dir + .join(OMP_HOOKS_PRE_SUBDIR) + .join(OMP_PLUGIN_FILE) + .exists(), + "dry-run must not create the OMP 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_LOCAL_DIR) + .join(OMP_HOOKS_PRE_SUBDIR) + .exists(), + "dry-run must not create .omp/hooks/pre/" + ); + } + + #[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_HOOKS_PRE_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 OMP 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_LOCAL_DIR) + .join(OMP_HOOKS_PRE_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 OMP extension" + ); + } + // ─── Copilot tests ─────────────────────────────────────────────── #[test] diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..b93830850d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,8 @@ pub enum AgentTarget { Hermes, /// Factory Droid CLI Droid, + /// OMP (Oh My Pi) coding agent + Omp, } #[derive(Parser)] @@ -1564,6 +1566,8 @@ where { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) + } else if agent == Some(AgentTarget::Omp) { + hooks::init::uninstall_omp(global, ctx) } else if agent == Some(AgentTarget::Droid) { hooks::init::uninstall_droid(global, ctx) } else { @@ -2068,6 +2072,8 @@ fn run_cli() -> Result { hooks::init::run_hermes_mode(ctx)?; } else if agent == Some(AgentTarget::Droid) { hooks::init::run_droid_mode(global, ctx)?; + } else if agent == Some(AgentTarget::Omp) { + hooks::init::run_omp_mode(global, ctx)? } else { let install_opencode = opencode; let install_claude = !opencode; @@ -3618,4 +3624,38 @@ 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::Omp), + "--agent omp must set Omp 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 { + uninstall, + agent, + global, + .. + } => { + assert!(uninstall); + assert_eq!(agent, Some(AgentTarget::Omp)); + assert!(global); + } + _ => panic!("Expected Init command"), + } + } } From f00542af44f3039e51b793fb323c294e473aafb6 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 22 Jul 2026 23:20:31 +0800 Subject: [PATCH 2/5] feat(hooks): add setLabel and session_start UI status for OMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two features that competing PRs (#1365, #2166) include and that maintainer KuSh specifically requested in #1365 review: - setLabel('RTK') — set extension label in OMP UI - session_start + ctx.ui.setStatus — show persistent warning in OMP UI when rtk binary is missing or too old Also extends ExtensionAPI interface with setLabel() and session_start event support (optional ui field for backward compatibility). Signed-off-by: DavidHLP --- hooks/omp/rtk.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/hooks/omp/rtk.ts b/hooks/omp/rtk.ts index 0d3508df60..64268891e6 100644 --- a/hooks/omp/rtk.ts +++ b/hooks/omp/rtk.ts @@ -30,8 +30,12 @@ interface ToolCallEvent { interface ExtensionContext { cwd: string; signal?: AbortSignal; + ui?: { + setStatus(key: string, text: string | undefined): void; + }; } interface ExtensionAPI { + setLabel(label: string): void; on( event: "tool_call", handler: ( @@ -42,6 +46,13 @@ interface ExtensionAPI { | { block?: boolean; reason?: string } | void, ): void; + on( + event: "session_start", + handler: ( + event: unknown, + ctx: ExtensionContext, + ) => Promise | void, + ): void; exec( command: string, args: string[], @@ -76,10 +87,15 @@ async function rewriteCommand( } export default async function (pi: ExtensionAPI): Promise { + pi.setLabel("RTK"); + // 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) { pi.logger.warn("[rtk] rtk binary not found in PATH — extension disabled"); + pi.on("session_start", (_event, ctx) => { + ctx?.ui?.setStatus("rtk", "RTK extension disabled: rtk binary not found in PATH."); + }); return; } From 4b2699c2bba17800668bff53c01d2a0fcb785ab7 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Wed, 22 Jul 2026 23:27:42 +0800 Subject: [PATCH 3/5] test(hooks): fix false positive in OMP local install/uninstall tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests had false positive risks where they would pass even if the code was broken: 1. test_run_omp_mode_local_installs_plugin — only checked file exists, not content. Added content.contains('rtk rewrite') assertion to match the global variant. 2. test_omp_local_uninstall_removes_plugin — did not assert plugin exists before uninstall. If install was a no-op (bug), uninstall would succeed vacuously and assert!(!exists) would pass on an empty state. Added assert!(plugin.exists()) guard before uninstall. Signed-off-by: DavidHLP --- src/hooks/init.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index b3a3bce66f..9c9247a625 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -7570,6 +7570,11 @@ mod tests { .join(OMP_HOOKS_PRE_SUBDIR) .join(OMP_PLUGIN_FILE); assert!(plugin.exists(), "local OMP extension must be created"); + let content = fs::read_to_string(&plugin).unwrap(); + assert!( + content.contains("rtk rewrite"), + "local extension must delegate to rtk rewrite" + ); } #[test] @@ -7630,15 +7635,18 @@ mod tests { 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_LOCAL_DIR) .join(OMP_HOOKS_PRE_SUBDIR) .join(OMP_PLUGIN_FILE); + assert!(plugin.exists(), "plugin must exist before uninstall"); + + let result = uninstall_omp(false, InitContext::default()); + std::env::set_current_dir(&cwd).unwrap(); + result.unwrap(); + assert!(!plugin.exists(), "local plugin must be removed"); } From 1c03bf742b66b8864d4188e2cfc39a2d5df44ee5 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Thu, 23 Jul 2026 17:15:43 +0800 Subject: [PATCH 4/5] docs(readme): fix OMP Quick Start alignment to match surrounding lines The OMP install line used one extra padding space (comment marker at column 34 instead of 33), misaligning it from all other agents in the Quick Start block. Trimmed one space so 'rtk init -g --agent omp' aligns with the rest. Signed-off-by: DavidHLP --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ce97ed61d..f0fb79c53f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init --agent kimi # Kimi AI rtk init -g --agent pi # Pi -rtk init -g --agent omp # OMP (Oh My Pi) +rtk init -g --agent omp # OMP (Oh My Pi) rtk init --agent hermes # Hermes rtk init -g --agent droid # Factory Droid From e20915473f09604a3062c57a85704458d70c697f Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Thu, 23 Jul 2026 17:05:54 +0800 Subject: [PATCH 5/5] docs(hooks): add Install section and document OMP-specific UI behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks/omp/README.md previously only had an Uninstall section and a generic version-guard bullet. Added: - Install section (project-local + global, matching supported-agents.md) - OMP_AGENT_DIR override mention - --dry-run usage - Specifics: setLabel("RTK") and session_start status-line warning when rtk binary is missing — both are OMP-exclusive behaviors absent from the Pi extension No code changes. Signed-off-by: DavidHLP --- hooks/omp/README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/hooks/omp/README.md b/hooks/omp/README.md index 11bc909f63..fce0ed5a9d 100644 --- a/hooks/omp/README.md +++ b/hooks/omp/README.md @@ -17,7 +17,8 @@ RTK's hook fast, predictable, and composable with other OMP extensions. - Subscribes to `tool_call` event, narrows to `bash` tool via `toolName` check - Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs - All error paths return `undefined` (pass through); RTK never blocks execution -- Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing +- Sets UI label to "RTK" via `pi.setLabel("RTK")` so the extension is identifiable in OMP's status line +- Version guard at load time: probes `rtk --version`; if the binary is missing, sets a persistent status-line warning via `session_start` (`ctx.ui.setStatus`) and disables rewrites; if `< 0.23.0`, logs a warning and disables rewrites — the extension never blocks on a missing or stale binary - Installed to `.omp/hooks/pre/rtk.ts` (project-local) or `~/.omp/agent/hooks/pre/rtk.ts` (global) ## Architecture @@ -30,6 +31,27 @@ transparently before the bash tool runs. This is the same architecture as the Pi extension. OMP and Pi share a common extension API lineage (Earendil Works). +## Install + +```bash +# Project-local (default) +rtk init --agent omp +# → creates .omp/hooks/pre/rtk.ts + +# Global — all projects +rtk init -g --agent omp +# → creates ~/.omp/agent/hooks/pre/rtk.ts +``` + +OMP auto-discovers extensions from the `hooks/pre/` directory on startup. Set the +`OMP_AGENT_DIR` environment variable to override the global install location. + +Preview the install without writing files: + +```bash +rtk init --agent omp --dry-run +``` + ## Uninstall ```bash