From 28fb23cdaf4fce03ed6dbfcb4bb13be324b181ac Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 1 Apr 2026 21:00:09 +0300 Subject: [PATCH] feat: add claude, codex, and opencode integrations --- INTEGRATIONS.md | 198 ++++ README.md | 34 +- src/cli.rs | 140 ++- .../assets/claude/herdr-agent-state.sh | 70 ++ .../assets/codex/herdr-agent-state.sh | 70 ++ .../assets/opencode/herdr-agent-state.js | 99 ++ src/integration/mod.rs | 999 +++++++++++++++++- src/main.rs | 2 +- 8 files changed, 1603 insertions(+), 9 deletions(-) create mode 100644 INTEGRATIONS.md create mode 100644 src/integration/assets/claude/herdr-agent-state.sh create mode 100644 src/integration/assets/codex/herdr-agent-state.sh create mode 100644 src/integration/assets/opencode/herdr-agent-state.js 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");