diff --git a/README.md b/README.md index 92b96f3..a921c1c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ cn usage status ## Features -- **Multi-tool support** - Claude Code, OpenAI Codex, Google Gemini CLI +- **Multi-tool support** - Claude Code, OpenAI Codex, Google Gemini CLI, Oh My Pi (omp) - **Works everywhere** - Terminal, VSCode, Cursor, or any editor - **Cross-platform** - macOS, Linux, Windows - **Native notifications** - Uses system notification APIs @@ -145,6 +145,7 @@ npm packages are published with GitHub Actions Trusted Publisher and npm provena | `cn on claude` | Enable for Claude Code only | | `cn on codex` | Enable for Codex only | | `cn on gemini` | Enable for Gemini CLI only | +| `cn on omp` | Enable for Oh My Pi (omp) only | | `cn off` | Disable notifications | | `cn off all` | Explicit alias for disabling all tools | | `cn test` | Send test notification | @@ -173,10 +174,13 @@ Code-Notify uses the hook systems built into AI coding tools: - **Claude Code**: `~/.claude/settings.json` - **Codex**: `~/.codex/config.toml` - **Gemini CLI**: `~/.gemini/settings.json` +- **Oh My Pi (omp)**: `~/.omp/agent/extensions/code-notify.js` For Codex, Code-Notify configures `notify = ["/absolute/path/to/notifier.sh", "codex"]` and reads the JSON payload Codex appends on completion. Codex currently exposes completion events through `notify`; approval and `request_permissions` prompts do not currently arrive through this hook. +For omp, Code-Notify writes a small managed extension module to `~/.omp/agent/extensions/code-notify.js`, because omp loads extension modules instead of reading hook commands from a config file. The extension forwards omp's `agent_end` event to the same `notifier.sh`, so sound, voice, Slack/Discord channels, click-through, the global mute switch, and rate limiting all work unchanged. Like Codex, omp currently exposes completion events; approval and idle prompts are not yet wired. + When enabled, it adds hooks that call the notification script when tasks complete: ```json diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index ecb69f9..0779e6c 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -373,7 +373,7 @@ enable_notifications_global() { if [[ -z "$installed_tools" ]]; then warning "No supported AI tools detected" - info "Supported tools: Claude Code, Codex, Gemini CLI" + info "Supported tools: Claude Code, Codex, Gemini CLI, Oh My Pi (omp)" return 1 fi @@ -440,6 +440,7 @@ enable_single_tool() { "claude") config_file="$GLOBAL_SETTINGS_FILE" ;; "codex") config_file="$CODEX_CONFIG_FILE" ;; "gemini") config_file="$GEMINI_SETTINGS_FILE" ;; + "omp") config_file="$OMP_EXTENSION_FILE" ;; esac success "$tool: ENABLED" @@ -470,7 +471,7 @@ disable_notifications_global() { # No tool specified - disable all enabled tools local disabled_count=0 - for t in claude codex gemini; do + for t in claude codex gemini omp; do if is_tool_enabled "$t"; then if disable_single_tool "$t" "quiet"; then ((disabled_count++)) @@ -580,6 +581,19 @@ show_status() { echo " ${DIM}- Gemini CLI: not installed${RESET}" fi + # Oh My Pi (omp) + if is_tool_installed "omp"; then + if is_tool_enabled "omp"; then + echo " ${CHECK_MARK} omp: ${GREEN}ENABLED${RESET}" + echo " Config: $OMP_EXTENSION_FILE" + echo " Events: completion via agent_end extension" + else + echo " ${MUTE} omp: ${DIM}DISABLED${RESET}" + fi + else + echo " ${DIM}- omp: not installed${RESET}" + fi + # Voice status echo "" if is_voice_enabled "global"; then @@ -937,12 +951,13 @@ show_voice_status() { fi # Per-tool voice - for tool in claude codex gemini; do + for tool in claude codex gemini omp; do local tool_display case "$tool" in "claude") tool_display="Claude" ;; "codex") tool_display="Codex" ;; "gemini") tool_display="Gemini" ;; + "omp") tool_display="omp" ;; esac if is_voice_enabled "tool" "$tool"; then diff --git a/lib/code-notify/core/config.sh b/lib/code-notify/core/config.sh index 971478b..f953c09 100755 --- a/lib/code-notify/core/config.sh +++ b/lib/code-notify/core/config.sh @@ -54,6 +54,12 @@ CODEX_CONFIG_FILE="$CODEX_HOME/config.toml" GEMINI_HOME="${GEMINI_HOME:-$HOME/.gemini}" GEMINI_SETTINGS_FILE="$GEMINI_HOME/settings.json" +# Oh My Pi (omp) paths +OMP_HOME="${OMP_HOME:-$HOME/.omp}" +OMP_EXTENSIONS_DIR="${OMP_EXTENSIONS_DIR:-$OMP_HOME/agent/extensions}" +OMP_EXTENSION_FILE="$OMP_EXTENSIONS_DIR/code-notify.js" +OMP_EXTENSION_MARKER="code-notify: managed omp extension" + # Ensure config directory exists ensure_config_dir() { mkdir -p "$CONFIG_DIR" "$BACKUP_DIR" @@ -1551,6 +1557,95 @@ PYTHON fi } +# ============================================ +# Oh My Pi (omp) integration +# ============================================ +# omp loads JS/TS extension modules from ~/.omp/agent/extensions instead of +# reading hook commands from a config file. So enabling = writing a managed +# extension that forwards omp's agent_end event to notifier.sh; disabling = +# removing that file. All downstream delivery (sound, voice, channels, +# click-through, kill switch, rate limiting) is reused through notifier.sh. + +# Generate the omp extension module that forwards completion events to the notifier +generate_omp_extension() { + local notify_script="$1" + # Escape backslashes and double quotes for safe embedding in the JS string literal + notify_script="${notify_script//\\/\\\\}" + notify_script="${notify_script//\"/\\\"}" + cat << EOF +// ${OMP_EXTENSION_MARKER} +// Created by \`cn on omp\`; removed by \`cn off omp\`. Do not edit -- regenerated on enable. +// omp (Oh My Pi) loads extension modules instead of config-file hooks, so this +// file forwards the "agent finished" event to code-notify's notifier. +const NOTIFIER = "${notify_script}"; + +export default function codeNotify(pi) { + pi.on("agent_end", async (event, ctx) => { + // Only ping the top-level interactive session; skip subagents/headless runs + // unless OMP_NOTIFY_ALL=1 is set. + if (!ctx.hasUI && process.env.OMP_NOTIFY_ALL !== "1") return; + // Map the final stop reason to the notifier hook type so a failed run produces an + // error notification instead of a false success. Scan back to the last assistant + // turn: the array can end in a tool-result or custom (bash/compaction) message + // that would otherwise mask the outcome. + const messages = Array.isArray(event?.messages) ? event.messages : []; + let reason; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i] && messages[i].role === "assistant") { reason = messages[i].stopReason; break; } + } + const hook = (reason === "error" || reason === "aborted" || reason === "length") + ? "error" : "stop"; + try { + // Pass the project name via cwd so the notifier derives it from basename(\$PWD), + // keeping the notification scoped globally (not project-scoped) so that + // \`cn off\` / the global kill switch correctly suppresses it. + await pi.exec(NOTIFIER, [hook, "omp"], { timeout: 5000, cwd: ctx.cwd || undefined }); + } catch (err) { + // Never let notification failures disrupt the session. + pi.logger?.debug?.(\`code-notify: notifier exec failed: \${err}\`); + } + }); +} +EOF +} + +# Check if omp notifications are enabled +is_omp_enabled() { + [[ -f "$OMP_EXTENSION_FILE" ]] && grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null +} + +# Enable omp notifications by writing a managed extension module +enable_omp_hooks() { + local notify_script + notify_script="$(get_notify_script)" + + # Refuse to overwrite a file we didn't create (consistent with disable_omp_hooks which + # preserves non-managed files). Check before creating any dirs so a refused enable + # leaves the filesystem untouched. + if [[ -f "$OMP_EXTENSION_FILE" ]] && ! grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null; then + error "Refusing to overwrite existing non-code-notify extension: $OMP_EXTENSION_FILE" + info "Remove or rename that file manually, then run \`cn on omp\` again." + return 1 + fi + + mkdir -p "$OMP_EXTENSIONS_DIR" + atomic_write "$OMP_EXTENSION_FILE" "$(generate_omp_extension "$notify_script")" +} + +# Disable omp notifications by removing our managed extension module +disable_omp_hooks() { + if [[ ! -f "$OMP_EXTENSION_FILE" ]]; then + return 0 + fi + + # Only remove our own managed file. Never touch ~/.omp/agent or its subdirs -- + # that is omp's data directory (agent.db, sessions, config.yml, ...), not ours. + if grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null; then + rm -f "$OMP_EXTENSION_FILE" + fi + return 0 +} + # ============================================ # Multi-tool helpers # ============================================ @@ -1569,6 +1664,9 @@ enable_tool() { "gemini") enable_gemini_hooks ;; + "omp") + enable_omp_hooks + ;; *) return 1 ;; @@ -1589,6 +1687,9 @@ disable_tool() { "gemini") disable_gemini_hooks ;; + "omp") + disable_omp_hooks + ;; *) return 1 ;; @@ -1609,6 +1710,9 @@ is_tool_enabled() { "gemini") is_gemini_enabled ;; + "omp") + is_omp_enabled + ;; *) return 1 ;; diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 80ba88a..7eae839 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -128,6 +128,7 @@ get_tool_display_name() { "claude") echo "Claude" ;; "codex") echo "Codex" ;; "gemini") echo "Gemini" ;; + "omp") echo "omp" ;; *) echo "AI" ;; esac } diff --git a/lib/code-notify/utils/detect.sh b/lib/code-notify/utils/detect.sh index e09d3a5..1a8f17d 100755 --- a/lib/code-notify/utils/detect.sh +++ b/lib/code-notify/utils/detect.sh @@ -121,6 +121,21 @@ detect_gemini_cli() { return 1 } +# Detect Oh My Pi (omp) installation. +# Binary-only, like detect_codex / detect_gemini_cli: a directory is not a reliable +# "installed" signal here. ~/.omp/agent is omp's data dir (agent.db, sessions, ...) -- +# it persists after the binary is uninstalled, and code-notify itself creates the +# extensions/ subdir on enable, so keying on it gives false positives. The legacy `pi` +# alias is intentionally NOT accepted: `pi` is a common generic binary name and would +# misfire. OMP_HOME is still honoured for the echoed config location. +detect_omp() { + if command -v omp &> /dev/null; then + echo "${OMP_HOME:-$HOME/.omp}" + return 0 + fi + return 1 +} + # Get list of all installed AI coding tools get_installed_tools() { local tools=() @@ -137,6 +152,10 @@ get_installed_tools() { tools+=("gemini") fi + if detect_omp &> /dev/null; then + tools+=("omp") + fi + # Return space-separated list echo "${tools[*]}" } @@ -155,6 +174,9 @@ is_tool_installed() { "gemini") detect_gemini_cli &> /dev/null ;; + "omp") + detect_omp &> /dev/null + ;; *) return 1 ;; diff --git a/lib/code-notify/utils/help.sh b/lib/code-notify/utils/help.sh index 836625b..c78dc77 100644 --- a/lib/code-notify/utils/help.sh +++ b/lib/code-notify/utils/help.sh @@ -14,7 +14,7 @@ show_help() { ${BOLD}Code-Notify${RESET} - Desktop notifications for AI coding tools ${BOLD}SUPPORTED TOOLS:${RESET} - Claude Code, OpenAI Codex, Google Gemini CLI + Claude Code, OpenAI Codex, Google Gemini CLI, Oh My Pi (omp) ${BOLD}USAGE:${RESET} $cmd_name [tool] @@ -22,7 +22,7 @@ ${BOLD}USAGE:${RESET} ${BOLD}COMMANDS:${RESET} ${GREEN}on${RESET} Enable notifications (all detected tools) ${GREEN}on${RESET} all Enable notifications (explicit alias for all detected tools) - ${GREEN}on${RESET} Enable for specific tool (claude/codex/gemini) + ${GREEN}on${RESET} Enable for specific tool (claude/codex/gemini/omp) ${GREEN}off${RESET} Disable notifications (all tools) ${GREEN}off${RESET} all Disable notifications (explicit alias for all tools) ${GREEN}off${RESET} Disable for specific tool @@ -51,6 +51,7 @@ ${BOLD}TOOL NAMES:${RESET} ${CYAN}claude${RESET} Claude Code ${CYAN}codex${RESET} OpenAI Codex CLI ${CYAN}gemini${RESET} Google Gemini CLI + ${CYAN}omp${RESET} Oh My Pi (omp) ${BOLD}PROJECT COMMANDS:${RESET} ${GREEN}project on${RESET} Enable for current project @@ -67,6 +68,7 @@ ${BOLD}ALERT TYPES:${RESET} Claude events: ${CYAN}SubagentStart${RESET}, ${CYAN}SubagentStop${RESET}, ${CYAN}TeammateIdle${RESET}, ${CYAN}TaskCreated${RESET}, ${CYAN}TaskCompleted${RESET} Note: alert-type matching applies to Claude Code and Gemini CLI hooks. Codex currently exposes completion events through its notify payload. + omp exposes completion events through a generated extension module. ${BOLD}VOICE COMMANDS:${RESET} ${GREEN}voice on${RESET} Enable voice for all tools diff --git a/lib/code-notify/utils/voice.sh b/lib/code-notify/utils/voice.sh index b598d92..3d9b3e9 100644 --- a/lib/code-notify/utils/voice.sh +++ b/lib/code-notify/utils/voice.sh @@ -72,6 +72,7 @@ disable_voice() { rm -f "$VOICE_DIR/voice-claude" rm -f "$VOICE_DIR/voice-codex" rm -f "$VOICE_DIR/voice-gemini" + rm -f "$VOICE_DIR/voice-omp" ;; "global"|*) rm -f "$GLOBAL_VOICE_FILE" diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index e12b1d3..51d32f7 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -1908,24 +1908,31 @@ function Invoke-CodeNotify { ) $toolCommands = @("claude", "codex", "gemini") + $unsupportedTools = @("omp") switch ($Command.ToLower()) { "on" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Enable-Notifications -Tool $SubCommand } else { Enable-Notifications } } "off" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Disable-Notifications -Tool $SubCommand } else { Disable-Notifications } } "status" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Show-Status -Tool $SubCommand } else { Show-Status diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 843c247..bf472b9 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -251,6 +251,14 @@ else test_fail "ask_user alert preservation failed" fi +# Test 24: omp (Oh My Pi) extension install/detect/notify +test_start "omp extension support" +if bash tests/test-omp-extension.sh >/dev/null 2>&1; then + test_pass +else + test_fail "omp extension support failed" +fi + # Summary echo "" echo "Test Summary:" diff --git a/tests/test-all-alias.sh b/tests/test-all-alias.sh index bcce52e..1e9704c 100644 --- a/tests/test-all-alias.sh +++ b/tests/test-all-alias.sh @@ -22,7 +22,7 @@ run_enable_all_alias_test() { source "$SCRIPT_DIR/../lib/code-notify/core/config.sh" source "$SCRIPT_DIR/../lib/code-notify/commands/global.sh" - get_installed_tools() { echo "claude codex gemini"; } + get_installed_tools() { echo "claude codex gemini omp"; } is_tool_installed() { return 0; } is_tool_enabled() { return 1; } enable_tool() { @@ -35,7 +35,7 @@ run_enable_all_alias_test() { local enabled_tools enabled_tools="$(sort "$HOME/enabled-tools" | tr '\n' ' ')" - [[ "$enabled_tools" == "claude codex gemini " ]] || fail "cn on all did not enable every detected tool" + [[ "$enabled_tools" == "claude codex gemini omp " ]] || fail "cn on all did not enable every detected tool" ) rm -rf "$test_dir" @@ -66,7 +66,7 @@ run_disable_all_alias_test() { local disabled_tools disabled_tools="$(sort "$HOME/disabled-tools" | tr '\n' ' ')" - [[ "$disabled_tools" == "claude codex gemini " ]] || fail "cn off all did not disable every enabled tool" + [[ "$disabled_tools" == "claude codex gemini omp " ]] || fail "cn off all did not disable every enabled tool" ) rm -rf "$test_dir" diff --git a/tests/test-omp-extension.sh b/tests/test-omp-extension.sh new file mode 100755 index 0000000..8df3724 --- /dev/null +++ b/tests/test-omp-extension.sh @@ -0,0 +1,247 @@ +#!/bin/bash + +# Verifies Oh My Pi (omp) support: +# - detection from PATH / config dir +# - `enable` writes a valid managed agent_end extension module +# - the generated extension's runtime contract (notifier.sh stop omp ) +# - `disable` removes only the managed file, preserving user-owned ones +# - global kill switch suppresses omp stop notifications +# - `enable` refuses to overwrite a non-managed extension file +# - `detect_omp` does not stay true after enable+disable with no binary + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/../lib/code-notify" +NOTIFIER="$LIB_DIR/core/notifier.sh" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; exit 1; } + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +export HOME="$test_dir/home" +fake_bin="$test_dir/bin" +log_dir="$test_dir/log" +mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$log_dir" + +# Fake desktop notifier so the runtime contract can be asserted headlessly. +case "$(uname -s)" in + Darwin) + notification_log="$log_dir/terminal-notifier.log" + cat > "$fake_bin/terminal-notifier" <> "$notification_log" +EOF + ;; + Linux) + notification_log="$log_dir/notify-send.log" + cat > "$fake_bin/notify-send" <> "$notification_log" +EOF + ;; + *) + echo "SKIP: unsupported OS for omp extension test" + exit 0 + ;; +esac + +# Fake omp binary so detection reports omp as installed. +cat > "$fake_bin/omp" <<'EOF' +#!/bin/bash +exit 0 +EOF +chmod +x "$fake_bin"/* + +# Source the library under the temp HOME so omp paths resolve into the sandbox. +source "$LIB_DIR/utils/detect.sh" +source "$LIB_DIR/core/config.sh" + +ext_file="$HOME/.omp/agent/extensions/code-notify.js" + +# 1. Detection picks up omp when the command is present. +PATH="$fake_bin:$PATH" is_tool_installed "omp" || fail "omp not detected when the 'omp' binary is present" +case " $(PATH="$fake_bin:$PATH" get_installed_tools) " in + *" omp "*) ;; + *) fail "get_installed_tools did not include omp" ;; +esac +pass "omp detected from PATH" + +# 2. Enable writes a valid managed extension module. +enable_omp_hooks || fail "enable_omp_hooks failed" +[[ -f "$ext_file" ]] || fail "extension file not created at $ext_file" +grep -q "$OMP_EXTENSION_MARKER" "$ext_file" || fail "managed marker missing from extension" +grep -q 'pi.on("agent_end"' "$ext_file" || fail "extension does not hook agent_end" +# Extension now passes only [hook, "omp"] -- no project arg3. +grep -q '"omp"]' "$ext_file" || fail "extension does not call notifier with 'omp' as tool arg" +grep -q "$NOTIFIER" "$ext_file" || fail "extension does not embed the resolved notifier path" +is_omp_enabled || fail "is_omp_enabled returned false after enable" + +# JS validity check: copy to .mjs so Node parses it as ESM regardless of version. +if command -v node >/dev/null 2>&1; then + cp "$ext_file" "$test_dir/code-notify-check.mjs" + node --check "$test_dir/code-notify-check.mjs" || fail "generated extension is not valid JavaScript" +fi + +# Functional import harness: load the extension under a fake pi API and verify +# that the agent_end handler calls exec with the right arguments. +if command -v node >/dev/null 2>&1; then + node --input-type=module < calls exec with ["stop","omp"] +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for normal stop"); +if (capturedArgs.args[0] !== "stop") throw new Error("expected hook=stop, got " + capturedArgs.args[0]); +if (capturedArgs.args[1] !== "omp") throw new Error("expected tool=omp"); +if (capturedArgs.args.length !== 2) throw new Error("project must NOT be passed as arg3 (kill-switch fix)"); +if (capturedArgs.opts?.cwd !== "/Users/me/myrepo") throw new Error("cwd must be passed via opts.cwd"); + +// Case B: error outcome -> calls exec with ["error","omp"] +capturedArgs = undefined; +const errMsg = { role: "assistant", stopReason: "error" }; +await fakePi._handler({ messages: [errMsg] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for error outcome"); +if (capturedArgs.args[0] !== "error") throw new Error("expected hook=error for error stopReason, got " + capturedArgs.args[0]); + +// Case C: aborted -> calls exec with ["error","omp"] +capturedArgs = undefined; +const abortMsg = { role: "assistant", stopReason: "aborted" }; +await fakePi._handler({ messages: [abortMsg] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for aborted outcome"); +if (capturedArgs.args[0] !== "error") throw new Error("expected hook=error for aborted, got " + capturedArgs.args[0]); + +// Case D: headless session (hasUI=false, OMP_NOTIFY_ALL unset) -> NOT called +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: false, cwd: "/Users/me/myrepo" }); +if (capturedArgs) throw new Error("exec must not be called for headless sessions"); + +// Case E: headless with OMP_NOTIFY_ALL=1 -> called +process.env.OMP_NOTIFY_ALL = "1"; +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: false, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec must be called when OMP_NOTIFY_ALL=1"); +delete process.env.OMP_NOTIFY_ALL; + +// Case F: exec throws -> does NOT propagate (catch absorbs) +capturedArgs = undefined; +const throwingPi = Object.assign({}, fakePi, { async exec() { throw new Error("spawn ENOENT"); } }); +throwingPi._handler = null; +factory(throwingPi); +await throwingPi._handler({ messages: [] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +// If we reach here, the error was absorbed -- correct. + +console.log("HARNESS OK"); +HARNESS + [[ $? -eq 0 ]] || fail "functional JS harness failed" + pass "functional JS harness: all runtime contract cases pass" +fi + +# 3. Runtime contract: notifier.sh stop omp renders a completion notification. +# Extension now sends no arg3; notifier derives project from $PWD (basename). +# Override PWD so the test gets a predictable project name. +( + cd "$test_dir" + mkdir -p demo && cd demo + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ + bash "$NOTIFIER" stop omp "$notification_log" +touch "$HOME/.claude/notifications/disabled" +( + cd "$test_dir" + mkdir -p demo2 && cd demo2 + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ + bash "$NOTIFIER" stop omp "$ext_file" +disable_omp_hooks || fail "disable_omp_hooks failed on a user-owned file" +[[ -f "$ext_file" ]] || fail "disable clobbered a non-managed extension file" +pass "disable preserves a non-managed extension file" + +# 6. Enable refuses to overwrite a non-managed extension file (P2 clobber fix). +rm -f "$ext_file" +mkdir -p "$(dirname "$ext_file")" +printf '%s\n' "// user's own extension -- do not clobber" > "$ext_file" +enable_omp_hooks 2>/dev/null && fail "enable_omp_hooks should have refused to overwrite non-managed file" +[[ "$(head -n 1 "$ext_file")" == "// user's own extension -- do not clobber" ]] || \ + fail "enable_omp_hooks clobbered a non-managed extension file" +# Clean up for detection test +rm -f "$ext_file" +pass "enable refuses to overwrite a non-managed extension file" + +# 7. Detection is binary-only (matches detect_codex / detect_gemini_cli). Leftover +# ~/.omp dirs -- from a prior `cn on omp`, or from omp's own data that survives a +# binary uninstall -- must NOT make detect_omp report omp as installed. +( + sticky_home="$(mktemp -d)/home" + export HOME="$sticky_home" + mkdir -p "$sticky_home" + export OMP_HOME="$sticky_home/.omp" + # config.sh was sourced at the top of this file; its OMP_* vars leak into this + # subshell and would shadow the fresh OMP_HOME (config.sh uses ${VAR:-default}). + # Clear them so every omp path recomputes under sticky_home. + unset OMP_EXTENSIONS_DIR OMP_EXTENSION_FILE + source "$LIB_DIR/utils/detect.sh" + source "$LIB_DIR/core/config.sh" + PATH="/usr/bin:/bin:/usr/sbin:/sbin" # deliberately no omp binary on PATH + # Simulate leftovers: omp's own data dir, plus a full enable+disable cycle. + mkdir -p "$OMP_HOME/agent/sessions" + : > "$OMP_HOME/agent/agent.db" + enable_omp_hooks &>/dev/null || true + disable_omp_hooks &>/dev/null || true + if detect_omp &>/dev/null; then + echo "FAIL: detect_omp reported omp installed with no binary (leftover ~/.omp dir fooled it)" + exit 1 + fi + rm -rf "$sticky_home" +) +pass "detect_omp is binary-only: leftover ~/.omp dirs do not trigger detection" + +echo "All omp extension tests passed"