From 67af19e83baaef31ba64c7e7dcae1bf671093fdc Mon Sep 17 00:00:00 2001 From: Andy11-cpu Date: Thu, 27 Aug 2026 13:53:22 -0400 Subject: [PATCH 1/6] feat(cli): persist install hook opt-out Signed-off-by: Andy11-cpu --- README.md | 2 + docs/CONFIGURATION.md | 4 + scripts/smoke-test.sh | 15 +++ src/cli/cli.c | 250 ++++++++++++++++++++++++++++++++++++++---- src/cli/cli.h | 1 + src/main.c | 2 +- tests/test_cli.c | 233 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 482 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index dfd9690442..8d4a3b092e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ The `install` command automatically strips macOS quarantine attributes and ad-ho The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported. +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 the final session shuts it down. 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 fe9c24a7b5..d795fed9dd 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: @@ -89,6 +90,9 @@ Current keys: | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | +| `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. > **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the > watcher *subsystem* starts at all (the background poll thread). `auto_watch` is diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index fb7fe3dd32..5fd4c84f5b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1691,6 +1691,21 @@ 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 +if grep -qE 'cbm-(code-discovery-gate|session-reminder|subagent-reminder)' "$FAKE_HOME/.claude/settings.json" 2>/dev/null || + ! 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 'cbm-code-discovery-gate' "$FAKE_HOME/.claude/settings.json" 2>/dev/null; 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 56af212d1a..cd1837260c 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -6841,6 +6841,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, + {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"}, @@ -7535,11 +7536,19 @@ 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"); +} 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; @@ -7732,6 +7741,21 @@ 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(settings_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_session_hooks(settings_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + if (cbm_remove_claude_subagent_hooks(settings_path) != CLI_OK) { + record_agent_config_error(false, "Claude Code", "hook_disable", settings_path); + } + } + print_hooks_skipped(); + goto claude_hooks_done; + } bool gate_ok = dry_run; bool session_ok = dry_run; bool subagent_ok = dry_run; @@ -7810,6 +7834,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]) { @@ -8171,6 +8196,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(hooks_dir, CLI_OCTAL_PERM) || cbm_upsert_copilot_hooks(binary_path, hook_path) != CLI_OK)) { @@ -8343,6 +8376,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; @@ -8402,6 +8443,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; @@ -8439,6 +8488,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; @@ -8547,6 +8604,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)) { @@ -8763,6 +8828,19 @@ 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 = cbm_remove_gemini_coverage_hook(cp, binary_path); + 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); @@ -8806,8 +8884,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, @@ -8848,7 +8927,14 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const plan_record("Codex CLI", "hook", hook_target); } else { bool hook_ok = true; - 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); + hook_ok = toml_result == CLI_OK && json_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 && @@ -8858,7 +8944,11 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const hook_ok = cbm_upsert_codex_hooks_command(cp, command, command_windows) == 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(" hooks: SessionStart + SubagentStart (dynamic graph context)\n"); printf(" note: non-managed hooks require /hooks trust; definition changes " @@ -8899,8 +8989,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]; @@ -9141,6 +9240,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]; @@ -9163,16 +9269,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"); } } @@ -9259,14 +9365,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); } @@ -9312,7 +9423,11 @@ 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; + } 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); @@ -9320,16 +9435,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"); } } @@ -9390,11 +9512,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) { @@ -9439,6 +9570,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 { @@ -9914,6 +10051,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 @@ -10051,6 +10226,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; @@ -10104,6 +10280,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. */ @@ -10175,6 +10358,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; @@ -10182,6 +10366,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++) { @@ -10195,6 +10381,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]) { @@ -10232,11 +10422,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 @@ -10487,6 +10685,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, @@ -12444,6 +12643,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 da2c4512a7..295883c553 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -423,6 +423,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_INSTALL_HOOKS "install_hooks" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/main.c b/src/main.c index 1e06d86ff8..7851ab3eca 100644 --- a/src/main.c +++ b/src/main.c @@ -901,7 +901,7 @@ static void print_help(void) { printf(" codebase-memory-mcp cli [--progress] [--json] [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(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n"); printf(" codebase-memory-mcp update [-y|-n]\n"); printf(" codebase-memory-mcp config \n"); diff --git a/tests/test_cli.c b/tests/test_cli.c index a72ed64747..e5c113abbe 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1158,6 +1158,223 @@ 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]; + 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); + 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, "cbm-code-discovery-gate") && + strstr(default_state, "/usr/bin/foreign-hook"); + 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, "cbm-code-discovery-gate") && disabled_mcp && + strstr(disabled_mcp, "codebase-memory-mcp"); + 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, "cbm-code-discovery-gate"); + 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, "cbm-code-discovery-gate") && + strstr(enabled_state, "/usr/bin/foreign-hook"); + free(enabled_state); + int bare_enabled_rc = cli_test_cmd_install(3, bare_args); + bool results_ok = + conflict_a_rc == 1 && conflict_b_rc == 1 && default_rc == 0 && default_hook && + no_hooks_plan_rc == 0 && disabled_plan_ok && no_hooks_dry_rc == 0 && previews_read_only && + no_hooks_rc == 0 && stored_false && disabled_ok && bare_disabled_rc == 0 && bare_disabled && + hooks_plan_rc == 0 && hooks_dry_rc == 0 && previews_retained_false && hooks_rc == 0 && + stored_true && enabled_ok && bare_enabled_rc == 0 && 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"); + + 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(); +} + TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX"); @@ -12724,18 +12941,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"}; @@ -13669,6 +13900,8 @@ 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_install_reset_deletion_waits_for_final_activation_guard); RUN_TEST(cli_install_config_only_waits_for_cohort_drain); RUN_TEST(cli_install_config_and_path_finish_before_guard_release); From b0713cc24dcd2d4f35c5cce1988b1b4b6b04907e Mon Sep 17 00:00:00 2001 From: Andy11-cpu Date: Fri, 28 Aug 2026 00:37:48 -0400 Subject: [PATCH 2/6] fix(cli): remove owned Claude scripts with no-hooks Signed-off-by: Andy11-cpu --- scripts/smoke-test.sh | 1 + src/cli/cli.c | 169 ++++++++++++++++++++++-------------------- tests/test_cli.c | 47 +++++++++++- 3 files changed, 134 insertions(+), 83 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 5fd4c84f5b..aa192261ee 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1694,6 +1694,7 @@ 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 if grep -qE 'cbm-(code-discovery-gate|session-reminder|subagent-reminder)' "$FAKE_HOME/.claude/settings.json" 2>/dev/null || + [ -e "$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" ] || ! 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 diff --git a/src/cli/cli.c b/src/cli/cli.c index cd1837260c..0fd9aca4eb 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7663,6 +7663,93 @@ 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; +} + static void install_claude_code_config(const char *home, const char *binary_path, bool force, bool dry_run) { char config_dir[CLI_BUF_1K]; @@ -7752,6 +7839,7 @@ static void install_claude_code_config(const char *home, const char *binary_path if (cbm_remove_claude_subagent_hooks(settings_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; @@ -10787,86 +10875,7 @@ static void uninstall_claude_code(const char *home, bool dry_run) { 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"); } diff --git a/tests/test_cli.c b/tests/test_cli.c index e5c113abbe..b9790fdce6 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1176,11 +1176,23 @@ TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { 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\":" @@ -1203,6 +1215,8 @@ TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { char *default_state = read_test_file_alloc(settings_path); bool default_hook = default_state && strstr(default_state, "cbm-code-discovery-gate") && 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"}; @@ -1238,6 +1252,8 @@ TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { bool disabled_ok = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && !strstr(disabled_state, "cbm-code-discovery-gate") && 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); @@ -1246,6 +1262,9 @@ TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { disabled_state = read_test_file_alloc(settings_path); bool bare_disabled = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") && !strstr(disabled_state, "cbm-code-discovery-gate"); + 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"}; @@ -1264,14 +1283,36 @@ TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) { char *enabled_state = read_test_file_alloc(settings_path); bool enabled_ok = enabled_state && strstr(enabled_state, "cbm-code-discovery-gate") && 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 && - no_hooks_plan_rc == 0 && disabled_plan_ok && no_hooks_dry_rc == 0 && previews_read_only && - no_hooks_rc == 0 && stored_false && disabled_ok && bare_disabled_rc == 0 && bare_disabled && + 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 && bare_enabled_rc == 0 && fake.mutation_reserve_count > 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); From 0e8fc16abce48346d07af78acf3293a805085e23 Mon Sep 17 00:00:00 2001 From: Andy11-cpu Date: Wed, 2 Sep 2026 00:22:06 -0400 Subject: [PATCH 3/6] fix(cli): guard Gemini coverage cleanup on Windows Signed-off-by: Andy11-cpu --- src/cli/cli.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 0fd9aca4eb..bb4a4cd32c 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8919,7 +8919,10 @@ static void install_gemini_config(const char *home, const char *binary_path, boo if (!g_install_hooks) { if (!dry_run) { int tool_result = cbm_remove_gemini_hooks(cp); - int coverage_result = cbm_remove_gemini_coverage_hook(cp, binary_path); + 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); From 5216b70635beb936eebb1a0c6b26b4aa11ac3b5d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:34:09 +0100 Subject: [PATCH 4/6] test(cli): adapt the no-hooks plan fixture to the Claude detection rule Since #1180 a bare ~/.claude directory no longer counts as a Claude Code install. The fixture of cli_no_hooks_plan_covers_every_hook_installer_family now also writes .claude/settings.json. Without it claude-code drops out of agents_detected and the test fails on the tree merged with current main. Signed-off-by: Martin Vogel --- tests/test_cli.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_cli.c b/tests/test_cli.c index 484b30dfaa..4e750d3147 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2676,6 +2676,9 @@ TEST(cli_no_hooks_plan_covers_every_hook_installer_family) { 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"}; From ebe26ba2b5ab0f0229492dc9e1159ff24bd87029 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:34:09 +0100 Subject: [PATCH 5/6] fix(cli): remove the Codex PreToolUse hook under the install-hook opt-out #2719 added a PreToolUse Bash hook for Codex after this opt-out was written. In config.toml it lives inside the managed block, which the opt-out already removes. In hooks.json it is a separate entry beside the lifecycle pair, and the opt-out removed only the pair, so after install --no-hooks, and on every later bare install or update that respects the stored preference, our PreToolUse hook stayed registered. The --no-hooks branch now also calls cbm_remove_codex_pretool_hooks_json, the exact-command remover that uninstall uses since #2719. A PreToolUse hook the user owns is left alone. Test: cli_no_hooks_removes_codex_pretool_hook drives the real install dispatch through both hook representations. A default install writes ours beside a foreign hook; --no-hooks and a bare install with the stored preference remove ours and keep the foreign hook and the MCP entry; and --hooks restores ours. Without the fix it fails: ours is still in hooks.json after --no-hooks. Signed-off-by: Martin Vogel --- src/cli/cli.c | 8 ++- tests/test_cli.c | 149 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index df29e41eaf..cf69fe13bc 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -10755,7 +10755,13 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const int json_result = !cbm_file_exists(hooks_json) ? CLI_OK : cbm_remove_paired_lifecycle_hooks_json(hooks_json, command); - hook_ok = toml_result == CLI_OK && json_result == CLI_OK; + /* #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, diff --git a/tests/test_cli.c b/tests/test_cli.c index 4e750d3147..569c9353da 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2743,6 +2743,154 @@ TEST(cli_no_hooks_plan_covers_every_hook_installer_family) { 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(); +} + TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX"); @@ -20433,6 +20581,7 @@ SUITE(cli) { 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_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); From e1be4c4eaf6ef77fb5e55bacbe04e47bbb13d71d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:52:14 +0100 Subject: [PATCH 6/6] fix(cli): remove the owned Augment scripts under the install-hook opt-out install --no-hooks and the persisted install_hooks=false preference removed the Augment SessionStart and PostToolUse view registrations from ~/.augment/settings.json but left the two scripts they pointed at, ~/.augment/hooks/codebase-memory-session.sh and codebase-memory-coverage.sh (.ps1 on Windows), on disk. The opt-out now deletes them the way uninstall does. Both paths share remove_augment_owned_hook_scripts(): each script is rebuilt from the binary path the install embedded and removed with cbm_text_remove_owned_document() only on a byte-for-byte match, so a script the user edited, or a foreign file with the same name, is kept. Uninstall keeps its error operation names; the opt-out records session_script_disable and coverage_script_disable. install --hooks writes both scripts again. Test: cli_no_hooks_removes_augment_owned_scripts drives the install command with fake activation ops in a temporary HOME: default install, --no-hooks (both scripts gone, MCP entry kept), a bare install with the stored preference (still gone), --hooks (restored), and a user-edited session script that --no-hooks keeps while it deletes the untouched coverage script. The test ends on --hooks because the opt-out is process state that later tests rely on. Signed-off-by: Martin Vogel --- src/cli/cli.c | 55 ++++++++++++--------- tests/test_cli.c | 126 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 24 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index cf69fe13bc..44c0861dfd 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -9140,6 +9140,35 @@ static int remove_claude_owned_hook_scripts(const char *config_dir, const char * 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]; @@ -11326,6 +11355,7 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents 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", @@ -13701,30 +13731,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"); } diff --git a/tests/test_cli.c b/tests/test_cli.c index 569c9353da..82b65975ef 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2891,6 +2891,131 @@ TEST(cli_no_hooks_removes_codex_pretool_hook) { 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"); @@ -20582,6 +20707,7 @@ SUITE(cli) { 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);