aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorCoder Wayne <1418551940@qq.com>2026-05-27 22:42:26 +0800
committerGitHub <noreply@github.com>2026-05-27 17:42:26 +0300
commitbfb9e756a01a8ddf4e20d2451c8316ab33454bb4 (patch)
tree2cf90e050e5a6e4d32bbda2f13321aecb4615345 /src/integration
parent858c36974bad616e9e6c1897882b8c9a97396bec (diff)
feat: add Qoder CLI integration (#309)
* 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 <jlu1318079810@163.com> Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/qodercli/herdr-agent-state.sh114
-rw-r--r--src/integration/mod.rs389
2 files changed, 502 insertions, 1 deletions
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<PathBuf>,
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<HermesUninstallResult> {
})
}
+pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> {
+ 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::<Value>(&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<QodercliUninstallResult> {
+ 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::<Value>(&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<PathBuf> {
.join(HERMES_PLUGIN_INSTALL_NAME))
}
+fn qodercli_dir() -> io::Result<PathBuf> {
+ config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"])
+}
+
fn home_dir() -> io::Result<PathBuf> {
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);
}
}