diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md
new file mode 100644
index 00000000..2c5ccb72
--- /dev/null
+++ b/INTEGRATIONS.md
@@ -0,0 +1,198 @@
+# integrations
+
+herdr works without any hook or plugin setup.
+
+out of the box, it detects supported agents by combining foreground process detection with screen heuristics. that is enough to give you workspace awareness with zero configuration.
+
+optional integrations improve the semantic signal on top of that.
+
+## how herdr uses integrations
+
+herdr uses a hybrid model:
+
+- **process detection** owns pane identity, liveness, and "the process is gone"
+- **agent integrations** report semantic state like `working`, `blocked`, and `idle` when the tool exposes those events
+- **screen heuristics** remain the fallback for gaps, unsupported tools, or incomplete hook surfaces
+
+that means hooks/plugins do **not** become the source of truth for pane ownership. they enrich state reporting; they do not replace process detection.
+
+## install commands
+
+```bash
+herdr integration install pi
+herdr integration install claude
+herdr integration install codex
+herdr integration install opencode
+```
+
+## uninstall commands
+
+```bash
+herdr integration uninstall pi
+herdr integration uninstall claude
+herdr integration uninstall codex
+herdr integration uninstall opencode
+```
+
+## pi
+
+install:
+
+```bash
+herdr integration install pi
+```
+
+this writes the bundled pi extension to:
+
+```text
+~/.pi/agent/extensions/herdr-agent-state.ts
+```
+
+pi is the cleanest integration. it already has an authoritative hook model, so herdr can get direct state reports without guessing as much from the terminal.
+
+uninstall:
+
+```bash
+herdr integration uninstall pi
+```
+
+this removes:
+
+```text
+~/.pi/agent/extensions/herdr-agent-state.ts
+```
+
+## claude code
+
+install:
+
+```bash
+herdr integration install claude
+```
+
+this:
+
+- writes the hook script to `~/.claude/hooks/herdr-agent-state.sh`
+- updates `~/.claude/settings.json`
+
+current hook mapping:
+
+- `UserPromptSubmit` → `working`
+- `PreToolUse` → `working`
+- `PermissionRequest` → `blocked`
+- `Stop` → `idle`
+- `SessionEnd` → `release`
+
+notes:
+
+- claude's current hook surface improves state reporting, but it is not a perfect permission lifecycle.
+- when a permission prompt is canceled, claude does not currently give herdr a clean hook event that always resolves the pane out of `blocked` immediately.
+- that is acceptable in herdr's model: process detection still owns liveness, and heuristics remain the fallback for unresolved edges.
+
+uninstall:
+
+```bash
+herdr integration uninstall claude
+```
+
+this:
+
+- removes `~/.claude/hooks/herdr-agent-state.sh`
+- removes herdr-owned hook entries from `~/.claude/settings.json`
+
+## codex
+
+install:
+
+```bash
+herdr integration install codex
+```
+
+this:
+
+- writes the hook script to `~/.codex/herdr-agent-state.sh`
+- updates `~/.codex/hooks.json`
+- ensures `codex_hooks = true` in `~/.codex/config.toml`
+
+current hook mapping:
+
+- `SessionStart` → `idle`
+- `UserPromptSubmit` → `working`
+- `PreToolUse` → `working`
+- `Stop` → `idle`
+
+notes:
+
+- codex does **not** currently expose a permission-specific hook like claude or opencode, so `blocked` still depends on herdr's normal heuristics.
+- codex currently renders hook lifecycle messages in its own tui, for example `Running SessionStart hook` and `SessionStart hook (completed)`.
+- that noise is an upstream codex limitation, not a herdr-specific issue.
+- codex has a `suppressOutput` field in its hook output schema, but it is currently not effective for suppressing those tui lifecycle lines.
+
+uninstall:
+
+```bash
+herdr integration uninstall codex
+```
+
+this:
+
+- removes `~/.codex/herdr-agent-state.sh`
+- removes herdr-owned hook entries from `~/.codex/hooks.json`
+- intentionally leaves `~/.codex/config.toml` alone
+
+that last point is deliberate: herdr does **not** try to guess whether `codex_hooks = true` is still needed for some other codex hook setup.
+
+## opencode
+
+install:
+
+```bash
+herdr integration install opencode
+```
+
+this writes the bundled plugin to:
+
+```text
+~/.config/opencode/plugins/herdr-agent-state.js
+```
+
+current plugin mapping:
+
+- `permission.asked` → `blocked`
+- `permission.replied: once|always` → `working`
+- `permission.replied: reject` → `idle`
+- `session.status: busy|retry` → `working`
+- `session.status: idle` → `idle`
+- `session.idle` → `idle`
+
+notes:
+
+- opencode has the richest event surface of the currently supported integrations.
+- herdr intentionally does **not** guess that `session.deleted` means process exit. process detection still owns liveness and pane identity.
+
+uninstall:
+
+```bash
+herdr integration uninstall opencode
+```
+
+this removes:
+
+```text
+~/.config/opencode/plugins/herdr-agent-state.js
+```
+
+## known limitations
+
+- these integrations only activate inside herdr-managed panes.
+- if an agent has an incomplete hook surface, herdr falls back to process detection and screen heuristics rather than inventing lease or ttl behavior.
+- codex currently shows hook lifecycle chatter in its own tui until upstream adds a real silent mode.
+
+## troubleshooting
+
+if an install command succeeds but you do not see improved state reporting:
+
+1. make sure you launched the agent inside a herdr pane
+2. restart the agent session so it picks up the new hook/plugin config
+3. verify the expected config file was written to the path above
+4. remember that unsupported transitions still fall back to herdr's built-in heuristics
diff --git a/README.md b/README.md
index 63dede83..4e49f284 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
herd your agents.
- herdr.dev · install · usage · configuration · agent skill · socket api
+ herdr.dev · install · usage · integrations · configuration · agent skill · socket api
---
@@ -239,10 +239,42 @@ this means detection works with any supported agent, installed any way, with zer
the heuristics are pattern-matched against each agent's actual terminal output: prompt boxes, spinners, waiting-for-input messages, tool execution indicators. detection runs on a separate async task per pane, polled every 300-500ms, decoupled from terminal rendering.
+## optional direct integrations
+
+herdr also supports optional direct integrations for tools that expose hooks or plugins:
+
+- [pi](./INTEGRATIONS.md#pi)
+- [claude code](./INTEGRATIONS.md#claude-code)
+- [codex](./INTEGRATIONS.md#codex)
+- [opencode](./INTEGRATIONS.md#opencode)
+
+install them with:
+
+```bash
+herdr integration install pi
+herdr integration install claude
+herdr integration install codex
+herdr integration install opencode
+```
+
+remove them with:
+
+```bash
+herdr integration uninstall pi
+herdr integration uninstall claude
+herdr integration uninstall codex
+herdr integration uninstall opencode
+```
+
+these integrations improve semantic state reporting, but they do not replace herdr's core process detection model. for setup details, file locations, caveats, and uninstall behavior, see [`INTEGRATIONS.md`](./INTEGRATIONS.md).
+
+known codex caveat: codex currently renders hook lifecycle lines in its own tui when hooks are enabled. that noise is upstream codex behavior, not herdr-specific.
+
## api and automation
for direct integration details, use the docs instead of reverse-engineering the README:
+- [`INTEGRATIONS.md`](./INTEGRATIONS.md) — install and behavior notes for pi, claude code, codex, and opencode
- [`SKILL.md`](./SKILL.md) — reusable agent skill for agents already running inside herdr
- [`SOCKET_API.md`](./SOCKET_API.md) — canonical socket protocol + cli wrapper reference
diff --git a/src/cli.rs b/src/cli.rs
index 3f45c2ed..d13f059e 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -99,6 +99,7 @@ fn run_integration_command(args: &[String]) -> std::io::Result {
match subcommand {
"install" => integration_install(&args[1..]),
+ "uninstall" => integration_uninstall(&args[1..]),
_ => {
print_integration_help();
Ok(2)
@@ -450,11 +451,11 @@ fn pane_run(args: &[String]) -> std::io::Result {
fn integration_install(args: &[String]) -> std::io::Result {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
- eprintln!("usage: herdr integration install ");
+ eprintln!("usage: herdr integration install ");
return Ok(2);
};
if args.len() != 1 {
- eprintln!("usage: herdr integration install ");
+ eprintln!("usage: herdr integration install ");
return Ok(2);
}
@@ -464,9 +465,135 @@ fn integration_install(args: &[String]) -> std::io::Result {
println!("installed pi integration to {}", path.display());
Ok(0)
}
+ "claude" => {
+ let installed = crate::integration::install_claude()?;
+ println!(
+ "installed claude integration hook to {}",
+ installed.hook_path.display()
+ );
+ println!(
+ "ensured claude settings at {}",
+ installed.settings_path.display()
+ );
+ Ok(0)
+ }
+ "codex" => {
+ let installed = crate::integration::install_codex()?;
+ println!(
+ "installed codex integration hook to {}",
+ installed.hook_path.display()
+ );
+ println!("ensured codex hooks at {}", installed.hooks_path.display());
+ println!(
+ "ensured codex config at {}",
+ installed.config_path.display()
+ );
+ Ok(0)
+ }
+ "opencode" => {
+ let installed = crate::integration::install_opencode()?;
+ println!(
+ "installed opencode integration plugin to {}",
+ installed.plugin_path.display()
+ );
+ Ok(0)
+ }
_ => {
eprintln!("unknown integration target: {target}");
- eprintln!("currently supported: pi");
+ eprintln!("currently supported: pi, claude, codex, opencode");
+ Ok(2)
+ }
+ }
+}
+
+fn integration_uninstall(args: &[String]) -> std::io::Result {
+ let Some(target) = args.first().map(|arg| arg.as_str()) else {
+ eprintln!("usage: herdr integration uninstall ");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr integration uninstall ");
+ return Ok(2);
+ }
+
+ match target {
+ "pi" => {
+ let result = crate::integration::uninstall_pi()?;
+ if result.removed_extension {
+ println!(
+ "removed pi integration extension at {}",
+ result.extension_path.display()
+ );
+ } else {
+ println!(
+ "no pi integration extension found at {}",
+ result.extension_path.display()
+ );
+ }
+ Ok(0)
+ }
+ "claude" => {
+ let result = crate::integration::uninstall_claude()?;
+ if result.removed_hook_file {
+ println!("removed claude hook at {}", result.hook_path.display());
+ } else {
+ println!("no claude hook found at {}", result.hook_path.display());
+ }
+ if result.updated_settings {
+ println!(
+ "removed herdr claude hook entries from {}",
+ result.settings_path.display()
+ );
+ } else {
+ println!(
+ "no herdr claude hook entries found in {}",
+ result.settings_path.display()
+ );
+ }
+ Ok(0)
+ }
+ "codex" => {
+ let result = crate::integration::uninstall_codex()?;
+ if result.removed_hook_file {
+ println!("removed codex hook at {}", result.hook_path.display());
+ } else {
+ println!("no codex hook found at {}", result.hook_path.display());
+ }
+ if result.updated_hooks {
+ println!(
+ "removed herdr codex hook entries from {}",
+ result.hooks_path.display()
+ );
+ } else {
+ println!(
+ "no herdr codex hook entries found in {}",
+ result.hooks_path.display()
+ );
+ }
+ println!(
+ "left codex config unchanged at {}",
+ result.config_path.display()
+ );
+ Ok(0)
+ }
+ "opencode" => {
+ let result = crate::integration::uninstall_opencode()?;
+ if result.removed_plugin {
+ println!(
+ "removed opencode integration plugin at {}",
+ result.plugin_path.display()
+ );
+ } else {
+ println!(
+ "no opencode integration plugin found at {}",
+ result.plugin_path.display()
+ );
+ }
+ Ok(0)
+ }
+ _ => {
+ eprintln!("unknown integration target: {target}");
+ eprintln!("currently supported: pi, claude, codex, opencode");
Ok(2)
}
}
@@ -786,6 +913,13 @@ fn print_wait_help() {
fn print_integration_help() {
eprintln!("herdr integration commands:");
eprintln!(" herdr integration install pi");
+ eprintln!(" herdr integration install claude");
+ eprintln!(" herdr integration install codex");
+ eprintln!(" herdr integration install opencode");
+ eprintln!(" herdr integration uninstall pi");
+ eprintln!(" herdr integration uninstall claude");
+ eprintln!(" herdr integration uninstall codex");
+ eprintln!(" herdr integration uninstall opencode");
}
fn _print_json(value: &T) {
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
new file mode 100644
index 00000000..3a202a2e
--- /dev/null
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+# installed by herdr
+# safe to edit. this hook only activates inside herdr-managed panes.
+
+set -eu
+
+action="${1:-}"
+cat >/dev/null 2>/dev/null || true
+
+case "$action" in
+ working|idle|blocked|release) ;;
+ *) exit 0 ;;
+esac
+
+[ "${HERDR_ENV:-}" = "1" ] || exit 0
+[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
+[ -n "${HERDR_PANE_ID:-}" ] || exit 0
+command -v python3 >/dev/null 2>&1 || exit 0
+
+HERDR_ACTION="$action" python3 - <<'PY'
+import json
+import os
+import random
+import socket
+import time
+
+source = "herdr:claude"
+action = os.environ.get("HERDR_ACTION", "")
+pane_id = os.environ.get("HERDR_PANE_ID")
+socket_path = os.environ.get("HERDR_SOCKET_PATH")
+
+if not pane_id or not socket_path:
+ raise SystemExit(0)
+
+request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
+if action == "release":
+ request = {
+ "id": request_id,
+ "method": "pane.release_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "claude",
+ },
+ }
+else:
+ request = {
+ "id": request_id,
+ "method": "pane.report_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "claude",
+ "state": action,
+ },
+ }
+
+try:
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ client.settimeout(0.5)
+ client.connect(socket_path)
+ client.sendall((json.dumps(request) + "\n").encode())
+ try:
+ client.recv(4096)
+ except Exception:
+ pass
+ client.close()
+except Exception:
+ pass
+PY
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
new file mode 100644
index 00000000..01510d6d
--- /dev/null
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+# installed by herdr
+# safe to edit. this hook only activates inside herdr-managed panes.
+
+set -eu
+
+action="${1:-}"
+cat >/dev/null 2>/dev/null || true
+
+case "$action" in
+ working|idle|blocked|release) ;;
+ *) exit 0 ;;
+esac
+
+[ "${HERDR_ENV:-}" = "1" ] || exit 0
+[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
+[ -n "${HERDR_PANE_ID:-}" ] || exit 0
+command -v python3 >/dev/null 2>&1 || exit 0
+
+HERDR_ACTION="$action" python3 - <<'PY'
+import json
+import os
+import random
+import socket
+import time
+
+source = "herdr:codex"
+action = os.environ.get("HERDR_ACTION", "")
+pane_id = os.environ.get("HERDR_PANE_ID")
+socket_path = os.environ.get("HERDR_SOCKET_PATH")
+
+if not pane_id or not socket_path:
+ raise SystemExit(0)
+
+request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
+if action == "release":
+ request = {
+ "id": request_id,
+ "method": "pane.release_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "codex",
+ },
+ }
+else:
+ request = {
+ "id": request_id,
+ "method": "pane.report_agent",
+ "params": {
+ "pane_id": pane_id,
+ "source": source,
+ "agent": "codex",
+ "state": action,
+ },
+ }
+
+try:
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ client.settimeout(0.5)
+ client.connect(socket_path)
+ client.sendall((json.dumps(request) + "\n").encode())
+ try:
+ client.recv(4096)
+ except Exception:
+ pass
+ client.close()
+except Exception:
+ pass
+PY
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
new file mode 100644
index 00000000..8dbaa0d8
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -0,0 +1,99 @@
+import net from "node:net";
+
+const SOURCE = "herdr:opencode";
+
+function reportState(action) {
+ const paneId = process.env.HERDR_PANE_ID;
+ const socketPath = process.env.HERDR_SOCKET_PATH;
+
+ if (!paneId || !socketPath) {
+ return Promise.resolve();
+ }
+
+ const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0")}`;
+ const request = {
+ id: requestId,
+ method: action === "release" ? "pane.release_agent" : "pane.report_agent",
+ params:
+ action === "release"
+ ? {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: "opencode",
+ }
+ : {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: "opencode",
+ state: action,
+ },
+ };
+
+ return new Promise((resolve) => {
+ const client = net.createConnection(socketPath, () => {
+ client.write(`${JSON.stringify(request)}\n`);
+ });
+
+ const finish = () => {
+ client.destroy();
+ resolve();
+ };
+
+ client.setTimeout(500, finish);
+ client.on("data", finish);
+ client.on("error", finish);
+ client.on("end", finish);
+ client.on("close", resolve);
+ });
+}
+
+export const HerdrAgentStatePlugin = async () => {
+ if (
+ process.env.HERDR_ENV !== "1" ||
+ !process.env.HERDR_SOCKET_PATH ||
+ !process.env.HERDR_PANE_ID
+ ) {
+ return {};
+ }
+
+ return {
+ event: async ({ event }) => {
+ const type = event?.type;
+ const properties = event?.properties ?? {};
+
+ switch (type) {
+ case "permission.asked":
+ await reportState("blocked");
+ break;
+ case "permission.replied": {
+ const reply = properties.reply ?? properties.response;
+ if (reply === "reject") {
+ await reportState("idle");
+ } else if (reply === "once" || reply === "always") {
+ await reportState("working");
+ }
+ break;
+ }
+ case "session.status": {
+ const status =
+ typeof properties.status === "string"
+ ? properties.status
+ : properties.status?.type;
+ if (status === "busy" || status === "retry") {
+ await reportState("working");
+ } else if (status === "idle") {
+ await reportState("idle");
+ }
+ break;
+ }
+ case "session.idle":
+ await reportState("idle");
+ break;
+ default:
+ break;
+ }
+ },
+ };
+};
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 65c5c6ad..5240420a 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -1,14 +1,68 @@
use std::fs;
use std::io;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use portable_pty::CommandBuilder;
+use serde_json::{json, Map, Value};
use crate::layout::PaneId;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
+const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
+const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
+const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
+const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
+const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
+const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
+
+#[derive(Debug)]
+pub(crate) struct ClaudeInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct CodexInstallPaths {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub config_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct OpenCodeInstallPaths {
+ pub plugin_path: PathBuf,
+}
+
+#[derive(Debug)]
+pub(crate) struct PiUninstallResult {
+ pub extension_path: PathBuf,
+ pub removed_extension: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct ClaudeUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct CodexUninstallResult {
+ pub hook_path: PathBuf,
+ pub hooks_path: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_hooks: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct OpenCodeUninstallResult {
+ pub plugin_path: PathBuf,
+ pub removed_plugin: bool,
+}
pub(crate) fn apply_pane_env(cmd: &mut CommandBuilder, pane_id: PaneId) {
cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
@@ -29,16 +83,553 @@ pub(crate) fn install_pi() -> io::Result {
Ok(path)
}
+pub(crate) fn install_claude() -> io::Result {
+ let dir = claude_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "claude directory not found at {}. install claude code first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CLAUDE_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let settings_path = dir.join("settings.json");
+ let mut settings = if settings_path.is_file() {
+ serde_json::from_str::(&fs::read_to_string(&settings_path)?).map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?
+ } else {
+ json!({})
+ };
+
+ let hooks = ensure_hooks_object(
+ &mut settings,
+ &settings_path,
+ "claude settings",
+ "claude settings hooks",
+ )?;
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ ensure_command_hook(
+ hooks,
+ "UserPromptSubmit",
+ format!("bash {quoted_hook_path} working"),
+ 10,
+ Some("*"),
+ )?;
+ ensure_command_hook(
+ hooks,
+ "PreToolUse",
+ format!("bash {quoted_hook_path} working"),
+ 10,
+ Some("*"),
+ )?;
+ ensure_command_hook(
+ hooks,
+ "PermissionRequest",
+ format!("bash {quoted_hook_path} blocked"),
+ 10,
+ Some("*"),
+ )?;
+ ensure_command_hook(
+ hooks,
+ "Stop",
+ format!("bash {quoted_hook_path} idle"),
+ 10,
+ Some("*"),
+ )?;
+ ensure_command_hook(
+ hooks,
+ "SessionEnd",
+ format!("bash {quoted_hook_path} release"),
+ 10,
+ Some("*"),
+ )?;
+
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+
+ Ok(ClaudeInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
+pub(crate) fn install_codex() -> io::Result {
+ let dir = codex_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "codex config directory not found at {}. install codex first",
+ dir.display()
+ )));
+ }
+
+ let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CODEX_HOOK_ASSET)?;
+ make_executable(&hook_path)?;
+
+ let hooks_path = dir.join("hooks.json");
+ let mut hooks_file = if hooks_path.is_file() {
+ serde_json::from_str::(&fs::read_to_string(&hooks_path)?).map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?
+ } else {
+ json!({})
+ };
+
+ let hooks = ensure_hooks_object(
+ &mut hooks_file,
+ &hooks_path,
+ "codex hooks file",
+ "codex hooks file hooks",
+ )?;
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ ensure_command_hook(
+ hooks,
+ "SessionStart",
+ format!("bash {quoted_hook_path} idle"),
+ 10,
+ None,
+ )?;
+ ensure_command_hook(
+ hooks,
+ "UserPromptSubmit",
+ format!("bash {quoted_hook_path} working"),
+ 10,
+ None,
+ )?;
+ ensure_command_hook(
+ hooks,
+ "PreToolUse",
+ format!("bash {quoted_hook_path} working"),
+ 10,
+ None,
+ )?;
+ ensure_command_hook(
+ hooks,
+ "Stop",
+ format!("bash {quoted_hook_path} idle"),
+ 10,
+ None,
+ )?;
+
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+
+ let config_path = dir.join("config.toml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = build_codex_config_with_hooks(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+
+ Ok(CodexInstallPaths {
+ hook_path,
+ hooks_path,
+ config_path,
+ })
+}
+
+pub(crate) fn install_opencode() -> io::Result {
+ let dir = opencode_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "opencode config directory not found at {}. install opencode first",
+ dir.display()
+ )));
+ }
+
+ let plugins_dir = dir.join("plugins");
+ fs::create_dir_all(&plugins_dir)?;
+
+ let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
+
+ Ok(OpenCodeInstallPaths { plugin_path })
+}
+
+pub(crate) fn uninstall_pi() -> io::Result {
+ let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME);
+ let removed_extension = remove_file_if_exists(&extension_path)?;
+
+ Ok(PiUninstallResult {
+ extension_path,
+ removed_extension,
+ })
+}
+
+pub(crate) fn uninstall_claude() -> io::Result {
+ let hook_path = claude_dir()?.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME);
+ let settings_path = claude_dir()?.join("settings.json");
+ let mut updated_settings = false;
+
+ if settings_path.is_file() {
+ let mut settings = serde_json::from_str::(&fs::read_to_string(&settings_path)?)
+ .map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut settings,
+ &settings_path,
+ "claude settings",
+ "claude settings hooks",
+ )? {
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ updated_settings |= remove_command_hook(
+ hooks,
+ "UserPromptSubmit",
+ &format!("bash {quoted_hook_path} working"),
+ )?;
+ updated_settings |= remove_command_hook(
+ hooks,
+ "PreToolUse",
+ &format!("bash {quoted_hook_path} working"),
+ )?;
+ updated_settings |= remove_command_hook(
+ hooks,
+ "PermissionRequest",
+ &format!("bash {quoted_hook_path} blocked"),
+ )?;
+ updated_settings |=
+ remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
+ updated_settings |= remove_command_hook(
+ hooks,
+ "SessionEnd",
+ &format!("bash {quoted_hook_path} release"),
+ )?;
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(ClaudeUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
+pub(crate) fn uninstall_codex() -> io::Result {
+ let codex_dir = codex_dir()?;
+ let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
+ let hooks_path = codex_dir.join("hooks.json");
+ let config_path = codex_dir.join("config.toml");
+ let mut updated_hooks = false;
+
+ if hooks_path.is_file() {
+ let mut hooks_file = serde_json::from_str::(&fs::read_to_string(&hooks_path)?)
+ .map_err(|err| {
+ io::Error::other(format!("failed to parse {}: {err}", hooks_path.display()))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut hooks_file,
+ &hooks_path,
+ "codex hooks file",
+ "codex hooks file hooks",
+ )? {
+ let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
+ updated_hooks |= remove_command_hook(
+ hooks,
+ "SessionStart",
+ &format!("bash {quoted_hook_path} idle"),
+ )?;
+ updated_hooks |= remove_command_hook(
+ hooks,
+ "UserPromptSubmit",
+ &format!("bash {quoted_hook_path} working"),
+ )?;
+ updated_hooks |= remove_command_hook(
+ hooks,
+ "PreToolUse",
+ &format!("bash {quoted_hook_path} working"),
+ )?;
+ updated_hooks |=
+ remove_command_hook(hooks, "Stop", &format!("bash {quoted_hook_path} idle"))?;
+ }
+
+ if updated_hooks {
+ fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(CodexUninstallResult {
+ hook_path,
+ hooks_path,
+ config_path,
+ removed_hook_file,
+ updated_hooks,
+ })
+}
+
+pub(crate) fn uninstall_opencode() -> io::Result {
+ let plugin_path = opencode_dir()?
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let removed_plugin = remove_file_if_exists(&plugin_path)?;
+
+ Ok(OpenCodeUninstallResult {
+ plugin_path,
+ removed_plugin,
+ })
+}
+
+fn ensure_hooks_object<'a>(
+ settings: &'a mut Value,
+ settings_path: &Path,
+ root_description: &str,
+ hooks_description: &str,
+) -> io::Result<&'a mut Map> {
+ let root = settings.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{root_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })?;
+
+ let hooks = root.entry("hooks").or_insert_with(|| json!({}));
+ hooks.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{hooks_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })
+}
+
+fn hooks_object_if_present<'a>(
+ settings: &'a mut Value,
+ settings_path: &Path,
+ root_description: &str,
+ hooks_description: &str,
+) -> io::Result>> {
+ let root = settings.as_object_mut().ok_or_else(|| {
+ io::Error::other(format!(
+ "{root_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })?;
+
+ let Some(hooks) = root.get_mut("hooks") else {
+ return Ok(None);
+ };
+
+ hooks.as_object_mut().map(Some).ok_or_else(|| {
+ io::Error::other(format!(
+ "{hooks_description} at {} must be a JSON object",
+ settings_path.display()
+ ))
+ })
+}
+
+fn ensure_command_hook(
+ hooks: &mut Map,
+ event: &str,
+ command: String,
+ timeout: u64,
+ matcher: Option<&str>,
+) -> io::Result<()> {
+ let entries = hooks
+ .entry(event.to_string())
+ .or_insert_with(|| Value::Array(Vec::new()))
+ .as_array_mut()
+ .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
+
+ let already_installed = entries.iter().any(|entry| {
+ entry
+ .get("hooks")
+ .and_then(Value::as_array)
+ .is_some_and(|hook_entries| {
+ hook_entries.iter().any(|hook| {
+ hook.get("type").and_then(Value::as_str) == Some("command")
+ && hook.get("command").and_then(Value::as_str) == Some(command.as_str())
+ })
+ })
+ });
+ if already_installed {
+ return Ok(());
+ }
+
+ let mut entry = Map::new();
+ if let Some(matcher) = matcher {
+ entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
+ }
+ entry.insert(
+ "hooks".to_string(),
+ json!([
+ {
+ "type": "command",
+ "command": command,
+ "timeout": timeout,
+ }
+ ]),
+ );
+
+ entries.push(Value::Object(entry));
+ Ok(())
+}
+
+fn remove_command_hook(
+ hooks: &mut Map,
+ event: &str,
+ command: &str,
+) -> io::Result {
+ let Some(entries_value) = hooks.get_mut(event) else {
+ return Ok(false);
+ };
+
+ let entries = entries_value
+ .as_array_mut()
+ .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
+
+ let mut removed = false;
+ entries.retain_mut(|entry| {
+ let Some(entry_object) = entry.as_object_mut() else {
+ return true;
+ };
+ let Some(hook_entries) = entry_object.get_mut("hooks") else {
+ return true;
+ };
+ let Some(hook_entries) = hook_entries.as_array_mut() else {
+ return true;
+ };
+
+ let before = hook_entries.len();
+ hook_entries.retain(|hook| !is_matching_command_hook(hook, command));
+ if hook_entries.len() != before {
+ removed = true;
+ }
+
+ !hook_entries.is_empty()
+ });
+
+ let remove_event = entries.is_empty();
+ if remove_event {
+ hooks.remove(event);
+ }
+
+ Ok(removed)
+}
+
+fn is_matching_command_hook(hook: &Value, command: &str) -> bool {
+ hook.get("type").and_then(Value::as_str) == Some("command")
+ && hook.get("command").and_then(Value::as_str) == Some(command)
+}
+
+fn remove_file_if_exists(path: &Path) -> io::Result {
+ match fs::remove_file(path) {
+ Ok(()) => Ok(true),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+fn build_codex_config_with_hooks(content: &str) -> String {
+ let mut lines: Vec = content.lines().map(str::to_string).collect();
+ let trailing_newline = content.ends_with('\n');
+
+ if let Some(index) = lines
+ .iter()
+ .position(|line| is_toml_key(line, "codex_hooks"))
+ {
+ lines[index] = "codex_hooks = true".to_string();
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
+ result.push('\n');
+ }
+ return result;
+ }
+
+ if let Some(index) = lines.iter().position(|line| line.trim() == "[features]") {
+ lines.insert(index + 1, "codex_hooks = true".to_string());
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
+ result.push('\n');
+ }
+ return result;
+ }
+
+ let mut result = content.trim_end_matches('\n').to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ result.push('\n');
+ }
+ result.push_str("[features]\ncodex_hooks = true\n");
+ result
+}
+
+fn is_toml_key(line: &str, key: &str) -> bool {
+ let trimmed = line.trim();
+ if trimmed.starts_with('#') || !trimmed.starts_with(key) {
+ return false;
+ }
+
+ trimmed[key.len()..].trim_start().starts_with('=')
+}
+
+fn shell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', "'\"'\"'"))
+}
+
+fn make_executable(path: &Path) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ let mut perms = fs::metadata(path)?.permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(path, perms)?;
+ }
+
+ Ok(())
+}
+
fn pi_extension_dir() -> io::Result {
- let home = std::env::var("HOME")
+ Ok(home_dir()?.join(".pi/agent/extensions"))
+}
+
+fn claude_dir() -> io::Result {
+ Ok(home_dir()?.join(".claude"))
+}
+
+fn codex_dir() -> io::Result {
+ Ok(home_dir()?.join(".codex"))
+}
+
+fn opencode_dir() -> io::Result {
+ Ok(home_dir()?.join(".config/opencode"))
+}
+
+fn home_dir() -> io::Result {
+ std::env::var("HOME")
.map(PathBuf::from)
- .map_err(|_| io::Error::other("HOME is not set; cannot locate ~/.pi/agent/extensions"))?;
- Ok(home.join(".pi/agent/extensions"))
+ .map_err(|_| io::Error::other("HOME is not set; cannot locate home directory"))
}
#[cfg(test)]
mod tests {
use super::*;
+ use std::sync::{Mutex, MutexGuard, OnceLock};
fn unique_base() -> PathBuf {
std::env::temp_dir().join(format!(
@@ -51,8 +642,14 @@ mod tests {
))
}
+ fn env_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
+ }
+
#[test]
fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
+ let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
@@ -69,8 +666,32 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
+ #[test]
+ fn uninstall_pi_removes_embedded_extension_when_present() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_pi().unwrap();
+
+ assert_eq!(
+ result.extension_path,
+ ext_dir.join(PI_EXTENSION_INSTALL_NAME)
+ );
+ assert!(result.removed_extension);
+ assert!(!result.extension_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
#[test]
fn install_pi_errors_when_extension_dir_missing() {
+ let _lock = env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
@@ -83,4 +704,374 @@ mod tests {
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
+
+ #[test]
+ fn install_claude_writes_hook_and_updates_settings() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ fs::create_dir_all(&claude_dir).unwrap();
+ fs::write(
+ claude_dir.join("settings.json"),
+ r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_claude().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap();
+
+ assert_eq!(
+ installed.hook_path,
+ claude_dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)
+ );
+ assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
+ assert!(settings["permissions"]["allow"].is_array());
+ assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*");
+ assert!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" working")
+ );
+ assert!(settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" working"));
+ assert!(
+ settings["hooks"]["PermissionRequest"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" blocked")
+ );
+ assert!(settings["hooks"]["Stop"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" idle"));
+ assert!(settings["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" release"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_claude_is_idempotent_for_hook_entries() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ fs::create_dir_all(&claude_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_claude().unwrap();
+ install_claude().unwrap();
+
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
+ .unwrap();
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
+ assert_eq!(
+ settings["hooks"]["PermissionRequest"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1);
+ assert_eq!(settings["hooks"]["SessionEnd"].as_array().unwrap().len(), 1);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_claude_removes_herdr_hooks_and_preserves_others() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let claude_dir = home.join(".claude");
+ let hooks_dir = claude_dir.join("hooks");
+ fs::create_dir_all(&hooks_dir).unwrap();
+ let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CLAUDE_HOOK_ASSET).unwrap();
+ fs::write(
+ claude_dir.join("settings.json"),
+ format!(
+ r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
+ hook_path.display(),
+ hook_path.display(),
+ hook_path.display(),
+ ),
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_claude().unwrap();
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(claude_dir.join("settings.json")).unwrap())
+ .unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!result.hook_path.exists());
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(settings["hooks"].get("Stop").is_none());
+ assert!(settings["hooks"].get("SessionEnd").is_none());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_claude_errors_when_claude_dir_missing() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_claude().unwrap_err().to_string();
+
+ assert!(err.contains("claude directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_codex_writes_hook_and_updates_hooks_and_config() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.4\"\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_codex().unwrap();
+ let hook_content = fs::read_to_string(&installed.hook_path).unwrap();
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+
+ assert_eq!(installed.hook_path, codex_dir.join(CODEX_HOOK_INSTALL_NAME));
+ assert_eq!(installed.hooks_path, codex_dir.join("hooks.json"));
+ assert_eq!(installed.config_path, codex_dir.join("config.toml"));
+ assert_eq!(hook_content, CODEX_HOOK_ASSET);
+ assert!(hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" idle"));
+ assert!(hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" working"));
+ assert!(hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" working"));
+ assert!(hooks["hooks"]["Stop"][0]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .contains(" idle"));
+ assert!(config.contains("model = \"gpt-5.4\""));
+ assert!(config.contains("[features]"));
+ assert!(config.contains("codex_hooks = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "[features]\ncodex_hooks = false\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_codex().unwrap();
+ install_codex().unwrap();
+
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
+ .unwrap();
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
+ assert_eq!(
+ hooks["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
+ 1
+ );
+ assert_eq!(hooks["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
+ assert_eq!(hooks["hooks"]["Stop"].as_array().unwrap().len(), 1);
+ assert_eq!(config.matches("codex_hooks = true").count(), 1);
+ assert!(config.contains("other = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let codex_dir = home.join(".codex");
+ fs::create_dir_all(&codex_dir).unwrap();
+ let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME);
+ fs::write(&hook_path, CODEX_HOOK_ASSET).unwrap();
+ fs::write(
+ codex_dir.join("hooks.json"),
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"Stop":[{{"hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}]}}}}"#,
+ hook_path.display(),
+ hook_path.display(),
+ hook_path.display(),
+ ),
+ )
+ .unwrap();
+ fs::write(
+ codex_dir.join("config.toml"),
+ "[features]\ncodex_hooks = true\nother = true\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_codex().unwrap();
+ let hooks: Value =
+ serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
+ .unwrap();
+ let config = fs::read_to_string(codex_dir.join("config.toml")).unwrap();
+
+ assert!(result.removed_hook_file);
+ assert!(result.updated_hooks);
+ assert!(!result.hook_path.exists());
+ assert!(hooks["hooks"].get("SessionStart").is_none());
+ assert!(hooks["hooks"].get("Stop").is_none());
+ assert_eq!(
+ hooks["hooks"]["UserPromptSubmit"][0]["hooks"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
+ "echo keep"
+ );
+ assert!(config.contains("codex_hooks = true"));
+ assert!(config.contains("other = true"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_codex_errors_when_config_dir_missing() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_codex().unwrap_err().to_string();
+
+ assert!(err.contains("codex config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_opencode_writes_plugin_to_plugins_dir() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_opencode().unwrap();
+ let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_path,
+ opencode_dir
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_opencode_removes_plugin_when_present() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode/plugins");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ fs::write(
+ opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
+ OPENCODE_PLUGIN_ASSET,
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_opencode().unwrap();
+
+ assert!(result.removed_plugin);
+ assert!(!result.plugin_path.exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_opencode_errors_when_config_dir_missing() {
+ let _lock = env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("opencode config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
}
diff --git a/src/main.rs b/src/main.rs
index ab841194..a23ec6c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -195,7 +195,7 @@ fn main() -> io::Result<()> {
println!(" workspace workspace helpers over the socket api");
println!(" pane pane control helpers over the socket api");
println!(" wait blocking wait helpers over the socket api");
- println!(" integration install built-in agent integrations");
+ println!(" integration manage built-in agent integrations");
println!();
println!("Options:");
println!(" --no-session Don't restore or save sessions");