From bfb9e756a01a8ddf4e20d2451c8316ab33454bb4 Mon Sep 17 00:00:00 2001 From: Coder Wayne <1418551940@qq.com> Date: Wed, 27 May 2026 22:42:26 +0800 Subject: [PATCH] feat: add Qoder CLI integration (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add qodercli agent integration Recognize qodercli as an agent in herdr panes, with basic Idle/Working/ Blocked screen-state detection mirroring the existing claude / codex patterns. Adds an IntegrationTarget::Qodercli variant, install_target wiring for shell hooks under src/integration/assets/qodercli/, the herdr integration install qodercli CLI surface, and sound-override coverage so qodercli notifications follow the same routing rules as other agents. refs #308 * fix: detect qodercli permission required as blocked refs #308 * fix: detect qodercli ask-user prompt as blocked refs #308 * refactor: extract has_qodercli_blocked_prompt helper aligning detect_claude refs #308 * fix: tighten qodercli working state detection Combine spinner-row strictness and explicit cancel-hint matching, and stop matching the bare word 'working'. This avoids false positives when narrative output (logs, commit messages, Markdown) contains the word 'working' or when a stale braille glyph is sitting in the scrollback from a previous frame. - has_qodercli_spinner_row: require row-start braille glyph + space + alphabetic char so only an active spinner row triggers Working. - has_qodercli_working_hint: match '(esc to cancel,' as a positive signal that survives spinner masking (e.g. when a hook icon replaces the spinner glyph). refs #308 * fix: short-circuit qodercli press-again banners to idle refs #308 * fix: detect qodercli AskUser dialog by its 'Asking User' title refs #308 * feat: recognize qodercli, qoderclicn, qoder, qodercn process names Map qodercli, qoderclicn, qoder, and qodercn process names all to the Qodercli agent. Covers both international (qodercli/qoder) and CN (qoderclicn/qodercn) Qoder CLI binary names. refs #308 * feat: register qodercli hook in qoder settings.json with stdin payload Address the integration-layer feedback on #308: - `herdr integration install qodercli` now writes the bundled hook into ~/.qoder/hooks/herdr-agent-state.sh AND registers it in ~/.qoder/settings.json under the schema documented at https://docs.qoder.com/zh/cli/hooks (and confirmed against the packages/core/src/hooks/types.ts type definitions in qodercli's own source tree). The hooks object is keyed by event name — SessionStart / UserPromptSubmit / PreToolUse / PermissionRequest / Stop / SessionEnd — with each entry holding a matcher and a list of `{ type: "command", command, timeout }` invocations, mirroring how install_claude already wires claude code's settings.json. - The bundled assets/qodercli/herdr-agent-state.sh now reads the hook payload from stdin (per HookInput in qodercli's types.ts: session_id, transcript_path, cwd, hook_event_name, agent_id, ...). No environment variable is consulted to identify the event; SubagentStop and subagent release events return early so a recap or away-summary frame cannot revive an idle pane, mirroring assets/claude/herdr-agent-state.sh. - uninstall_qodercli reverses the same set of entries while preserving any foreign hook entries the user may have configured by hand. - Tests cover write / idempotency / uninstall-preserves-others and lock the stdin contract via a bundled-asset assertion. refs #308 --------- Co-authored-by: coderwayne Co-authored-by: Can Celik --- src/api/schema.rs | 1 + src/cli/integration.rs | 13 +- src/config/sound.rs | 3 + src/detect.rs | 255 ++++++++++++ .../assets/qodercli/herdr-agent-state.sh | 114 +++++ src/integration/mod.rs | 389 +++++++++++++++++- 6 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 src/integration/assets/qodercli/herdr-agent-state.sh diff --git a/src/api/schema.rs b/src/api/schema.rs index 6eed4d2c..aa7cbd3c 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -510,6 +510,7 @@ pub enum IntegrationTarget { Codex, Opencode, Hermes, + Qodercli, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 4814d70d..d347628c 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -102,11 +102,15 @@ fn parse_integration_target( action: &str, ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!("usage: herdr integration {action} "); + eprintln!( + "usage: herdr integration {action} " + ); return Ok(None); }; if args.len() != 1 { - eprintln!("usage: herdr integration {action} "); + eprintln!( + "usage: herdr integration {action} " + ); return Ok(None); } @@ -117,9 +121,10 @@ fn parse_integration_target( "codex" => IntegrationTarget::Codex, "opencode" => IntegrationTarget::Opencode, "hermes" => IntegrationTarget::Hermes, + "qodercli" => IntegrationTarget::Qodercli, _ => { eprintln!("unknown integration target: {target}"); - eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes"); + eprintln!("currently supported: pi, omp, claude, codex, opencode, hermes, qodercli"); return Ok(None); } }; @@ -135,11 +140,13 @@ fn print_integration_help() { eprintln!(" herdr integration install codex"); eprintln!(" herdr integration install opencode"); eprintln!(" herdr integration install hermes"); + eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration uninstall pi"); eprintln!(" herdr integration uninstall omp"); eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall opencode"); eprintln!(" herdr integration uninstall hermes"); + eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration status [--outdated-only]"); } diff --git a/src/config/sound.rs b/src/config/sound.rs index 439454ab..e4f15108 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -40,6 +40,7 @@ pub struct AgentSoundOverrides { pub amp: AgentSoundSetting, pub grok: AgentSoundSetting, pub hermes: AgentSoundSetting, + pub qodercli: AgentSoundSetting, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] @@ -130,6 +131,7 @@ impl AgentSoundOverrides { Some(Agent::Amp) => self.amp, Some(Agent::Grok) => self.grok, Some(Agent::Hermes) => self.hermes, + Some(Agent::Qodercli) => self.qodercli, None => AgentSoundSetting::Default, } } @@ -165,6 +167,7 @@ impl Default for AgentSoundOverrides { amp: AgentSoundSetting::Default, grok: AgentSoundSetting::Default, hermes: AgentSoundSetting::Default, + qodercli: AgentSoundSetting::Default, } } } diff --git a/src/detect.rs b/src/detect.rs index 500d6eb3..a38251ce 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -52,6 +52,7 @@ pub enum Agent { Amp, Grok, Hermes, + Qodercli, } pub fn agent_label(agent: Agent) -> &'static str { @@ -71,6 +72,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Amp => "amp", Agent::Grok => "grok", Agent::Hermes => "hermes", + Agent::Qodercli => "qodercli", } } @@ -92,6 +94,7 @@ pub fn parse_agent_label(agent: &str) -> Option { "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), + "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), _ => None, } } @@ -117,6 +120,7 @@ pub fn identify_agent(process_name: &str) -> Option { "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), + "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), _ => None, } } @@ -184,6 +188,7 @@ pub fn detect_agent(agent: Option, screen_content: &str) -> AgentDetectio Agent::Amp => detect_amp(screen_content), Agent::Grok => detect_grok(screen_content), Agent::Hermes => detect_hermes(screen_content), + Agent::Qodercli => detect_qodercli(screen_content), }; AgentDetection { state, @@ -628,6 +633,104 @@ fn has_kiro_tool_spinner(content: &str) -> bool { }) } +/// Qodercli detection. +/// +/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt +/// while awaiting tool approval and a braille spinner while working. +fn detect_qodercli(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Idle short-circuit: double-press confirmation hints render *over* the + // input prompt while the user briefly holds Ctrl+C / Esc. The pane is + // effectively idle there — without this, a stale spinner row above could + // still flip it to Working. + if has_qodercli_idle_override(&lower) { + return AgentState::Idle; + } + + if has_qodercli_blocked_prompt(&lower) { + return AgentState::Blocked; + } + + // Working: explicit "(esc to cancel, …)" hint or an active spinner row. + if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) { + return AgentState::Working; + } + + AgentState::Idle +} + +/// Idle override hints. Mirrors the `⌕ Search…` / `ctrl+r to toggle` shortcut +/// in [`detect_claude`]: when these UI bits are visible the pane is sitting at +/// a static prompt and should not be classified as Working or Blocked. +/// +/// Covers qodercli's "press again" exit/rewind banners. +fn has_qodercli_idle_override(lower_content: &str) -> bool { + lower_content.contains("press ctrl+c again to exit") + || lower_content.contains("press ctrl+d again to exit") + || lower_content.contains("press esc again to rewind") +} + +/// Working hints qodercli prints alongside the spinner while the model is +/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading +/// indicator and survives even when the spinner glyph is masked (e.g. by +/// a hook icon). +fn has_qodercli_working_hint(lower_content: &str) -> bool { + lower_content.contains("(esc to cancel,") +} + +/// Strict spinner-row detection for qodercli. +/// +/// Matches a line whose first non-whitespace glyph is a braille pattern +/// (U+2800–U+28FF, the cli-spinners "dots" set qodercli renders), followed by +/// a space and at least one alphabetic character on the same line. This avoids +/// flagging the pane as Working when the scrollback merely contains a stale +/// braille glyph from an earlier frame. +fn has_qodercli_spinner_row(content: &str) -> bool { + for line in content.lines() { + let trimmed = line.trim_start(); + let mut chars = trimmed.chars(); + let Some(first) = chars.next() else { + continue; + }; + if !('\u{2800}'..='\u{28FF}').contains(&first) { + continue; + } + let rest: String = chars.collect(); + if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) { + return true; + } + } + false +} + +/// Blocked patterns specific to qodercli. +/// +/// Mirrors the helper structure used by [`has_claude_blocked_prompt`] so the +/// pattern surface stays a single, easy-to-extend list. +/// +/// Covered states: +/// * Tool-call confirmation banners ("Waiting for user confirmation", +/// "Awaiting approval"). +/// * The "Permission Required / Allow once or always?" approval dialog. +/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's +/// stable BaseTabDialog title and covers every form (single-select, +/// multi-select, free-form input, review tab). The "Enter your response" +/// placeholder and "Review your answers:" review heading are kept as +/// defensive fallbacks in case the title row scrolls off-screen. +/// * The interactive shell waiting hint emitted by qodercli when an agent +/// spawns a shell that is now parked for user keystrokes. +fn has_qodercli_blocked_prompt(lower_content: &str) -> bool { + lower_content.contains("waiting for user confirmation") + || lower_content.contains("awaiting approval") + || lower_content.contains("permission required") + || lower_content.contains("allow once or always?") + || lower_content.contains("asking user") + || lower_content.contains("enter your response") + || lower_content.contains("review your answers:") + || lower_content.contains("shell awaiting input") +} + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -2544,6 +2647,158 @@ mod tests { assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); } + // ---- Qodercli ---- + + #[test] + fn qodercli_identified_by_process_name() { + assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli)); + assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli)); + } + + #[test] + fn qodercli_blocked_on_confirmation() { + assert_eq!( + detect_qodercli("Waiting for user confirmation..."), + AgentState::Blocked, + ); + } + + #[test] + fn qodercli_working_on_spinner() { + assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working); + } + + #[test] + fn qodercli_idle_on_prompt() { + assert_eq!(detect_qodercli("> "), AgentState::Idle); + } + + #[test] + fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() { + // A single stray braille character in a previous output line must not + // flip the pane to Working — only an actual spinner *row* should. + let screen = "\ +agent finished a previous task.\n\ +\u{280B}\n\ +> \n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_working_on_full_spinner_row() { + // Real spinner row: braille glyph + space + alphabetic phrase. + let screen = "\u{280B} Thinking...\n"; + assert_eq!(detect_qodercli(screen), AgentState::Working); + } + + #[test] + fn qodercli_working_on_esc_to_cancel_hint() { + // The "(esc to cancel, …)" suffix is qodercli's explicit working + // marker. It must trigger Working even if a hook icon replaced the + // spinner glyph in this frame. + let screen = "Thinking... (esc to cancel, 5s)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Working); + } + + #[test] + fn qodercli_idle_when_text_mentions_working_in_prose() { + // The previous heuristic treated the bare word "working" as Working, + // which produced false positives for narrative output (commits, logs, + // Markdown). The pane should remain Idle until a real working signal + // appears. + let screen = "\ +fix: keep working set warm across reloads\n\ +\n\ +> \n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_idle_override_wins_over_spinner_row() { + // While the user is holding Ctrl+C, qodercli flashes a "press again" + // banner over the prompt. The pane is effectively idle there even if + // a stale spinner row is still in the buffer. + let screen = "\ +\u{280B} Thinking...\n\ +Press Ctrl+C again to exit.\n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_idle_override_wins_over_esc_rewind() { + let screen = "Press Esc again to rewind.\n"; + assert_eq!(detect_qodercli(screen), AgentState::Idle); + } + + #[test] + fn qodercli_blocked_on_permission_required_dialog() { + // qodercli renders this dialog when a tool call needs user approval. + let screen = "\ +Permission Required\n\ +Caller: test\n\ +Command: mkdir -p /root/example\n\ +Allow once or always?\n\ + \u{276F} 1. Allow Once - allow `mkdir` for one\n\ + 2. Always allow `mkdir` for future sessions\n\ + 3. Reject and tell qodercli something\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_permission_required_alone() { + // Even when the prompt copy gets truncated by the viewport, the title + // alone should be enough to flip the pane to blocked. + assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,); + } + + #[test] + fn qodercli_blocked_on_askuser_enter_response_placeholder() { + // qodercli's ask-user tool renders an input box with this placeholder + // when waiting for the user to type a response. + let screen = "\ +What kind of project are you working on?\n\ +> \n\ + Enter your response\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_askuser_review_tab() { + // The multi-question/multi-select review tab heading is unique to the + // ask-user dialog and means the agent is parked waiting on user input. + let screen = "\ +Review your answers:\n\ +\n\ +Project type \u{2192} Web app\n\ +Stack \u{2192} (not answered)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_interactive_shell_waiting() { + // When qodercli spawns an interactive shell, the loading row turns + // into a "Shell awaiting input" hint until the user takes focus. + let screen = "! Shell awaiting input (Tab to focus)\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + + #[test] + fn qodercli_blocked_on_askuser_single_choice_dialog() { + // Single-select ask-user has no "Enter your response" placeholder and + // no "Review your answers:" heading. The BaseTabDialog title + // "Asking User" is the only stable signal across every ask-user form. + let screen = "\ +Asking User\n\ +\n\ +Which framework should we use?\n\ + React\n\ + Vue\n\ + Svelte\n"; + assert_eq!(detect_qodercli(screen), AgentState::Blocked); + } + // ---- Helpers ---- #[test] diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/src/integration/assets/qodercli/herdr-agent-state.sh new file mode 100644 index 00000000..8903c45c --- /dev/null +++ b/src/integration/assets/qodercli/herdr-agent-state.sh @@ -0,0 +1,114 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=qodercli +# HERDR_INTEGRATION_VERSION=1 +# +# Reports qodercli agent state changes to herdr. Registered as a Command hook +# in ~/.qoder/settings.json by `herdr integration install qodercli` and +# invoked by qodercli's hook system on lifecycle events. +# +# qodercli (per https://docs.qoder.com/zh/cli/hooks) sends a JSON payload on +# stdin describing the hook event. The event name is read from the stdin +# payload's `hook_event_name` field, the same way +# `assets/claude/herdr-agent-state.sh` already consumes claude code's stdin +# payload. No environment variable is consulted for the event identity. + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-qodercli-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 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" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import time + +source = "herdr:qodercli" +action = os.environ.get("HERDR_ACTION", "") +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") + +if not pane_id or not socket_path: + raise SystemExit(0) + +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +# Per docs.qoder.com/zh/cli/hooks the payload always carries `hook_event_name`. +hook_event_name = str(hook_input.get("hook_event_name") or "") +is_subagent = bool(hook_input.get("agent_id")) +if hook_event_name == "SubagentStop": + # SubagentStop is a completion event; never let it revive an idle pane the + # way the parallel claude integration does. + raise SystemExit(0) +if is_subagent and action in ("idle", "release"): + # Subagent completion must not make the parent pane look done early. + raise SystemExit(0) + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() +session_id = hook_input.get("session_id") +agent_session_id = session_id if isinstance(session_id, str) and session_id else None +if action == "release": + request = { + "id": request_id, + "method": "pane.release_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "qodercli", + "seq": report_seq, + }, + } +else: + request = { + "id": request_id, + "method": "pane.report_agent", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "qodercli", + "state": action, + "seq": report_seq, + }, + } + if agent_session_id: + request["params"]["agent_session_id"] = agent_session_id + +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/mod.rs b/src/integration/mod.rs index 717107c2..529b8bdf 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -34,6 +34,10 @@ const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml"); const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py"); const HERMES_INTEGRATION_VERSION: u32 = 2; +const QODERCLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const QODERCLI_HOOK_ASSET: &str = include_str!("assets/qodercli/herdr-agent-state.sh"); +const QODERCLI_INTEGRATION_VERSION: u32 = 1; +const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] @@ -66,6 +70,20 @@ pub(crate) struct HermesInstallPaths { pub config_path: PathBuf, } +#[derive(Debug)] +pub(crate) struct QodercliInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct QodercliUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationStatus { pub target: crate::api::schema::IntegrationTarget, @@ -229,6 +247,19 @@ pub(crate) fn install_target( ), ] } + crate::api::schema::IntegrationTarget::Qodercli => { + let installed = install_qodercli()?; + vec![ + format!( + "installed qodercli integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured qodercli settings at {}", + installed.settings_path.display() + ), + ] + } }; crate::logging::integration_action("install", integration_target_label(target), "ok"); @@ -366,6 +397,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Qodercli => { + let result = uninstall_qodercli()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed qodercli hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no qodercli hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr qodercli hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr qodercli hook entries found in {}", + result.settings_path.display() + )); + } + messages + } }; crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); @@ -382,6 +440,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", + crate::api::schema::IntegrationTarget::Qodercli => "qodercli", } } @@ -393,6 +452,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", + crate::api::schema::IntegrationTarget::Qodercli => "qodercli", } } @@ -466,7 +526,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 6] { +); 7] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -498,6 +558,11 @@ fn integration_specs() -> [( hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)), HERMES_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::Qodercli, + qodercli_dir().map(|dir| dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME)), + QODERCLI_INTEGRATION_VERSION, + ), ] } @@ -1070,6 +1135,170 @@ pub(crate) fn uninstall_hermes() -> io::Result { }) } +pub(crate) fn install_qodercli() -> io::Result { + let dir = qodercli_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "qodercli config directory not found at {}. install qodercli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME); + fs::write(&hook_path, QODERCLI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + // Register the hook in ~/.qoder/settings.json. The schema mirrors claude + // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level + // `hooks` object keyed by event name, each entry holding a matcher + a + // list of `{type: "command", command, timeout?}` invocations. The hook + // script reads the event payload from stdin via `hook_event_name` so the + // installation never depends on a `QODER_HOOK_EVENT` environment + // variable. + 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, + "qodercli settings", + "qodercli settings hooks", + )?; + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + + // SubagentStop is intentionally *not* mapped to working: the hook script + // returns early on it (mirroring assets/claude/herdr-agent-state.sh) so + // that recap/away-summary frames cannot revive an idle pane. + ensure_command_hook( + hooks, + "SessionStart", + format!("bash {quoted_hook_path} idle"), + 10, + Some("*"), + )?; + 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(QodercliInstallPaths { + hook_path, + settings_path, + }) +} + +pub(crate) fn uninstall_qodercli() -> io::Result { + let hook_path = qodercli_dir()? + .join("hooks") + .join(QODERCLI_HOOK_INSTALL_NAME); + let settings_path = qodercli_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, + "qodercli settings", + "qodercli settings hooks", + )? { + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + updated_settings |= remove_command_hook( + hooks, + "SessionStart", + &format!("bash {quoted_hook_path} idle"), + )?; + 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(QodercliUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + fn ensure_hooks_object<'a>( settings: &'a mut Value, settings_path: &Path, @@ -1528,6 +1757,10 @@ fn hermes_plugin_dir() -> io::Result { .join(HERMES_PLUGIN_INSTALL_NAME)) } +fn qodercli_dir() -> io::Result { + config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"]) +} + fn home_dir() -> io::Result { std::env::var("HOME") .map(PathBuf::from) @@ -1548,6 +1781,7 @@ mod tests { std::env::remove_var(PI_CODING_AGENT_DIR_ENV_VAR); std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR); std::env::remove_var(CODEX_HOME_ENV_VAR); + std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); } fn unique_base() -> PathBuf { @@ -2626,5 +2860,158 @@ mod tests { assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); + // Qoder hook reads the event from the stdin JSON payload (per + // https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script + // never reaches for a QODER_HOOK_EVENT environment variable. + assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); + assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name")); + assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id")); + assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT")); + } + + #[test] + fn install_qodercli_writes_hook_and_updates_settings() { + let _lock = integration_env_lock(); + let base = unique_base(); + let qoder_dir = base.join(".qoder"); + fs::create_dir_all(&qoder_dir).unwrap(); + fs::write( + qoder_dir.join("settings.json"), + r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#, + ) + .unwrap(); + std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir); + + let installed = install_qodercli().unwrap(); + + assert_eq!( + installed.hook_path, + qoder_dir.join("hooks").join(QODERCLI_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.settings_path, qoder_dir.join("settings.json")); + assert!(installed.hook_path.is_file()); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + let hooks = settings + .get("hooks") + .and_then(Value::as_object) + .expect("hooks should be present"); + for event in [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "Stop", + "SessionEnd", + ] { + assert!( + hooks.contains_key(event), + "expected hooks.{event} to be registered" + ); + } + // Pre-existing settings keys must be preserved. + assert!(settings.get("permissions").is_some()); + + std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_qodercli_is_idempotent_for_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let qoder_dir = base.join(".qoder"); + fs::create_dir_all(&qoder_dir).unwrap(); + std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir); + + install_qodercli().unwrap(); + install_qodercli().unwrap(); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) + .unwrap(); + let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); + for event in [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "Stop", + "SessionEnd", + ] { + let entries = hooks.get(event).and_then(Value::as_array).unwrap(); + assert_eq!( + entries.len(), + 1, + "expected hooks.{event} to contain exactly one entry, got {entries:?}" + ); + } + + std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_qodercli_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let qoder_dir = base.join(".qoder"); + fs::create_dir_all(&qoder_dir).unwrap(); + std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &qoder_dir); + + install_qodercli().unwrap(); + // Inject a foreign hook entry the user might have configured by hand. + let mut settings: Value = + serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) + .unwrap(); + settings["hooks"]["UserPromptSubmit"] + .as_array_mut() + .unwrap() + .push(json!({ + "matcher": "*", + "hooks": [{"type": "command", "command": "echo user-defined"}], + })); + fs::write( + qoder_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).unwrap(), + ) + .unwrap(); + + let result = uninstall_qodercli().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_settings); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) + .unwrap(); + let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); + let remaining = hooks + .get("UserPromptSubmit") + .and_then(Value::as_array) + .unwrap(); + assert_eq!(remaining.len(), 1); + let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap(); + assert_eq!(cmd, "echo user-defined"); + + std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_qodercli_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let missing = base.join(".qoder"); + std::env::set_var(QODERCLI_CONFIG_DIR_ENV_VAR, &missing); + + let err = install_qodercli().unwrap_err().to_string(); + assert!( + err.contains("qodercli config directory not found"), + "unexpected error: {err}" + ); + + std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); } }