diff --git a/README.md b/README.md index 46fb6317b..f3bdffe8a 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ Installation also equips supported coding agents to use the graph in daily work. Hook augmentation is context-only and fail-open: it does not replace or block the requested tool call. Features vary by client and platform; some clients use durable instructions or a parent-agent handoff instead of hooks or direct subagent MCP access. Codex users review and trust installed hooks through `/hooks`. See [Multi-Agent Support](#multi-agent-support) and [Sessions, compaction, and subagents](#sessions-compaction-and-subagents) for exact support and activation requirements. +Hooks are enabled by default. Run `codebase-memory-mcp install --no-hooks` to remove CBM-managed hooks while keeping MCP registration and other integration files; the choice persists for later installs and updates. Run `codebase-memory-mcp install --hooks` to re-enable them, or inspect the current preference with `codebase-memory-mcp config get install_hooks`. + ### Session Coordination Daemon CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and a session-managed daemon stops after the final session. A daemon started with `daemon start` remains running until stopped. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a37e4a4ab..67c624a60 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -79,6 +79,7 @@ codebase-memory-mcp config set auto_index true codebase-memory-mcp config set auto_index_limit 50000 codebase-memory-mcp config set watcher_enabled false codebase-memory-mcp config reset auto_index +codebase-memory-mcp config get install_hooks ``` Current keys: @@ -96,6 +97,9 @@ Current keys: | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | | `index_max_rss_mb` | `off` | Optional maximum charged memory in MiB for the complete contained index-worker process tree (`64..1048576`). On macOS this is phys_footprint, not resident size. | | `index_max_duration_seconds` | `off` | Optional maximum wall-clock duration in seconds for the whole index request, including crash/hang recovery (`1..86400`). Like `index_max_rss_mb`, it stops a worker only before it starts to publish the new index; see [INDEX_RESOURCE_LIMITS.md](INDEX_RESOURCE_LIMITS.md). | +| `install_hooks` | `true` | Install CBM-managed client hooks. `install --no-hooks` persists `false`; `install --hooks` restores `true`. | + +Bare installs and updates respect the stored hook preference. MCP registration, skills, instructions, and profiles remain installed when hooks are disabled. For a watched project, `index_status` names the polling method in `watch.strategy`: `git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 993870217..dc4045830 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1863,6 +1863,25 @@ if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then fi echo "OK 8e: shim installed, non-blocking, delegates to hook-augment" +HOME="$FAKE_HOME" XDG_CONFIG_HOME="$FAKE_HOME/.config" APPDATA="$FAKE_HOME/AppData/Roaming" LOCALAPPDATA="$FAKE_HOME/AppData/Local" PATH="$FAKE_HOME/.local/bin:$PATH" \ + "$BINARY" install --no-hooks --skip-binary --clients=claude -y >/dev/null +# Claude hooks are registered shell-free (#1733): 'hook-augment' is the owned +# marker; the shim names only cover legacy shell registrations. +if grep -qE 'hook-augment|cbm-(code-discovery-gate|session-reminder|subagent-reminder)' "$FAKE_HOME/.claude/settings.json" 2>/dev/null || + [ -e "$GATE_SCRIPT" ] || + ! grep -q 'codebase-memory-mcp' "$FAKE_HOME/.claude.json" 2>/dev/null; then + echo "FAIL 8e-i: --no-hooks did not remove owned Claude hooks while preserving MCP" + exit 1 +fi +HOME="$FAKE_HOME" XDG_CONFIG_HOME="$FAKE_HOME/.config" APPDATA="$FAKE_HOME/AppData/Roaming" LOCALAPPDATA="$FAKE_HOME/AppData/Local" PATH="$FAKE_HOME/.local/bin:$PATH" \ + "$BINARY" install --hooks --skip-binary --clients=claude -y >/dev/null +if ! grep -q 'hook-augment' "$FAKE_HOME/.claude/settings.json" 2>/dev/null || + [ ! -e "$GATE_SCRIPT" ]; then + echo "FAIL 8e-i: --hooks did not restore owned Claude hooks" + exit 1 +fi +echo "OK 8e-i: Claude hook opt-out and re-enable preserve MCP" + # 8f-8h: Codex TOML if ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml"; then echo "FAIL 8f: Codex TOML missing MCP section" diff --git a/src/cli/cli.c b/src/cli/cli.c index 22f6ae7a1..247c64216 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8096,6 +8096,7 @@ static const config_key_def_t CONFIG_KEYS[] = { "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_WATCH_NON_GIT, "false", "Also poll non-git project roots for changes (file-tree scan); off by default"}, + {CBM_CONFIG_INSTALL_HOOKS, "true", "Install managed client hooks"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, {CBM_CONFIG_UI_ENABLED, "false", "Serve the graph UI on a loopback HTTP port"}, {CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"}, @@ -8836,6 +8837,11 @@ typedef struct { static cbm_install_plan_t *g_install_plan = NULL; static int g_agent_install_errors = 0; static int g_agent_uninstall_errors = 0; +static bool g_install_hooks = true; + +static void print_hooks_skipped(void) { + printf(" hooks: skipped (install --hooks to enable)\n"); +} /* Every agent configuration uninstall could not clean, kept for the closing * summary. A cleanup failure no longer stops executable and index removal @@ -8925,6 +8931,9 @@ static void plan_record(const char *agent, const char *kind, const char *path) { if (!g_install_plan || !path || !path[0]) { return; } + if (!g_install_hooks && strcmp(kind, "hook") == 0) { + return; + } cbm_install_plan_t *pl = g_install_plan; if (pl->count >= pl->cap) { int ncap = pl->cap ? pl->cap * 2 : CLI_BUF_16; @@ -9052,6 +9061,122 @@ static void uninstall_tiered_profile_prompts(const char *label, const char *veri cbm_graph_profile_dialect_t dialect, const char *legacy_verify_content, bool dry_run); +static int remove_claude_owned_hook_scripts(const char *config_dir, const char *binary_path, + bool uninstall) { + char current_gate[CLI_BUF_8K]; + char current_session[CLI_BUF_8K]; + char current_subagent[CLI_BUF_8K]; + char released_gate[CLI_BUF_8K]; + const char *const gate_legacy[] = {released_gate}; + const char *const session_legacy[] = {cmm_released_session_script}; + const char *const subagent_legacy[] = {cmm_released_subagent_script}; + size_t gate_legacy_count = + cbm_build_released_gate_script(binary_path, released_gate, sizeof(released_gate)) == CLI_OK + ? 1U + : 0U; + static const struct { + const char *name; + const char *legacy_name; + const char *prefix; + } hook_types[] = { + {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix}, + {CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY, + cmm_session_script_prefix}, + {CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, + cmm_subagent_script_prefix}, + }; + struct { + const char *name; + const char *legacy_name; + const char *current; + const char *const *legacy; + size_t legacy_count; + bool current_valid; + } owned_scripts[] = { + {hook_types[0].name, hook_types[0].legacy_name, current_gate, gate_legacy, + gate_legacy_count, + cbm_build_current_hook_script(hook_types[0].prefix, binary_path, current_gate, + sizeof(current_gate)) == CLI_OK}, + {hook_types[1].name, hook_types[1].legacy_name, current_session, session_legacy, 1U, + cbm_build_current_hook_script(hook_types[1].prefix, binary_path, current_session, + sizeof(current_session)) == CLI_OK}, + {hook_types[2].name, hook_types[2].legacy_name, current_subagent, subagent_legacy, 1U, + cbm_build_current_hook_script(hook_types[2].prefix, binary_path, current_subagent, + sizeof(current_subagent)) == CLI_OK}, + }; + char hooks_dir[CLI_BUF_1K]; + int hooks_written = snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); + bool hooks_dir_valid = hooks_written > 0 && (size_t)hooks_written < sizeof(hooks_dir); + int rc = CLI_OK; + const char *op = uninstall ? "hook_script_uninstall" : "hook_script_disable"; + for (size_t i = 0; i < sizeof(owned_scripts) / sizeof(owned_scripts[0]); i++) { + char script_path[CLI_BUF_1K]; + int script_written = hooks_dir_valid ? snprintf(script_path, sizeof(script_path), "%s/%s", + hooks_dir, owned_scripts[i].name) + : CLI_ERR; + bool script_path_valid = script_written > 0 && (size_t)script_written < sizeof(script_path); + if (!owned_scripts[i].current_valid) { + record_agent_config_error(uninstall, "Claude Code", op, owned_scripts[i].name); + rc = CLI_ERR; + continue; + } + if (!script_path_valid || + cbm_remove_owned_hook_script(script_path, owned_scripts[i].current, + owned_scripts[i].legacy, + owned_scripts[i].legacy_count) < CLI_OK) { + record_agent_config_error(uninstall, "Claude Code", op, + script_path_valid ? script_path : owned_scripts[i].name); + rc = CLI_ERR; + } +#ifdef _WIN32 + if (!hooks_dir_valid || + cbm_remove_owned_legacy_hook_script(hooks_dir, owned_scripts[i].legacy_name, + owned_scripts[i].current, owned_scripts[i].legacy, + owned_scripts[i].legacy_count) != CLI_OK) { + char legacy_path[CLI_BUF_1K]; + int written = hooks_dir_valid ? snprintf(legacy_path, sizeof(legacy_path), "%s/%s", + hooks_dir, owned_scripts[i].legacy_name) + : CLI_ERR; + record_agent_config_error(uninstall, "Claude Code", "legacy_hook_script_uninstall", + written > 0 && (size_t)written < sizeof(legacy_path) + ? legacy_path + : owned_scripts[i].legacy_name); + rc = CLI_ERR; + } +#endif + } + return rc; +} + +/* Augment's SessionStart and PostToolUse view scripts are exact-owned: each is + * rebuilt from the binary path the install embedded and deleted only on a + * byte-for-byte match, so a script the user edited stays. Uninstall and the + * install-hook opt-out share this rule. */ +static void remove_augment_owned_hook_scripts(const char *binary_path, const char *session_path, + const char *coverage_path, bool uninstall) { + const struct { + const char *path; + int (*build)(const char *, char *, size_t); + const char *build_op; + const char *remove_op; + } scripts[] = { + {session_path, cbm_build_augment_session_script, "session_script_build", + uninstall ? "session_script_uninstall" : "session_script_disable"}, + {coverage_path, cbm_build_augment_coverage_script, "coverage_script_build", + uninstall ? "coverage_script_uninstall" : "coverage_script_disable"}, + }; + for (size_t i = 0; i < sizeof(scripts) / sizeof(scripts[0]); i++) { + char script[CLI_BUF_8K]; + if (scripts[i].build(binary_path, script, sizeof(script)) != CLI_OK) { + record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].build_op, + scripts[i].path); + } else if (cbm_text_remove_owned_document(scripts[i].path, script) < 0) { + record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].remove_op, + scripts[i].path); + } + } +} + static void install_claude_code_config(const char *home, const char *binary_path, bool force, bool dry_run) { char config_dir[CLI_BUF_1K]; @@ -9130,6 +9255,23 @@ static void install_claude_code_config(const char *home, const char *binary_path char settings_path[CLI_BUF_1K]; snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir); + if (!g_install_hooks) { + if (!dry_run) { + if (cbm_remove_claude_hooks_with_binary(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_session_hooks(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_claude_subagent_hooks_with_binary(settings_path, binary_path) != + CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + remove_claude_owned_hook_scripts(config_dir, binary_path, false); + } + print_hooks_skipped(); + goto claude_hooks_done; + } bool gate_ok = dry_run; bool session_ok = dry_run; bool subagent_ok = dry_run; @@ -9209,6 +9351,7 @@ static void install_claude_code_config(const char *home, const char *binary_path } } +claude_hooks_done: /* Migration nudge: when CLAUDE_CONFIG_DIR is set and a legacy ~/.claude tree * still exists, mention it so users can clean up stale artifacts. */ if (home && home[0]) { @@ -9866,6 +10009,14 @@ static void install_copilot_durable_context(const char *home, const char *binary plan_record("Copilot", "hook", hook_path); return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hook_path) && + cbm_remove_copilot_hooks(hook_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Copilot", "hook_disable", hook_path); + } + print_hooks_skipped(); + return; + } bool hook_ok = true; if (!dry_run && (!cbm_mkdir_p_ex(hooks_dir, CLI_OCTAL_PERM, CBM_MKDIR_FOLLOW_OWNED) || cbm_upsert_copilot_hooks(binary_path, hook_path) != CLI_OK)) { @@ -10042,6 +10193,14 @@ static void install_qoder_durable_context(const char *home, const char *binary_p } return; } + if (!g_install_hooks) { + if (!dry_run && config_resolved && cbm_file_exists(settings_path) && + cbm_remove_qoder_context_hook(settings_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Qoder CLI", "hook_disable", settings_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hooks: withheld because no documented executor is available\n"); return; @@ -10101,6 +10260,14 @@ static void install_gitlab_durable_context(const cbm_agent_registry_context_t *r } return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hooks_path) && + cbm_remove_gitlab_session_hook(hooks_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "GitLab Duo CLI", "hook_disable", hooks_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hook: withheld on Windows (vendor hook shell is undocumented)\n"); return; @@ -10138,6 +10305,14 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re } return; } + if (!g_install_hooks) { + if (!dry_run && config_resolved && cbm_file_exists(config_path) && + cbm_remove_devin_context_hooks(config_path, binary_path) != CLI_OK) { + record_agent_config_error(false, "Devin CLI / Local", "hook_disable", config_path); + } + print_hooks_skipped(); + return; + } if (!hook_supported) { printf(" hooks: withheld on Windows (vendor hook shell is undocumented)\n"); return; @@ -10275,6 +10450,14 @@ static void install_kimi_durable_context(const cbm_agent_registry_context_t *reg plan_record("Kimi Code CLI", "hook", config_path); return; } + if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(config_path) && + cbm_remove_kimi_context_hook(config_path) != CLI_OK) { + record_agent_config_error(false, "Kimi Code CLI", "hook_disable", config_path); + } + print_hooks_skipped(); + return; + } bool installed = true; if (!dry_run && (!prepare_config_parent(config_path) || cbm_upsert_kimi_context_hook(config_path, binary_path) != CLI_OK)) { @@ -10494,6 +10677,22 @@ static void install_gemini_config(const char *home, const char *binary_path, boo plan_record("Gemini CLI", "hook", cp); /* BeforeTool + SessionStart in settings.json */ return; } + if (!g_install_hooks) { + if (!dry_run) { + int tool_result = cbm_remove_gemini_hooks(cp); + int coverage_result = CLI_OK; +#ifndef _WIN32 + coverage_result = cbm_remove_gemini_coverage_hook(cp, binary_path); +#endif + int session_result = cbm_remove_gemini_session_hooks(cp); + if (tool_result != CLI_OK || coverage_result != CLI_OK || session_result != CLI_OK) { + record_agent_config_error(false, "Gemini CLI", "hook_disable", cp); + } + } + print_hooks_skipped(); + printf(" subagents: Scout + Verify + Auditor\n"); + return; + } if (!dry_run) { if (cbm_upsert_gemini_hooks(cp) != CLI_OK) { record_agent_config_error(false, "Gemini CLI", "before_tool_hook_install", cp); @@ -10537,8 +10736,9 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const cbm_build_augment_command(binary_path, command, sizeof(command)) == CLI_OK && cbm_build_augment_command_windows(binary_path, command_windows, sizeof(command_windows)) == CLI_OK; - cbm_toml_codex_hook_action_t preflight_action = - use_hooks_json ? CBM_TOML_CODEX_HOOK_REMOVE : CBM_TOML_CODEX_HOOK_UPSERT; + cbm_toml_codex_hook_action_t preflight_action = !g_install_hooks || use_hooks_json + ? CBM_TOML_CODEX_HOOK_REMOVE + : CBM_TOML_CODEX_HOOK_UPSERT; cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE; int preflight_result = commands_ok ? cbm_reconcile_codex_hooks_command_detailed( cp, command, command_windows, preflight_action, @@ -10586,7 +10786,20 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const } else { bool hook_ok = true; int pretool_written = 1; - if (!dry_run && use_hooks_json) { + if (!g_install_hooks && !dry_run) { + int toml_result = cbm_reconcile_codex_hooks_command( + cp, command, command_windows, CBM_TOML_CODEX_HOOK_REMOVE, false); + int json_result = !cbm_file_exists(hooks_json) + ? CLI_OK + : cbm_remove_paired_lifecycle_hooks_json(hooks_json, command); + /* #2719: the PreToolUse Bash hook shares hooks.json with the + * lifecycle pair, so the opt-out removes it the way uninstall does. */ + int pretool_result = !cbm_file_exists(hooks_json) + ? CLI_OK + : cbm_remove_codex_pretool_hooks_json(hooks_json, command); + hook_ok = + toml_result == CLI_OK && json_result == CLI_OK && pretool_result == CLI_OK; + } else if (!dry_run && use_hooks_json) { hook_ok = cbm_upsert_paired_lifecycle_hooks_json(hooks_json, command, command_windows, NULL, CMM_HOOK_TIMEOUT_SEC) == CLI_OK && @@ -10600,7 +10813,11 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const &pretool_written) == CLI_OK; } if (!hook_ok) { - record_agent_config_error(false, "Codex CLI", "hook_install", hook_target); + record_agent_config_error(false, "Codex CLI", + g_install_hooks ? "hook_install" : "hook_disable", + hook_target); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(pretool_written ? " hooks: SessionStart + SubagentStart + PreToolUse Bash (dynamic " @@ -10646,8 +10863,17 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const char plugin_path[CLI_BUF_1K]; snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts", home); - install_generated_client_extension("OpenCode", plugin_path, binary_path, - cbm_client_adapter_opencode, dry_run); + if (g_install_hooks) { + install_generated_client_extension("OpenCode", plugin_path, binary_path, + cbm_client_adapter_opencode, dry_run); + } else if (!g_install_plan) { + if (!dry_run && cbm_file_exists(plugin_path) && + cbm_text_remove_managed_block(plugin_path, CBM_ADAPTER_MARKER_START, + CBM_ADAPTER_MARKER_END) != 0) { + record_agent_config_error(false, "OpenCode", "hook_disable", plugin_path); + } + print_hooks_skipped(); + } } if (agents->antigravity) { char cp[CLI_BUF_1K]; @@ -10929,6 +11155,13 @@ static void install_editor_agent_configs(const cbm_detected_agents_t *agents, co bool workspace_ok = cbm_openclaw_workspace_path(home, cp, workspace, sizeof(workspace)); (void)install_generic_agent_config("OpenClaw", binary_path, cp, NULL, dry_run, cbm_install_openclaw_mcp); + if (!g_install_hooks && !g_install_plan) { + if (!dry_run && cbm_file_exists(cp) && + cbm_remove_openclaw_compaction(cp) != CLI_OK) { + record_agent_config_error(false, "OpenClaw", "hook_disable", cp); + } + print_hooks_skipped(); + } if (workspace_ok) { char agents_path[CLI_BUF_1K]; char tools_path[CLI_BUF_1K]; @@ -10951,16 +11184,16 @@ static void install_editor_agent_configs(const cbm_detected_agents_t *agents, co record_agent_config_error(false, "OpenClaw", "tools_context_install", tools_path); } - if (cbm_upsert_openclaw_compaction(cp) != CLI_OK) { + if (g_install_hooks && cbm_upsert_openclaw_compaction(cp) != CLI_OK) { compaction_installed = false; record_agent_config_error(false, "OpenClaw", "compaction_install", cp); } } printf(" instructions: %s\n", agents_path); printf(" tools context: %s\n", tools_path); - if (compaction_installed) { + if (g_install_hooks && compaction_installed) { printf(" compaction: reinjects Codebase Memory\n"); - } else { + } else if (g_install_hooks) { printf(" compaction: could not update exact-owned augmentation\n"); } } @@ -11047,14 +11280,19 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents } else { int hook_result = CBM_YAML_IDENTITY_EDIT_OK; if (!dry_run) { - hook_result = prepare_config_parent(cp) - ? cbm_upsert_hermes_context_hook(cp, binary_path) - : CBM_YAML_IDENTITY_EDIT_ERROR; + hook_result = g_install_hooks + ? (prepare_config_parent(cp) + ? cbm_upsert_hermes_context_hook(cp, binary_path) + : CBM_YAML_IDENTITY_EDIT_ERROR) + : cbm_remove_hermes_context_hook(cp, binary_path); } if (hook_result == CBM_YAML_IDENTITY_EDIT_FOREIGN) { record_agent_config_error(false, "Hermes", "pre_llm_hook_foreign", cp); } else if (hook_result != CBM_YAML_IDENTITY_EDIT_OK) { - record_agent_config_error(false, "Hermes", "pre_llm_hook_install", cp); + record_agent_config_error( + false, "Hermes", g_install_hooks ? "pre_llm_hook_install" : "hook_disable", cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(" hook: %s (pre_llm_call)\n", cp); } @@ -11121,7 +11359,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents } else { bool hook_ok = true; if (!dry_run) { - if (!cbm_install_augment_session_script(binary_path, session_hp)) { + if (!g_install_hooks) { + int session_result = cbm_remove_augment_session_hook(cp, session_hp); + int coverage_result = cbm_remove_augment_coverage_hook(cp, coverage_hp); + hook_ok = session_result == CLI_OK && coverage_result == CLI_OK; + remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, false); + } else if (!cbm_install_augment_session_script(binary_path, session_hp)) { hook_ok = false; record_agent_config_error(false, "Augment/Auggie", "session_script_install", session_hp); @@ -11129,16 +11372,23 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents hook_ok = false; record_agent_config_error(false, "Augment/Auggie", "session_hook_install", cp); } - if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) { - hook_ok = false; - record_agent_config_error(false, "Augment/Auggie", "coverage_script_install", - coverage_hp); - } else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { - hook_ok = false; - record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install", cp); + if (g_install_hooks) { + if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) { + hook_ok = false; + record_agent_config_error(false, "Augment/Auggie", + "coverage_script_install", coverage_hp); + } else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { + hook_ok = false; + record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install", + cp); + } } } - if (hook_ok) { + if (!hook_ok && !g_install_hooks) { + record_agent_config_error(false, "Augment/Auggie", "hook_disable", cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); + } else if (hook_ok) { printf(" hooks: SessionStart + PostToolUse view coverage\n"); } } @@ -11199,11 +11449,20 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents #else bool windows = false; #endif - if (cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows) != CLI_OK) { - record_agent_config_error(false, "Qwen Code", "lifecycle_hook_install", cp); + int hook_result = g_install_hooks + ? cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows) + : cbm_remove_qwen_lifecycle_hooks(cp, binary_path, windows); + if (hook_result != CLI_OK) { + record_agent_config_error( + false, "Qwen Code", g_install_hooks ? "lifecycle_hook_install" : "hook_disable", + cp); + } else if (!g_install_hooks) { + print_hooks_skipped(); } else { printf(" hooks: SessionStart + SubagentStart + PostToolUse ReadFile\n"); } + } else if (!g_install_hooks) { + print_hooks_skipped(); } } if (agents->copilot_cli) { @@ -11248,6 +11507,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents if (hook_supported) { plan_record("Factory Droid", "hook", hp); } + } else if (!g_install_hooks) { + if (!dry_run && cbm_file_exists(hp) && + cbm_remove_factory_hooks(hp, binary_path) != CLI_OK) { + record_agent_config_error(false, "Factory Droid", "hook_disable", hp); + } + print_hooks_skipped(); } else if (!hook_supported) { printf(" hooks: withheld on Windows (vendor documents Bash only)\n"); } else { @@ -11798,6 +12063,44 @@ static bool cli_binary_is_externally_managed(const char *self_path, bool self_pa return cli_external_manager_name(self_path) != NULL; } +static int cli_resolve_install_hooks(bool override_set, bool override_value) { + bool enabled = true; + if (!override_set) { + const char *cache_dir = cbm_resolve_cache_dir(); + char db_path[CLI_BUF_1K]; + int written = + cache_dir ? snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir) : CLI_ERR; + if (written <= 0 || (size_t)written >= sizeof(db_path)) { + (void)fprintf(stderr, "error: config database path is unavailable\n"); + return CLI_ERR; + } + if (cbm_file_exists(db_path)) { + cbm_config_t *cfg = cbm_config_open(cache_dir); + if (!cfg) { + (void)fprintf(stderr, "error: cannot read install_hooks preference\n"); + return CLI_ERR; + } + enabled = cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + } + } else { + enabled = override_value; + } + g_install_hooks = enabled; + return CLI_OK; +} + +static int cli_persist_install_hooks(void) { + const char *cache_dir = cbm_resolve_cache_dir(); + cbm_config_t *cfg = cache_dir ? cbm_config_open(cache_dir) : NULL; + if (!cfg) { + return CLI_ERR; + } + int result = cbm_config_set(cfg, CBM_CONFIG_INSTALL_HOOKS, g_install_hooks ? "true" : "false"); + cbm_config_close(cfg); + return result; +} + /* Build the agent.install.plan.v1 receipt (#388): a machine-readable list of * the config / instruction / skill / agent / hook files `install` WOULD write, produced by * running the real install dispatch in record-only mode (no mutation, no @@ -11946,6 +12249,7 @@ typedef struct { bool skip_binary; bool delete_indexes; bool skip_config; + bool persist_install_hooks; bool force; bool dry_run; } cli_install_activation_t; @@ -12001,6 +12305,13 @@ static int cli_install_activate(void *opaque) { } printf("Installed binary -> %s\n\n", activation->bin_target); } + if (!activation->dry_run && activation->persist_install_hooks && + cli_persist_install_hooks() != CLI_OK) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, "install_transaction_config_failure_finalize"); + (void)fprintf(stderr, "error: cannot persist install_hooks preference\n"); + return CLI_ACTIVATION_PARTIAL; + } /* Config and PATH refreshes are install mutations too. Keep them in this * callback so the startup lock covers the complete filesystem window, * including same-binary and non-force installs. */ @@ -12075,6 +12386,7 @@ static int cli_install_activate(void *opaque) { int cbm_cmd_install(int argc, char **argv) { parse_auto_answer(argc, argv); + g_client_selection = NULL; bool dry_run = false; bool force = false; bool plan = false; @@ -12082,6 +12394,8 @@ int cbm_cmd_install(int argc, char **argv) { bool skip_config = false; bool skip_binary = false; bool force_binary = false; + bool hooks_flag = false; + bool no_hooks_flag = false; const char *requested_clients = NULL; const char *requested_bin_dir = NULL; for (int i = 0; i < argc; i++) { @@ -12095,6 +12409,10 @@ int cbm_cmd_install(int argc, char **argv) { reset_indexes = true; } else if (strcmp(argv[i], "--skip-config") == 0) { skip_config = true; + } else if (strcmp(argv[i], "--hooks") == 0) { + hooks_flag = true; + } else if (strcmp(argv[i], "--no-hooks") == 0) { + no_hooks_flag = true; } else if (strncmp(argv[i], "--clients=", SLEN("--clients=")) == 0) { requested_clients = argv[i] + SLEN("--clients="); if (!requested_clients[0]) { @@ -12132,11 +12450,19 @@ int cbm_cmd_install(int argc, char **argv) { } } + if (hooks_flag && no_hooks_flag) { + (void)fprintf(stderr, "error: --hooks and --no-hooks cannot be used together\n"); + return CLI_TRUE; + } + const char *home = cbm_get_home_dir(); if (!home) { (void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n"); return CLI_TRUE; } + if (cli_resolve_install_hooks(hooks_flag || no_hooks_flag, hooks_flag) != CLI_OK) { + return CLI_TRUE; + } char bin_dir[CLI_BUF_1K]; int bin_dir_length = requested_bin_dir @@ -12403,6 +12729,7 @@ int cbm_cmd_install(int argc, char **argv) { .copy_binary = do_copy, .delete_indexes = delete_indexes, .skip_config = skip_config, + .persist_install_hooks = hooks_flag || no_hooks_flag, .skip_binary = skip_binary, .force = force, .dry_run = dry_run, @@ -12508,86 +12835,7 @@ static void uninstall_claude_code(const char *home, const char *installed_binary record_agent_config_error(true, "Claude Code", "subagent_hook_uninstall", settings_path); } - char current_gate[CLI_BUF_8K]; - char current_session[CLI_BUF_8K]; - char current_subagent[CLI_BUF_8K]; - char released_gate[CLI_BUF_8K]; - const char *const gate_legacy[] = {released_gate}; - const char *const session_legacy[] = {cmm_released_session_script}; - const char *const subagent_legacy[] = {cmm_released_subagent_script}; - size_t gate_legacy_count = cbm_build_released_gate_script(installed_binary, released_gate, - sizeof(released_gate)) == CLI_OK - ? 1U - : 0U; - static const struct { - const char *name; - const char *legacy_name; - const char *prefix; - } hook_types[] = { - {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix}, - {CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY, - cmm_session_script_prefix}, - {CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, - cmm_subagent_script_prefix}, - }; - struct { - const char *name; - const char *legacy_name; - const char *current; - const char *const *legacy; - size_t legacy_count; - bool current_valid; - } owned_scripts[] = { - {hook_types[0].name, hook_types[0].legacy_name, current_gate, gate_legacy, - gate_legacy_count, - cbm_build_current_hook_script(hook_types[0].prefix, installed_binary, current_gate, - sizeof(current_gate)) == CLI_OK}, - {hook_types[1].name, hook_types[1].legacy_name, current_session, session_legacy, 1U, - cbm_build_current_hook_script(hook_types[1].prefix, installed_binary, current_session, - sizeof(current_session)) == CLI_OK}, - {hook_types[2].name, hook_types[2].legacy_name, current_subagent, subagent_legacy, 1U, - cbm_build_current_hook_script(hook_types[2].prefix, installed_binary, current_subagent, - sizeof(current_subagent)) == CLI_OK}, - }; - char hooks_dir[CLI_BUF_1K]; - int hooks_written = snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); - bool hooks_dir_valid = hooks_written > 0 && (size_t)hooks_written < sizeof(hooks_dir); - for (size_t i = 0; i < sizeof(owned_scripts) / sizeof(owned_scripts[0]); i++) { - char script_path[CLI_BUF_1K]; - int script_written = hooks_dir_valid - ? snprintf(script_path, sizeof(script_path), "%s/%s", - hooks_dir, owned_scripts[i].name) - : CLI_ERR; - bool script_path_valid = - script_written > 0 && (size_t)script_written < sizeof(script_path); - if (!owned_scripts[i].current_valid) { - record_agent_config_error(true, "Claude Code", "hook_script_uninstall", - owned_scripts[i].name); - continue; - } - if (!script_path_valid || - cbm_remove_owned_hook_script(script_path, owned_scripts[i].current, - owned_scripts[i].legacy, - owned_scripts[i].legacy_count) < CLI_OK) { - record_agent_config_error(true, "Claude Code", "hook_script_uninstall", - script_path_valid ? script_path : owned_scripts[i].name); - } -#ifdef _WIN32 - if (!hooks_dir_valid || - cbm_remove_owned_legacy_hook_script( - hooks_dir, owned_scripts[i].legacy_name, owned_scripts[i].current, - owned_scripts[i].legacy, owned_scripts[i].legacy_count) != CLI_OK) { - char legacy_path[CLI_BUF_1K]; - int written = hooks_dir_valid ? snprintf(legacy_path, sizeof(legacy_path), "%s/%s", - hooks_dir, owned_scripts[i].legacy_name) - : CLI_ERR; - record_agent_config_error(true, "Claude Code", "legacy_hook_script_uninstall", - written > 0 && (size_t)written < sizeof(legacy_path) - ? legacy_path - : owned_scripts[i].legacy_name); - } -#endif - } + remove_claude_owned_hook_scripts(config_dir, installed_binary, true); } printf(" removed PreToolUse + SessionStart + SubagentStart hooks\n"); } @@ -13491,30 +13739,7 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con if (cbm_remove_augment_coverage_hook(cp, coverage_hp) != CLI_OK) { record_agent_config_error(true, "Augment/Auggie", "coverage_hook_uninstall", cp); } - char session_script[CLI_BUF_8K]; - if (cbm_build_augment_session_script(binary_path, session_script, - sizeof(session_script)) != CLI_OK) { - record_agent_config_error(true, "Augment/Auggie", "session_script_build", - session_hp); - } else { - int script_rc = cbm_text_remove_owned_document(session_hp, session_script); - if (script_rc < 0) { - record_agent_config_error(true, "Augment/Auggie", "session_script_uninstall", - session_hp); - } - } - char coverage_script[CLI_BUF_8K]; - if (cbm_build_augment_coverage_script(binary_path, coverage_script, - sizeof(coverage_script)) != CLI_OK) { - record_agent_config_error(true, "Augment/Auggie", "coverage_script_build", - coverage_hp); - } else { - int script_rc = cbm_text_remove_owned_document(coverage_hp, coverage_script); - if (script_rc < 0) { - record_agent_config_error(true, "Augment/Auggie", "coverage_script_uninstall", - coverage_hp); - } - } + remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, true); } printf(" removed SessionStart + PostToolUse hooks + dedicated subagent\n"); } @@ -14595,6 +14820,9 @@ int cbm_cmd_update(int argc, char **argv) { "v0.10.0 there is one build per platform and it always includes the " "graph UI.\n"); } + if (cli_resolve_install_hooks(false, true) != CLI_OK) { + return CLI_TRUE; + } /* #1566: we cannot update a binary a package manager owns, and pretending * otherwise is the dishonest-success pattern this project keeps fixing: diff --git a/src/cli/cli.h b/src/cli/cli.h index 7d0c4e8da..0829ce15f 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -532,6 +532,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_WATCH "auto_watch" #define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_DOC_CANDIDATES "doc_candidates" +#define CBM_CONFIG_INSTALL_HOOKS "install_hooks" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" #define CBM_CONFIG_WATCH_NON_GIT "watch_non_git" diff --git a/src/main.c b/src/main.c index e5cb74c34..3a7eed240 100644 --- a/src/main.c +++ b/src/main.c @@ -1142,7 +1142,7 @@ static void print_help(void) { "[args]\n"); printf(" Run one tool locally, then exit\n"); printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] " - "[--dir=] [--skip-config]\n"); + "[--dir=] [--skip-config] [--hooks|--no-hooks]\n"); printf(" [--clients=] Run " "'install --clients' to list tokens\n"); printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run] [--delete-indexes]\n"); diff --git a/tests/test_cli.c b/tests/test_cli.c index 15255ff84..610f11ebc 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2483,6 +2483,540 @@ TEST(cli_activation_commands_reject_malformed_and_unknown_flags) { PASS(); } +TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-install-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CLAUDE_CONFIG_DIR", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char claude_dir[640]; + char settings_path[768]; + char mcp_path[768]; + char db_path[768]; + char gate_path[768]; + char session_path[768]; + char subagent_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", claude_dir); + snprintf(mcp_path, sizeof(mcp_path), "%s/.claude.json", tmpdir); + snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir); +#ifdef _WIN32 + snprintf(gate_path, sizeof(gate_path), "%s/hooks/cbm-code-discovery-gate.cmd", claude_dir); + snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder.cmd", claude_dir); + snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder.cmd", claude_dir); +#else + snprintf(gate_path, sizeof(gate_path), "%s/hooks/cbm-code-discovery-gate", claude_dir); + snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder", claude_dir); + snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder", claude_dir); +#endif + test_mkdirp(claude_dir); + write_test_file(settings_path, + "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":" + "\"command\",\"command\":\"/usr/bin/foreign-hook\"}]}]}}\n"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + cli_activation_fake_t fake = {.mutation_reserve_result = 1}; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *conflict_a[] = {"--hooks", "--no-hooks"}; + char *conflict_b[] = {"--no-hooks", "--hooks"}; + int conflict_a_rc = cli_test_cmd_install(2, conflict_a); + int conflict_b_rc = cli_test_cmd_install(2, conflict_b); + + char *default_args[] = {"--skip-binary", "--clients=claude", "--yes"}; + int default_rc = cli_test_cmd_install(3, default_args); + char *default_state = read_test_file_alloc(settings_path); + bool default_hook = default_state && strstr(default_state, "hook-augment") && + strstr(default_state, "/usr/bin/foreign-hook"); + bool default_scripts = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + free(default_state); + + char *no_hooks_plan_args[] = {"--plan", "--no-hooks"}; + int no_hooks_plan_rc = cli_test_cmd_install(2, no_hooks_plan_args); + char *disabled_plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *disabled_doc = + disabled_plan ? yyjson_read(disabled_plan, strlen(disabled_plan), 0) : NULL; + yyjson_val *disabled_root = disabled_doc ? yyjson_doc_get_root(disabled_doc) : NULL; + yyjson_val *disabled_hooks = + disabled_root ? yyjson_obj_get(disabled_root, "hooks_planned") : NULL; + yyjson_val *disabled_configs = + disabled_root ? yyjson_obj_get(disabled_root, "config_files_planned") : NULL; + bool disabled_plan_ok = disabled_hooks && yyjson_is_arr(disabled_hooks) && + yyjson_arr_size(disabled_hooks) == 0U && disabled_configs && + yyjson_is_arr(disabled_configs) && + yyjson_arr_size(disabled_configs) > 0U; + yyjson_doc_free(disabled_doc); + free(disabled_plan); + + char *no_hooks_dry_args[] = {"--dry-run", "--no-hooks", "--skip-binary", "--clients=claude", + "--yes"}; + int no_hooks_dry_rc = cli_test_cmd_install(5, no_hooks_dry_args); + struct stat db_status; + bool previews_read_only = stat(db_path, &db_status) != 0; + + char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=claude", "--yes"}; + int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + cbm_config_t *cfg = cbm_config_open(cache_dir); + bool stored_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + char *disabled_state = read_test_file_alloc(settings_path); + char *disabled_mcp = read_test_file_alloc(mcp_path); + bool disabled_ok = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && + !strstr(disabled_state, "hook-augment") && disabled_mcp && + strstr(disabled_mcp, "codebase-memory-mcp"); + bool no_hooks_scripts_gone = !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) && + !cbm_file_exists(subagent_path); + free(disabled_state); + free(disabled_mcp); + + char *bare_args[] = {"--skip-binary", "--clients=claude", "--yes"}; + int bare_disabled_rc = cli_test_cmd_install(3, bare_args); + disabled_state = read_test_file_alloc(settings_path); + bool bare_disabled = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && + !strstr(disabled_state, "hook-augment"); + bool bare_scripts_gone = + !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) && + !cbm_file_exists(subagent_path); + free(disabled_state); + + char *hooks_plan_args[] = {"--plan", "--hooks"}; + char *hooks_dry_args[] = {"--dry-run", "--hooks", "--skip-binary", "--clients=claude", "--yes"}; + int hooks_plan_rc = cli_test_cmd_install(2, hooks_plan_args); + int hooks_dry_rc = cli_test_cmd_install(5, hooks_dry_args); + cfg = cbm_config_open(cache_dir); + bool previews_retained_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true); + cbm_config_close(cfg); + + char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=claude", "--yes"}; + int hooks_rc = cli_test_cmd_install(4, hooks_args); + cfg = cbm_config_open(cache_dir); + bool stored_true = cfg && cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, false); + cbm_config_close(cfg); + char *enabled_state = read_test_file_alloc(settings_path); + bool enabled_ok = enabled_state && strstr(enabled_state, "hook-augment") && + strstr(enabled_state, "/usr/bin/foreign-hook"); + bool hooks_scripts_back = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + free(enabled_state); + int bare_enabled_rc = cli_test_cmd_install(3, bare_args); + bool bare_scripts_enabled = cbm_file_exists(gate_path) && cbm_file_exists(session_path) && + cbm_file_exists(subagent_path); + + const char *foreign_session_script = +#ifdef _WIN32 + "@echo off\r\nC:\\tmp\\user-owned-session-hook.cmd\r\n"; +#else + "#!/bin/sh\n/usr/bin/user-owned-session-hook\n"; +#endif + write_test_file(session_path, foreign_session_script); + int foreign_no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + char *preserved_session = read_test_file_alloc(session_path); + bool foreign_session_preserved = + foreign_no_hooks_rc == 0 && preserved_session && + strcmp(preserved_session, foreign_session_script) == 0 && !cbm_file_exists(gate_path) && + !cbm_file_exists(subagent_path); + free(preserved_session); + + bool results_ok = + conflict_a_rc == 1 && conflict_b_rc == 1 && default_rc == 0 && default_hook && + default_scripts && no_hooks_plan_rc == 0 && disabled_plan_ok && no_hooks_dry_rc == 0 && + previews_read_only && no_hooks_rc == 0 && stored_false && disabled_ok && + no_hooks_scripts_gone && bare_disabled_rc == 0 && bare_disabled && bare_scripts_gone && + hooks_plan_rc == 0 && hooks_dry_rc == 0 && previews_retained_false && hooks_rc == 0 && + stored_true && enabled_ok && hooks_scripts_back && bare_enabled_rc == 0 && + bare_scripts_enabled && foreign_session_preserved && fake.mutation_reserve_count > 0; + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(results_ok); + PASS(); +} + +TEST(cli_no_hooks_plan_covers_every_hook_installer_family) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-hook-families-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH", "XDG_CONFIG_HOME"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + const char *const dirs[] = {".claude", ".codex", ".gemini", + ".config/opencode", ".augment", ".openclaw/workspace", + ".hermes", ".qwen", ".copilot", + ".factory", ".qoder", ".kimi-code", + ".config/devin", ".gitlab/duo", "bin"}; + char path[768]; + for (size_t i = 0U; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]); + test_mkdirp(path); + } + snprintf(path, sizeof(path), "%s/.openclaw/openclaw.json", tmpdir); + write_test_file(path, "{}\n"); + snprintf(path, sizeof(path), "%s/.copilot/mcp-config.json", tmpdir); + write_test_file(path, "{}\n"); + /* #1180: a bare ~/.claude is not a Claude Code install; its settings.json is. */ + snprintf(path, sizeof(path), "%s/.claude/settings.json", tmpdir); + write_test_file(path, "{}\n"); + + const char *const commands[] = {"qodercli", "kimi", "duo", "devin", "gemini", + "copilot", "auggie", "hermes", "droid"}; + char bin_dir[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/bin", tmpdir); + for (size_t i = 0U; i < sizeof(commands) / sizeof(commands[0]); i++) { +#ifdef _WIN32 + snprintf(path, sizeof(path), "%s/%s.exe", bin_dir, commands[i]); +#else + snprintf(path, sizeof(path), "%s/%s", bin_dir, commands[i]); +#endif + write_test_file(path, "exit 0\n"); + chmod(path, 0700); + } + + char cache_dir[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", bin_dir, 1); + char *no_hooks_args[] = {"--plan", "--no-hooks"}; + int no_hooks_rc = cli_test_cmd_install(2, no_hooks_args); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *hooks = root ? yyjson_obj_get(root, "hooks_planned") : NULL; + yyjson_val *configs = root ? yyjson_obj_get(root, "config_files_planned") : NULL; + yyjson_val *instructions = root ? yyjson_obj_get(root, "instruction_files_planned") : NULL; + const char *const detected[] = {"claude-code", "codex", "gemini", "opencode", + "augment-auggie", "openclaw", "hermes", "qwen", + "copilot-cli", "factory-droid", "qoder", "kimi", + "gitlab-duo", "devin"}; + bool families = no_hooks_rc == 0 && hooks && yyjson_is_arr(hooks) && + yyjson_arr_size(hooks) == 0U && configs && yyjson_is_arr(configs) && + yyjson_arr_size(configs) > 0U && instructions && yyjson_is_arr(instructions) && + yyjson_arr_size(instructions) > 0U && plan && !strstr(plan, "cbm-augment.ts"); + for (size_t i = 0U; families && i < sizeof(detected) / sizeof(detected[0]); i++) { + families = test_json_string_array_contains(root, "agents_detected", detected[i]); + } + yyjson_doc_free(doc); + free(plan); + + char *hooks_args[] = {"--plan", "--hooks"}; + int hooks_rc = cli_test_cmd_install(2, hooks_args); + char *enabled_plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *enabled_doc = + enabled_plan ? yyjson_read(enabled_plan, strlen(enabled_plan), 0) : NULL; + yyjson_val *enabled_root = enabled_doc ? yyjson_doc_get_root(enabled_doc) : NULL; + yyjson_val *enabled_hooks = enabled_root ? yyjson_obj_get(enabled_root, "hooks_planned") : NULL; + bool enabled_plan_ok = + enabled_hooks && yyjson_is_arr(enabled_hooks) && yyjson_arr_size(enabled_hooks) > 0U; + yyjson_doc_free(enabled_doc); + free(enabled_plan); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(families); + ASSERT_EQ(hooks_rc, 0); + ASSERT_TRUE(enabled_plan_ok); + PASS(); +} + +/* Counts the PreToolUse hooks in a Codex hooks.json: ours run hook-augment, the + * foreign one is matched by its exact command. */ +static void test_codex_pretool_hook_counts(const char *path, const char *foreign_command, + size_t *owned, size_t *foreign) { + *owned = 0U; + *foreign = 0U; + char *text = read_test_file_alloc(path); + yyjson_doc *doc = text ? yyjson_read(text, strlen(text), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *hooks = root ? yyjson_obj_get(root, "hooks") : NULL; + yyjson_val *entries = hooks ? yyjson_obj_get(hooks, "PreToolUse") : NULL; + size_t entry_index; + size_t entry_count; + yyjson_val *entry; + if (entries && yyjson_is_arr(entries)) { + yyjson_arr_foreach(entries, entry_index, entry_count, entry) { + yyjson_val *entry_hooks = yyjson_obj_get(entry, "hooks"); + size_t hook_index; + size_t hook_count; + yyjson_val *hook; + if (!entry_hooks || !yyjson_is_arr(entry_hooks)) { + continue; + } + yyjson_arr_foreach(entry_hooks, hook_index, hook_count, hook) { + const char *command = yyjson_get_str(yyjson_obj_get(hook, "command")); + if (command && strstr(command, "hook-augment")) { + (*owned)++; + } else if (command && strcmp(command, foreign_command) == 0) { + (*foreign)++; + } + } + } + } + yyjson_doc_free(doc); + free(text); +} + +/* The Codex PreToolUse Bash hook (#2719) follows the persisted opt-out in both + * hook representations: --no-hooks removes ours from hooks.json and from the + * config.toml managed block, a bare install keeps it off, the user's own + * PreToolUse hook and the MCP entry stay, and --hooks restores ours. */ +TEST(cli_no_hooks_removes_codex_pretool_hook) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-codex-no-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CODEX_HOME", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char codex_dir[640]; + char hooks_path[768]; + char config_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir); + snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); + test_mkdirp(codex_dir); + /* An installed binary at the default target names the Codex hook command; + * without it --skip-binary would configure the test runner itself. */ + char binary_dir[640]; + char binary_path[768]; + snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir); + test_mkdirp(binary_dir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir); +#endif + write_test_file(binary_path, "installed binary\n"); + const char *foreign_command = "/usr/bin/foreign-shell-audit"; + write_test_file(hooks_path, + "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":" + "\"command\",\"command\":\"/usr/bin/foreign-shell-audit\"}]}]}}\n"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + cli_activation_fake_t fake = {.mutation_reserve_result = 1}; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + size_t owned = 0U; + size_t foreign = 0U; + char *default_args[] = {"--skip-binary", "--clients=codex", "--yes"}; + int default_rc = cli_test_cmd_install(3, default_args); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + bool default_json = default_rc == 0 && owned == 1U && foreign == 1U; + + char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=codex", "--yes"}; + int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + char *json_state = read_test_file_alloc(hooks_path); + char *toml_state = read_test_file_alloc(config_path); + bool json_disabled = no_hooks_rc == 0 && owned == 0U && foreign == 1U && json_state && + !strstr(json_state, "hook-augment"); + bool json_mcp_kept = toml_state && strstr(toml_state, "[mcp_servers.codebase-memory-mcp]") && + !strstr(toml_state, "hook-augment"); + free(json_state); + free(toml_state); + + int bare_rc = cli_test_cmd_install(3, default_args); + test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign); + bool bare_disabled = bare_rc == 0 && owned == 0U && foreign == 1U; + + /* Without hooks.json the config.toml managed block carries the hooks. */ + (void)remove(hooks_path); + char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=codex", "--yes"}; + int toml_enable_rc = cli_test_cmd_install(4, hooks_args); + toml_state = read_test_file_alloc(config_path); + bool toml_enabled = toml_enable_rc == 0 && toml_state && + test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U; + free(toml_state); + int toml_disable_rc = cli_test_cmd_install(4, no_hooks_args); + toml_state = read_test_file_alloc(config_path); + bool toml_disabled = toml_disable_rc == 0 && toml_state && + !strstr(toml_state, "hooks.PreToolUse") && + !strstr(toml_state, "hook-augment") && + strstr(toml_state, "[mcp_servers.codebase-memory-mcp]"); + free(toml_state); + int restore_rc = cli_test_cmd_install(4, hooks_args); + toml_state = read_test_file_alloc(config_path); + bool restored = restore_rc == 0 && toml_state && + test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U; + free(toml_state); + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(default_json); + ASSERT_TRUE(json_mcp_kept); + ASSERT_TRUE(toml_enabled); + ASSERT_TRUE(toml_disabled); + ASSERT_TRUE(restored); + ASSERT_TRUE(json_disabled); + ASSERT_TRUE(bare_disabled); + PASS(); +} + +/* The Augment SessionStart and PostToolUse view scripts follow the persisted + * opt-out the way uninstall treats them: --no-hooks deletes both exact-owned + * scripts and keeps the MCP entry, a bare install keeps them off, --hooks + * restores them, and a script the user edited is kept. */ +TEST(cli_no_hooks_removes_augment_owned_scripts) { + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s/cli-augment-no-hooks-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH"}; + char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + saved_env[i] = save_test_env(env_names[i]); + cbm_unsetenv(env_names[i]); + } + + char cache_dir[640]; + char augment_dir[640]; + char settings_path[768]; + char session_path[768]; + char coverage_path[768]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(augment_dir, sizeof(augment_dir), "%s/.augment", tmpdir); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", augment_dir); +#ifdef _WIN32 + snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-session.ps1", + augment_dir); + snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-coverage.ps1", + augment_dir); +#else + snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-session.sh", + augment_dir); + snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-coverage.sh", + augment_dir); +#endif + test_mkdirp(augment_dir); + /* An installed binary at the default target is the path the scripts embed; + * without it --skip-binary would configure the test runner itself. */ + char binary_dir[640]; + char binary_path[768]; + snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir); + test_mkdirp(binary_dir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir); +#endif + write_test_file(binary_path, "installed binary\n"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_setenv("PATH", tmpdir, 1); + + cli_activation_fake_t fake = {.mutation_reserve_result = 1}; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *default_args[] = {"--skip-binary", "--clients=augment", "--yes"}; + int default_rc = cli_test_cmd_install(3, default_args); + bool default_scripts = + default_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path); + + char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=augment", "--yes"}; + int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args); + bool scripts_removed = + no_hooks_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path); + char *settings = read_test_file_alloc(settings_path); + bool mcp_kept = settings && strstr(settings, "mcpServers") && + strstr(settings, "codebase-memory-mcp") && + !strstr(settings, "codebase-memory-session") && + !strstr(settings, "codebase-memory-coverage"); + free(settings); + + int bare_rc = cli_test_cmd_install(3, default_args); + bool bare_disabled = + bare_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path); + + char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=augment", "--yes"}; + int hooks_rc = cli_test_cmd_install(4, hooks_args); + settings = read_test_file_alloc(settings_path); + bool restored = hooks_rc == 0 && cbm_file_exists(session_path) && + cbm_file_exists(coverage_path) && settings && + strstr(settings, "codebase-memory-session") && + strstr(settings, "codebase-memory-coverage"); + free(settings); + + /* A script the user edited is no longer the owned document: the opt-out + * keeps it, as uninstall does, and still deletes the untouched one. */ + char *owned_session = read_test_file_alloc(session_path); + char user_session[4096]; + snprintf(user_session, sizeof(user_session), "%s# local edit\n", + owned_session ? owned_session : ""); + free(owned_session); + write_test_file(session_path, user_session); + int user_rc = cli_test_cmd_install(4, no_hooks_args); + char *kept_session = read_test_file_alloc(session_path); + bool user_script_kept = user_rc == 0 && kept_session && + strcmp(kept_session, user_session) == 0 && + !cbm_file_exists(coverage_path); + free(kept_session); + + /* End on --hooks, as the Codex case does: the opt-out is process state, + * and later tests install without the command line. */ + (void)remove(session_path); + int final_rc = cli_test_cmd_install(4, hooks_args); + bool final_restored = + final_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path); + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) { + restore_test_env(env_names[i], saved_env[i]); + } + test_rmdir_r(tmpdir); + + ASSERT_TRUE(default_scripts); + ASSERT_TRUE(mcp_kept); + ASSERT_TRUE(restored); + ASSERT_TRUE(scripts_removed); + ASSERT_TRUE(bare_disabled); + ASSERT_TRUE(user_script_kept); + ASSERT_TRUE(final_restored); + PASS(); +} + TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX"); @@ -18590,18 +19124,32 @@ TEST(cli_config_cmd_get_prints_defaults_and_rejects_unknown_keys) { char *get_limit[] = {"get", "auto_index_limit"}; ASSERT_EQ(cli_config_cmd_capture(2, get_limit, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "50000\n"); + char *get_install_hooks[] = {"get", "install_hooks"}; + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "true\n"); + char *list[] = {"list"}; + ASSERT_EQ(cli_config_cmd_capture(1, list, out, sizeof(out)), 0); + ASSERT_NOT_NULL(strstr(out, "install_hooks")); /* Stored value round-trips. */ char *set_watch[] = {"set", "auto_watch", "false"}; ASSERT_EQ(cli_config_cmd_capture(3, set_watch, out, sizeof(out)), 0); ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "false\n"); + char *set_install_hooks[] = {"set", "install_hooks", "false"}; + ASSERT_EQ(cli_config_cmd_capture(3, set_install_hooks, out, sizeof(out)), 0); + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "false\n"); /* Reset returns the key to its default. */ char *reset_watch[] = {"reset", "auto_watch"}; ASSERT_EQ(cli_config_cmd_capture(2, reset_watch, out, sizeof(out)), 0); ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0); ASSERT_STR_EQ(out, "true\n"); + char *reset_install_hooks[] = {"reset", "install_hooks"}; + ASSERT_EQ(cli_config_cmd_capture(2, reset_install_hooks, out, sizeof(out)), 0); + ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0); + ASSERT_STR_EQ(out, "true\n"); /* Unknown keys are errors on every subcommand, with clean stdout. */ char *get_bogus[] = {"get", "totally_bogus_key"}; @@ -20494,6 +21042,10 @@ SUITE(cli) { RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary); RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely); RUN_TEST(cli_activation_commands_reject_malformed_and_unknown_flags); + RUN_TEST(cli_install_hooks_preference_persists_and_migrates_owned_state); + RUN_TEST(cli_no_hooks_plan_covers_every_hook_installer_family); + RUN_TEST(cli_no_hooks_removes_codex_pretool_hook); + RUN_TEST(cli_no_hooks_removes_augment_owned_scripts); RUN_TEST(cli_install_reset_deletion_waits_for_final_activation_guard); RUN_TEST(cli_remove_indexes_deletes_orphan_sqlite_sidecars_issue2054); RUN_TEST(cli_index_enumeration_skips_internal_cache_dbs);