From 2b329fe75d6c683cf0dd65fdca2e8405ab3ef1ac Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 28 Sep 2026 02:42:24 +0200 Subject: [PATCH 1/3] fix(cli): client detection for OpenCode, Hermes, Claude (#1167, #1180) Three detection defects kept `codebase-memory-mcp install` from configuring agents people run, and made it configure one they never installed. OpenCode without a config file or a CLI on PATH (#1167, #1180). OpenCode was detected only from its global config file or directory, OPENCODE_CONFIG_DIR, or an `opencode` found on PATH. It now also counts as installed when its data directory exists: ~/.local/share/opencode on every OS, %USERPROFILE%\.local\share\opencode on Windows (opencode.ai/docs/troubleshooting, "Storage"; the same page names it as the global config location of older installs). The OpenCode CLI and the Desktop app both create it -- the Desktop's bundled opencode-cli server uses the same XDG paths (anomalyco/opencode packages/core/src/global.ts creates the config and data dirs at startup; packages/desktop/src/main/sidecar.ts only redirects XDG_STATE_HOME) -- so detection no longer depends on PATH. The install target ~/.config/opencode/opencode.json(c) -- %USERPROFILE%\.config\opencode on Windows per the same docs page -- is unchanged and right for CLI and Desktop alike. A PATH longer than 4 KB, which hides every CLI from find_in_path(), is handled separately by PR #2381. Hermes home on Windows (#1180). Native Windows Hermes -- the install.ps1 CLI, the MSIX package and the Desktop app -- defaults HERMES_HOME to %LOCALAPPDATA%\hermes and falls back to a legacy ~/.hermes only while that directory is absent (NousResearch/hermes-agent website/docs/user-guide/windows-native.md, "Data layout"; apps/desktop/electron/data-paths.mjs, resolveDesktopHermesHome). cbm probed and wrote ~/.hermes only, so a Desktop install was never detected and a Hermes found on PATH got its config.yaml and skill in a home it never reads. On Windows the home now resolves to /AppData/Local/hermes unless only ~/.hermes exists, using the home-derived AppData convention of the existing Zed, goose, Kilo and VS Code probes; detection, install and uninstall all follow it. HERMES_HOME still wins, and macOS/Linux keep ~/.hermes. Claude Code from a bare ~/.claude (#1180). Any ~/.claude directory counted as a Claude Code install, so a user who only had the folder got hooks, skills and MCP entries for a client they never installed. Detection now needs what Claude Code itself leaves behind: settings.json in the config dir, .claude.json in the home (in CLAUDE_CONFIG_DIR when set), or the claude CLI. Proof on macOS against synthetic HOME/cache/runtime dirs with a minimal PATH (`install --dry-run --skip-binary`, then a config-only install): bare ~/.claude main: Claude-Code fix: none ~/.local/share/opencode only main: none fix: OpenCode In the OpenCode case the fixed install writes the codebase-memory-mcp entry to ~/.config/opencode/opencode.json; main writes nothing. Controls (a launched Desktop layout, a real Claude user) are detected by both. The Windows Hermes home is covered by a platform-parameterised resolver test that runs on every host plus a _WIN32-bound end-to-end detection and plan test. Tests (cli suite, 335 passed): seven new tests; five fail on main three times out of three (the _WIN32-bound Hermes test and the look-alike control are green on macOS by design). Reverting the Claude+Hermes half and the OpenCode data-dir probe separately turns exactly their own tests red. Nine existing Claude fixtures that used a bare ~/.claude as the install marker now write .claude.json; two Hermes tests and smoke-test 8x expect %LOCALAPPDATA%\hermes on Windows. Refs #1167 Fixes #1180 Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 20 +-- src/cli/cli.c | 96 +++++++++++--- src/cli/cli.h | 13 +- tests/test_cli.c | 285 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+), 25 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index d1c64c6b75..477cc6ab32 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -2126,19 +2126,25 @@ sys.exit(0 if ok else 1) fi echo "OK 8w-ii: Kiro MCP + steering + isolated exact-tool graph agent" -# 8x: Hermes Agent YAML MCP mapping +# 8x: Hermes Agent YAML MCP mapping. Native Windows Hermes keeps its home in +# %LOCALAPPDATA%\hermes (#1180); the stub-only FAKE_HOME has no legacy ~/.hermes. +if [[ "$BINARY" == *.exe ]]; then + HERMES_HOME_DIR="$FAKE_HOME/AppData/Local/hermes" +else + HERMES_HOME_DIR="$FAKE_HOME/.hermes" +fi HERMES_CMD=$(sed -n '/^ codebase-memory-mcp:/{n;s/^ *command: *//p;}' \ - "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null | head -1) -if ! grep -q '^mcp_servers:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || - ! grep -q '^ codebase-memory-mcp:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || + "$HERMES_HOME_DIR/config.yaml" 2>/dev/null | head -1) +if ! grep -q '^mcp_servers:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || + ! grep -q '^ codebase-memory-mcp:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || ! quoted_path_value_matches "$HERMES_CMD" "$SELF_PATH"; then echo "FAIL 8x: Hermes MCP mapping missing or malformed" exit 1 fi echo "OK 8x: Hermes Agent MCP" -if ! grep -q '^name: codebase-memory$' "$FAKE_HOME/.hermes/skills/codebase-memory/SKILL.md" 2>/dev/null || - ! grep -q 'delegate_task' "$FAKE_HOME/.hermes/skills/codebase-memory/SKILL.md" 2>/dev/null || - ! grep -q '`context`' "$FAKE_HOME/.hermes/skills/codebase-memory/SKILL.md" 2>/dev/null; then +if ! grep -q '^name: codebase-memory$' "$HERMES_HOME_DIR/skills/codebase-memory/SKILL.md" 2>/dev/null || + ! grep -q 'delegate_task' "$HERMES_HOME_DIR/skills/codebase-memory/SKILL.md" 2>/dev/null || + ! grep -q '`context`' "$HERMES_HOME_DIR/skills/codebase-memory/SKILL.md" 2>/dev/null; then echo "FAIL 8x-i: Hermes delegation skill missing" exit 1 fi diff --git a/src/cli/cli.c b/src/cli/cli.c index b868fdca25..fb9b79b3f4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -2726,10 +2726,41 @@ static void cbm_kiro_home_dir(const char *home_dir, char *out, size_t out_sz) { cbm_env_home_dir("KIRO_HOME", home_dir, ".kiro", out, out_sz); } +/* #1180: native Windows Hermes -- the install.ps1 CLI, the MSIX package and + * the Desktop app -- defaults HERMES_HOME to %LOCALAPPDATA%\hermes and keeps a + * legacy ~/.hermes only while the new directory is absent (hermes-agent + * website/docs/user-guide/windows-native.md "Data layout"; + * apps/desktop/electron/data-paths.mjs resolveDesktopHermesHome). Probing + * only ~/.hermes missed those installs and aimed config.yaml at a home Hermes + * never reads. Other platforms use ~/.hermes. */ +static void cbm_hermes_home_dir_for(const char *home_dir, bool windows, char *out, size_t out_sz) { + const char *fallback = ".hermes"; + if (windows) { + char native[CLI_BUF_1K]; + char legacy[CLI_BUF_1K]; + snprintf(native, sizeof(native), "%s/AppData/Local/hermes", home_dir); + snprintf(legacy, sizeof(legacy), "%s/.hermes", home_dir); + if (dir_exists(native) || !dir_exists(legacy)) { + fallback = "AppData/Local/hermes"; + } + } + cbm_env_home_dir("HERMES_HOME", home_dir, fallback, out, out_sz); +} + static void cbm_hermes_home_dir(const char *home_dir, char *out, size_t out_sz) { - cbm_env_home_dir("HERMES_HOME", home_dir, ".hermes", out, out_sz); +#ifdef _WIN32 + cbm_hermes_home_dir_for(home_dir, true, out, out_sz); +#else + cbm_hermes_home_dir_for(home_dir, false, out, out_sz); +#endif } +#ifdef CBM_CLI_ENABLE_TEST_API +void cbm_hermes_home_dir_for_testing(const char *home_dir, bool windows, char *out, size_t out_sz) { + cbm_hermes_home_dir_for(home_dir, windows, out, out_sz); +} +#endif + static void cbm_qwen_home_dir(const char *home_dir, char *out, size_t out_sz) { cbm_env_home_dir("QWEN_HOME", home_dir, ".qwen", out, out_sz); } @@ -3031,6 +3062,53 @@ static int cbm_resolve_released_hook_command(const char *script_name, char *out, return written > 0 && (size_t)written < out_sz ? CLI_OK : CLI_ERR; } +/* #1180: a bare ~/.claude directory is not a Claude Code install -- other + * tools create it -- and treating it as one wrote hooks, skills and MCP + * entries for a client the user never installed. Claude Code itself leaves + * its user config .claude.json (under CLAUDE_CONFIG_DIR when set), its + * settings.json, or the claude CLI. */ +static bool cbm_claude_code_detected(const char *home_dir) { + char dir[CLI_BUF_1K]; + char path[CLI_BUF_1K]; + cbm_claude_config_dir(home_dir, dir, sizeof(dir)); + int written = snprintf(path, sizeof(path), "%s/settings.json", dir); + if (dir[0] && written > 0 && (size_t)written < sizeof(path) && cbm_file_exists(path)) { + return true; + } + cbm_claude_user_root(home_dir, dir, sizeof(dir)); + written = snprintf(path, sizeof(path), "%s/.claude.json", dir); + if (dir[0] && written > 0 && (size_t)written < sizeof(path) && cbm_file_exists(path)) { + return true; + } + return cbm_agent_cli_exists("claude", home_dir); +} + +/* OpenCode keeps its data (auth, logs, sessions; the global config itself on + * older installs) in ~/.local/share/opencode on every OS -- + * %USERPROFILE%\.local\share\opencode on Windows (opencode.ai/docs/ + * troubleshooting, "Storage"). The CLI and the Desktop app's bundled + * opencode-cli server both create it, so it identifies an install that has + * no config file, no ~/.config/opencode and no `opencode` on PATH + * (#1167, #1180). */ +static bool cbm_opencode_detected(const char *home_dir) { + char path[CLI_BUF_1K]; + cbm_opencode_config_path(home_dir, path, sizeof(path)); + if (cbm_file_exists(path) || cbm_agent_cli_exists("opencode", home_dir)) { + return true; + } + snprintf(path, sizeof(path), "%s/.config/opencode", home_dir); + if (dir_exists(path)) { + return true; + } + char env_buf[CLI_BUF_1K]; + const char *config_dir = cbm_safe_getenv("OPENCODE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL); + if (config_dir && config_dir[0] && dir_exists(config_dir)) { + return true; + } + snprintf(path, sizeof(path), "%s/.local/share/opencode", home_dir); + return dir_exists(path); +} + cbm_detected_agents_t cbm_detect_agents(const char *home_dir) { cbm_detected_agents_t agents; memset(&agents, 0, sizeof(agents)); @@ -3040,8 +3118,7 @@ cbm_detected_agents_t cbm_detect_agents(const char *home_dir) { char path[CLI_BUF_1K]; - cbm_claude_config_dir(home_dir, path, sizeof(path)); - agents.claude_code = path[0] != '\0' && dir_exists(path); + agents.claude_code = cbm_claude_code_detected(home_dir); cbm_codex_config_dir(home_dir, path, sizeof(path)); agents.codex = path[0] != '\0' && dir_exists(path); @@ -3055,18 +3132,7 @@ cbm_detected_agents_t cbm_detect_agents(const char *home_dir) { cbm_zed_config_dir(home_dir, path, sizeof(path)); agents.zed = dir_exists(path); - cbm_opencode_config_path(home_dir, path, sizeof(path)); - agents.opencode = cbm_file_exists(path) || cbm_agent_cli_exists("opencode", home_dir); - if (!agents.opencode) { - snprintf(path, sizeof(path), "%s/.config/opencode", home_dir); - agents.opencode = dir_exists(path); - } - if (!agents.opencode) { - char env_buf[CLI_BUF_1K]; - const char *config_dir = - cbm_safe_getenv("OPENCODE_CONFIG_DIR", env_buf, sizeof(env_buf), NULL); - agents.opencode = config_dir && config_dir[0] && dir_exists(config_dir); - } + agents.opencode = cbm_opencode_detected(home_dir); agents.aider = cbm_agent_cli_exists("aider", home_dir); diff --git a/src/cli/cli.h b/src/cli/cli.h index 5522edef06..2db84abd09 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -182,11 +182,11 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path); /* Detected coding agents on the system. */ typedef struct { - bool claude_code; /* ~/.claude/ exists */ + bool claude_code; /* settings.json, .claude.json, or claude CLI exists */ bool codex; /* $CODEX_HOME or ~/.codex exists */ bool gemini; /* Gemini settings or executable exists */ bool zed; /* platform-specific Zed config dir exists */ - bool opencode; /* opencode on PATH or config exists */ + bool opencode; /* opencode on PATH, config, or data dir exists */ bool antigravity; /* Antigravity CLI config or executable exists */ bool aider; /* aider on PATH */ bool kilocode; /* KiloCode globalStorage dir exists */ @@ -197,7 +197,7 @@ typedef struct { bool openclaw; /* ~/.openclaw/ exists */ bool kiro; /* ~/.kiro/ exists */ bool junie; /* ~/.junie/ exists */ - bool hermes; /* ~/.hermes/ or hermes CLI exists */ + bool hermes; /* Hermes home (Windows: %LOCALAPPDATA%\hermes) or CLI */ bool openhands; /* ~/.openhands/ or openhands CLI exists */ bool cline; /* ~/.cline/ or cline CLI exists */ bool warp; /* Warp footprint or oz/oz-preview/warp-cli exists */ @@ -217,6 +217,13 @@ cbm_detected_agents_t cbm_detect_agents(const char *home_dir); /* Install or refresh every detected agent integration below home. */ int cbm_install_agent_configs(const char *home, const char *binary_path, bool force, bool dry_run); +#ifdef CBM_CLI_ENABLE_TEST_API +/* #1180: the Hermes home as resolved on Windows (`windows`) or elsewhere, so + * the %LOCALAPPDATA%\hermes default and its legacy ~/.hermes fallback are + * exercised on every host. */ +void cbm_hermes_home_dir_for_testing(const char *home_dir, bool windows, char *out, size_t out_sz); +#endif + #ifdef CBM_CLI_ENABLE_TEST_API /* #1558: client-selector vocabulary, exposed so a test can prove every token * resolves and an unknown one is rejected. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 4f95e9c43a..60c23b8a30 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -5848,6 +5848,11 @@ TEST(cli_detect_agents_finds_claude) { char dir[512]; snprintf(dir, sizeof(dir), "%s/.claude", tmpdir); test_mkdirp(dir); + /* #1180: a bare directory is not evidence; the settings file Claude Code + * reads is. */ + char settings[640]; + snprintf(settings, sizeof(settings), "%s/settings.json", dir); + write_test_file(settings, "{}\n"); /* Unset CLAUDE_CONFIG_DIR so detection is exercised against home_dir/.claude * and the runner's real env (which may set it) does not leak in. */ @@ -5877,6 +5882,10 @@ TEST(cli_detect_agents_finds_claude_via_env) { char ccd[512]; snprintf(ccd, sizeof(ccd), "%s/custom-claude", tmpdir); test_mkdirp(ccd); + /* #1180: Claude Code keeps .claude.json inside CLAUDE_CONFIG_DIR. */ + char user_config[640]; + snprintf(user_config, sizeof(user_config), "%s/.claude.json", ccd); + write_test_file(user_config, "{}\n"); const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR"); char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL; @@ -5897,6 +5906,255 @@ TEST(cli_detect_agents_finds_claude_via_env) { PASS(); } +/* #1167/#1180: detection runs against a synthetic home with every env var that + * can redirect Claude/OpenCode/Hermes lookups cleared and PATH reduced to one + * empty directory, so the host's own installs never leak into the verdict. */ +typedef struct { + char *path; + char *claude_config_dir; + char *opencode_config; + char *opencode_config_dir; + char *hermes_home; +} client_detect_env_t; + +static client_detect_env_t client_detect_env_isolate(const char *path_dir) { + client_detect_env_t saved = { + save_test_env("PATH"), save_test_env("CLAUDE_CONFIG_DIR"), save_test_env("OPENCODE_CONFIG"), + save_test_env("OPENCODE_CONFIG_DIR"), save_test_env("HERMES_HOME")}; + cbm_setenv("PATH", path_dir, 1); + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + cbm_unsetenv("OPENCODE_CONFIG"); + cbm_unsetenv("OPENCODE_CONFIG_DIR"); + cbm_unsetenv("HERMES_HOME"); + return saved; +} + +static void client_detect_env_restore(client_detect_env_t *saved) { + restore_test_env("PATH", saved->path); + restore_test_env("CLAUDE_CONFIG_DIR", saved->claude_config_dir); + restore_test_env("OPENCODE_CONFIG", saved->opencode_config); + restore_test_env("OPENCODE_CONFIG_DIR", saved->opencode_config_dir); + restore_test_env("HERMES_HOME", saved->hermes_home); +} + +/* Fresh home plus an empty bin dir used as the whole PATH. */ +static bool client_detect_fixture(char *home, size_t home_sz, char *bin, size_t bin_sz) { + snprintf(home, home_sz, "/tmp/cli-client-detect-XXXXXX"); + if (!cbm_mkdtemp(home)) { + return false; + } + snprintf(bin, bin_sz, "%s/bin", home); + test_mkdirp(bin); + return true; +} + +/* #1180: a bare ~/.claude directory no longer counts as Claude Code. Fixtures + * that model a Claude Code user add the user config Claude Code itself writes + * (.claude.json in the home, or in CLAUDE_CONFIG_DIR when that is set). */ +static void test_mark_claude_code_installed(const char *user_root) { + char path[768]; + snprintf(path, sizeof(path), "%s/.claude.json", user_root); + write_test_file(path, "{}\n"); +} + +/* #1180: a user with only an (empty) ~/.claude folder and no Claude Code CLI + * was reported as "Detected agents: Claude-Code" and got hooks, skills and + * MCP entries written for a client they never installed. */ +TEST(cli_detect_claude_empty_dir_not_detected_issue1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char dir[512]; + snprintf(dir, sizeof(dir), "%s/.claude", home); + test_mkdirp(dir); + + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_detected_agents_t agents = cbm_detect_agents(home); + client_detect_env_restore(&saved); + test_rmdir_r(home); + if (agents.claude_code) + FAIL("an empty ~/.claude directory must not count as an installed Claude Code"); + PASS(); +} + +TEST(cli_detect_claude_empty_config_dir_env_not_detected_issue1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char ccd[512]; + snprintf(ccd, sizeof(ccd), "%s/custom-claude", home); + test_mkdirp(ccd); + + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_setenv("CLAUDE_CONFIG_DIR", ccd, 1); + cbm_detected_agents_t agents = cbm_detect_agents(home); + client_detect_env_restore(&saved); + test_rmdir_r(home); + if (agents.claude_code) + FAIL("an empty CLAUDE_CONFIG_DIR must not count as an installed Claude Code"); + PASS(); +} + +/* The evidence that DOES mean Claude Code is installed, one at a time: its + * settings file, its user config ~/.claude.json, or the claude CLI. */ +TEST(cli_detect_claude_real_install_detected_issue1180) { + enum { EVIDENCE_SETTINGS, EVIDENCE_USER_CONFIG, EVIDENCE_CLI, EVIDENCE_COUNT }; + static const char *const names[EVIDENCE_COUNT] = {"settings.json", ".claude.json", + "claude CLI"}; + for (int kind = 0; kind < EVIDENCE_COUNT; kind++) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char path[640]; + if (kind == EVIDENCE_SETTINGS) { + snprintf(path, sizeof(path), "%s/.claude", home); + test_mkdirp(path); + snprintf(path, sizeof(path), "%s/.claude/settings.json", home); + write_test_file(path, "{}\n"); + } else if (kind == EVIDENCE_USER_CONFIG) { + snprintf(path, sizeof(path), "%s/.claude.json", home); + write_test_file(path, "{}\n"); + } else { + snprintf(path, sizeof(path), "%s/claude", bin); + write_test_file(path, "#!/bin/sh\nexit 0\n"); + chmod(path, 0755); + } + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_detected_agents_t agents = cbm_detect_agents(home); + client_detect_env_restore(&saved); + test_rmdir_r(home); + if (!agents.claude_code) { + printf(" evidence: %s\n", names[kind]); + FAIL("real Claude Code evidence must be detected"); + } + } + PASS(); +} + +/* #1180: OpenCode keeps its data in ~/.local/share/opencode on every OS + * (opencode.ai/docs/troubleshooting, "Storage"; Windows: + * %USERPROFILE%\.local\share\opencode), and the Desktop app's bundled + * opencode-cli server creates it too. With no config file, no + * ~/.config/opencode and no opencode on PATH it is the remaining evidence. */ +TEST(cli_detect_opencode_desktop_storage_issue1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char dir[512]; + snprintf(dir, sizeof(dir), "%s/.local/share/opencode", home); + test_mkdirp(dir); + + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_detected_agents_t agents = cbm_detect_agents(home); + client_detect_env_restore(&saved); + test_rmdir_r(home); + if (!agents.opencode) + FAIL("OpenCode's data dir ~/.local/share/opencode must detect OpenCode"); + PASS(); +} + +/* #1180: native Windows Hermes (install.ps1 CLI, MSIX package, Desktop app) + * defaults HERMES_HOME to %LOCALAPPDATA%\hermes and reads a legacy ~/.hermes + * only while that directory is absent (hermes-agent windows-native.md "Data + * layout"; apps/desktop/electron/data-paths.mjs). cbm probed and wrote + * ~/.hermes only. Elsewhere the home stays ~/.hermes. */ +TEST(cli_hermes_home_windows_localappdata_issue1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char native[512]; + char legacy[512]; + snprintf(native, sizeof(native), "%s/AppData/Local/hermes", home); + snprintf(legacy, sizeof(legacy), "%s/.hermes", home); + + client_detect_env_t saved = client_detect_env_isolate(bin); + char fresh_windows[512]; + char fresh_posix[512]; + char legacy_windows[512]; + char both_windows[512]; + char both_posix[512]; + cbm_hermes_home_dir_for_testing(home, true, fresh_windows, sizeof(fresh_windows)); + cbm_hermes_home_dir_for_testing(home, false, fresh_posix, sizeof(fresh_posix)); + test_mkdirp(legacy); + cbm_hermes_home_dir_for_testing(home, true, legacy_windows, sizeof(legacy_windows)); + test_mkdirp(native); + cbm_hermes_home_dir_for_testing(home, true, both_windows, sizeof(both_windows)); + cbm_hermes_home_dir_for_testing(home, false, both_posix, sizeof(both_posix)); + client_detect_env_restore(&saved); + test_rmdir_r(home); + + bool ok = strcmp(fresh_windows, native) == 0 && strcmp(fresh_posix, legacy) == 0 && + strcmp(legacy_windows, legacy) == 0 && strcmp(both_windows, native) == 0 && + strcmp(both_posix, legacy) == 0; + if (!ok) { + printf(" fresh: windows=%s posix=%s\n legacy-only windows=%s\n" + " both: windows=%s posix=%s\n", + fresh_windows, fresh_posix, legacy_windows, both_windows, both_posix); + FAIL("Windows Hermes home must be AppData/Local/hermes unless only ~/.hermes exists"); + } + PASS(); +} + +/* #1180 end to end on the compiled-in platform: a Hermes Desktop home in + * AppData/Local/hermes is detected and receives config.yaml + the skill on + * Windows, and is not evidence anywhere else. */ +TEST(cli_detect_hermes_desktop_home_issue1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char dir[512]; + snprintf(dir, sizeof(dir), "%s/AppData/Local/hermes", home); + test_mkdirp(dir); + + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_detected_agents_t agents = cbm_detect_agents(home); + char *json = cbm_build_install_plan_json(home, "/usr/local/bin/codebase-memory-mcp"); + client_detect_env_restore(&saved); +#ifdef _WIN32 + bool ok = agents.hermes && json && strstr(json, "/AppData/Local/hermes/config.yaml") && + strstr(json, "/AppData/Local/hermes/skills/codebase-memory/SKILL.md") && + !strstr(json, "/.hermes/config.yaml"); +#else + bool ok = !agents.hermes && json && !strstr(json, "/AppData/Local/hermes/"); +#endif + free(json); + test_rmdir_r(home); + if (!ok) + FAIL("the Hermes home must follow the platform's documented default"); + PASS(); +} + +/* Control: look-alike neighbours of every new probe must not detect anything. */ +TEST(cli_detect_client_non_install_control_issue1167_1180) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + char path[640]; + snprintf(path, sizeof(path), "%s/.local/share/opencode-other", home); + test_mkdirp(path); + snprintf(path, sizeof(path), "%s/.claude-other", home); + test_mkdirp(path); + snprintf(path, sizeof(path), "%s/claude.json", home); + write_test_file(path, "{}\n"); + snprintf(path, sizeof(path), "%s/AppData/Local/hermes-other", home); + test_mkdirp(path); + + client_detect_env_t saved = client_detect_env_isolate(bin); + cbm_detected_agents_t agents = cbm_detect_agents(home); + client_detect_env_restore(&saved); + test_rmdir_r(home); + if (agents.claude_code || agents.opencode || agents.hermes) + FAIL("unrelated files next to the probed locations must not detect a client"); + PASS(); +} + TEST(cli_detect_agents_finds_codex) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX"); @@ -6774,6 +7032,7 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]); test_mkdirp(path); } + test_mark_claude_code_installed(tmpdir); snprintf(path, sizeof(path), "%s/mcp-config.json", copilot_home); write_test_file(path, "{}\n"); @@ -7917,6 +8176,7 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) { snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir); if (!cbm_mkdir_p(hooks_dir, 0755)) FAIL("mkdir hooks_dir failed"); + test_mark_claude_code_installed(tmpdir); /* A gate script that is NOT ours: a manual install pointing at another * binary. The real install refuses to rewrite it (TEXT_UNOWNED). */ char gate_path[768]; @@ -9081,6 +9341,7 @@ TEST(cli_devin_does_not_duplicate_owned_claude_session_start) { snprintf(claude_settings, sizeof(claude_settings), "%s/settings.json", claude_dir); snprintf(devin_config, sizeof(devin_config), "%s/config.json", devin_dir); test_mkdirp(claude_dir); + test_mark_claude_code_installed(tmpdir); test_mkdirp(devin_dir); write_test_file(devin_config, "{}\n"); @@ -10011,7 +10272,13 @@ TEST(cli_relative_kiro_and_hermes_homes_never_target_root) { char expected_kiro[512]; char expected_hermes[512]; snprintf(expected_kiro, sizeof(expected_kiro), "%s/.kiro/settings/mcp.json", tmpdir); +#ifdef _WIN32 + /* #1180: native Windows Hermes defaults to %LOCALAPPDATA%\hermes. */ + snprintf(expected_hermes, sizeof(expected_hermes), "%s/AppData/Local/hermes/config.yaml", + tmpdir); +#else snprintf(expected_hermes, sizeof(expected_hermes), "%s/.hermes/config.yaml", tmpdir); +#endif bool safe = json && strstr(json, expected_kiro) && strstr(json, expected_hermes) && !strstr(json, "\"/settings/mcp.json\"") && !strstr(json, "\"/config.yaml\""); @@ -10048,7 +10315,12 @@ TEST(cli_fresh_cli_only_yaml_and_toml_agents_create_parent_dirs) { cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); char path[768]; +#ifdef _WIN32 + /* #1180: native Windows Hermes defaults to %LOCALAPPDATA%\hermes. */ + snprintf(path, sizeof(path), "%s/AppData/Local/hermes/config.yaml", tmpdir); +#else snprintf(path, sizeof(path), "%s/.hermes/config.yaml", tmpdir); +#endif bool installed = test_file_contains_all( path, (const char *const[]){"mcp_servers:", "codebase-memory-mcp:"}, 2); #ifdef _WIN32 @@ -10756,6 +11028,7 @@ TEST(cli_uninstall_preserves_hook_script_with_modified_binary) { snprintf(script_path, sizeof(script_path), "%s/hooks/cbm-session-reminder", claude_dir); #endif test_mkdirp(claude_dir); + test_mark_claude_code_installed(tmpdir); char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); @@ -11098,6 +11371,7 @@ TEST(cli_claude_lifecycle_hooks_delegate_to_augmenter) { char config_dir[512]; snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); test_mkdirp(config_dir); + test_mark_claude_code_installed(tmpdir); char *saved_path = save_test_env("PATH"); char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR"); @@ -11751,6 +12025,7 @@ TEST(cli_claude_hook_scripts_shell_quote_binary_path) { char config_dir[512]; snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); test_mkdirp(config_dir); + test_mark_claude_code_installed(tmpdir); char copilot_dir[512]; snprintf(copilot_dir, sizeof(copilot_dir), "%s/.copilot", tmpdir); test_mkdirp(copilot_dir); @@ -11824,6 +12099,7 @@ TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir) { snprintf(config_dir, sizeof(config_dir), "%s/custom claude;$(touch cbm-hook-path-pwned)", tmpdir); test_mkdirp(config_dir); + test_mark_claude_code_installed(config_dir); char *saved_path = save_test_env("PATH"); char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR"); cbm_setenv("PATH", tmpdir, 1); @@ -12824,6 +13100,7 @@ TEST(cli_upgrade_migrates_released_claude_hook_scripts) { snprintf(subagent_path, sizeof(subagent_path), "%s/cbm-subagent-reminder", hooks_dir); snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", tmpdir); test_mkdirp(hooks_dir); + test_mark_claude_code_installed(tmpdir); char legacy_gate[8192]; ASSERT_TRUE(test_build_released_gate_hook_script("/opt/codebase-memory-mcp", legacy_gate, @@ -13121,6 +13398,7 @@ TEST(cli_uninstall_preserves_modified_claude_hook_script) { char config_dir[512]; snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); test_mkdirp(config_dir); + test_mark_claude_code_installed(tmpdir); char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); @@ -16410,6 +16688,13 @@ SUITE(cli) { /* Agent detection (6 tests — group A) */ RUN_TEST(cli_detect_agents_finds_claude); RUN_TEST(cli_detect_agents_finds_claude_via_env); + RUN_TEST(cli_detect_claude_empty_dir_not_detected_issue1180); + RUN_TEST(cli_detect_claude_empty_config_dir_env_not_detected_issue1180); + RUN_TEST(cli_detect_claude_real_install_detected_issue1180); + RUN_TEST(cli_detect_opencode_desktop_storage_issue1180); + RUN_TEST(cli_hermes_home_windows_localappdata_issue1180); + RUN_TEST(cli_detect_hermes_desktop_home_issue1180); + RUN_TEST(cli_detect_client_non_install_control_issue1167_1180); RUN_TEST(cli_detect_agents_finds_codex); RUN_TEST(cli_detect_agents_finds_grok); RUN_TEST(cli_detect_agents_finds_cursor_issue222); From 810d677d417d57d8df67f4b64e6f554af54219ac Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 06:45:39 +0200 Subject: [PATCH 2/3] test(smoke): follow the #1180 Hermes home and Claude detection The client-detection fix moved the Windows Hermes home to %LOCALAPPDATA%\hermes and stopped counting a bare ~/.claude directory as a Claude Code install. The smoke script followed only part of the way. Hermes: 8x and 8x-i read the new HERMES_HOME_DIR, but 8x-ii still looked for the pre_llm_call hook in ~/.hermes/config.yaml, and the uninstall absence checks 9m and 9n still named ~/.hermes. On Linux and macOS HERMES_HOME_DIR is ~/.hermes, so the stale paths pointed at the right directory and nothing showed. Only on Windows do the two differ: 8x-ii failed on a hook that install had written to the new home, and 9m and 9n passed without looking at any file install had produced. All three now use HERMES_HOME_DIR. The assertions are unchanged. Claude Code: the fixtures of 9b-2, 9b-3, 9b-8 and both Phase 13 legs created only an empty ~/.claude, which is no longer a Claude Code home. Wherever no claude executable is installed, 9b-2 then finds no .claude.json entry and 13e finds no agent config, and 9b-3, 9b-8 and the install.ps1 leg stop exercising the Claude paths without saying so. The Linux test container reproduces both failures. The Windows job never got that far, because 8x-ii stops it in Phase 8. The fixtures now seed the .claude.json user config through one helper, the same seed the unit tests use. 9b-1b pins the new rule end to end: where the empty-home control shows no Claude Code, a home with only a bare ~/.claude gets no Claude config. A venue that has a claude executable skips it, with the reason at the case. Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 70 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 477cc6ab32..7887c3146e 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -116,6 +116,16 @@ copy_smoke_binary() { cp "$BINARY" "$destination" } +# A fixture home that belongs to a Claude Code user. An empty ~/.claude is not +# evidence of one (#1180: other tools create that directory, and install then +# configured a client the user never had); the user config Claude Code itself +# writes is. Same seed as test_mark_claude_code_installed in tests/test_cli.c. +mark_claude_code_installed() { + local home="$1" + mkdir -p "$home/.claude" + echo '{}' > "$home/.claude.json" +} + # Retire this run's private account daemon (if one is running) and wait until # it reports not-running. Install/uninstall flows leave an ephemeral daemon # draining asynchronously whose mapped generation backing and open logs @@ -2149,10 +2159,10 @@ if ! grep -q '^name: codebase-memory$' "$HERMES_HOME_DIR/skills/codebase-memory/ exit 1 fi echo "OK 8x-i: Hermes durable delegation skill" -if ! grep -q '^hooks:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || - ! grep -q '^ pre_llm_call:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || - ! grep -q 'hook-augment' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || - ! grep -q -- '--dialect hermes' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null; then +if ! grep -q '^hooks:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || + ! grep -q '^ pre_llm_call:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || + ! grep -q 'hook-augment' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || + ! grep -q -- '--dialect hermes' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null; then echo "FAIL 8x-ii: Hermes pre_llm_call context hook missing" exit 1 fi @@ -3154,8 +3164,8 @@ fi echo "OK 9l: JSON agents, lifecycle hooks, and Kilo cleaned; foreign settings preserved" # 9m: YAML/TOML new agents are cleaned. -if grep -q '^ codebase-memory-mcp:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || - grep -q '^ pre_llm_call:' "$FAKE_HOME/.hermes/config.yaml" 2>/dev/null || +if grep -q '^ codebase-memory-mcp:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || + grep -q '^ pre_llm_call:' "$HERMES_HOME_DIR/config.yaml" 2>/dev/null || grep -q '^ codebase-memory-mcp:' "$GOOSE_CFG" 2>/dev/null || grep -q 'codebase-memory-mcp' "$FAKE_HOME/.grok/config.toml" 2>/dev/null || grep -q '^name = "codebase-memory-mcp"' "$FAKE_HOME/.vibe/config.toml" 2>/dev/null; then @@ -3209,7 +3219,7 @@ echo "OK 9m-i: durable instruction blocks removed" # 9n: Skills removed (consolidated skill dir) if [ -d "$FAKE_HOME/.claude/skills/codebase-memory" ] || - [ -d "$FAKE_HOME/.hermes/skills/codebase-memory" ] || + [ -d "$HERMES_HOME_DIR/skills/codebase-memory" ] || [ -d "$FAKE_HOME/.agents/skills/codebase-memory" ] || [ -d "$FAKE_HOME/.qoder/skills/codebase-memory" ] || [ -d "$CUSTOM_KIMI_HOME/skills/codebase-memory" ] || @@ -3287,9 +3297,40 @@ echo "OK 9b-1: install with minimal agents exits cleanly" retire_account_daemon "9b-1-cleanup" smoke_rmtree "$EMPTY_HOME" +# 9b-1b (#1180): an empty ~/.claude directory is not a Claude Code install. +# The empty home above is this venue's control: where it showed no Claude Code, +# the same home plus a bare ~/.claude must not be configured for it either. +# +# SKIP_WHITELIST: a venue with a claude executable (a developer machine) detects +# Claude Code in every home, so there is nothing for the bare directory to +# change. Narrowing PATH for this one install does not make such a venue +# claude-free: with HOME as the scanned home the product also probes fixed +# locations (/usr/local/bin, /opt/homebrew/bin). A venue without one, such as +# the Linux test image, runs the check. +if grep -q 'Detected agents:.* Claude-Code' <<<"$INSTALL_OUT"; then + echo "SKIP 9b-1b: a claude executable is installed in this venue (whitelisted above)" +else + BARE_HOME=$(smoke_mktemp_dir) + mkdir -p "$BARE_HOME/.claude" "$BARE_HOME/.local/bin" + BARE_RC=0 + BARE_OUT=$(HOME="$BARE_HOME" LOCALAPPDATA="$BARE_HOME/AppData/Local" "$BINARY" install -y 2>&1) || BARE_RC=$? + if [ "$BARE_RC" -ge 128 ] || + ! grep -q 'Detected agents:' <<<"$BARE_OUT" || + grep -q 'Detected agents:.* Claude-Code' <<<"$BARE_OUT" || + [ -e "$BARE_HOME/.claude.json" ] || + [ -n "$(ls -A "$BARE_HOME/.claude" 2>/dev/null)" ]; then + echo "FAIL 9b-1b: a bare ~/.claude directory was configured as a Claude Code install" + exit 1 + fi + echo "OK 9b-1b: a bare ~/.claude directory is not configured as Claude Code" + retire_account_daemon "9b-1b-cleanup" + smoke_rmtree "$BARE_HOME" +fi + # 9b-2: Install twice (idempotent) IDEM_HOME=$(smoke_mktemp_dir) -mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin" +mkdir -p "$IDEM_HOME/.local/bin" +mark_claude_code_installed "$IDEM_HOME" copy_smoke_binary "$IDEM_HOME/.local/bin/codebase-memory-mcp" run_no_crash 9b-2 env HOME="$IDEM_HOME" LOCALAPPDATA="$IDEM_HOME/AppData/Local" "$BINARY" install -y IDEM_INSTALLER="$BINARY" @@ -3313,7 +3354,8 @@ smoke_rmtree "$IDEM_HOME" # 9b-3: Uninstall without prior install CLEAN_HOME=$(smoke_mktemp_dir) -mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin" +mkdir -p "$CLEAN_HOME/.local/bin" +mark_claude_code_installed "$CLEAN_HOME" UNINSTALL_RC=0 UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || UNINSTALL_RC=$? if [ "$UNINSTALL_RC" -ge 128 ]; then @@ -3337,7 +3379,8 @@ smoke_rmtree "$CORRUPT_HOME" # 9b-8: Double uninstall DBL_HOME=$(smoke_mktemp_dir) -mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin" +mkdir -p "$DBL_HOME/.local/bin" +mark_claude_code_installed "$DBL_HOME" copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp" run_no_crash 9b-8-install env HOME="$DBL_HOME" "$BINARY" install -y DBL_UNINSTALLER="$BINARY" @@ -3834,7 +3877,7 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then echo "--- Phase 13: install.sh E2E ---" INSTALL_TEST_HOME=$(smoke_mktemp_dir) INSTALL_TEST_DIR=$(smoke_mktemp_dir) - mkdir -p "$INSTALL_TEST_HOME/.claude" + mark_claude_code_installed "$INSTALL_TEST_HOME" mkdir -p "$INSTALL_TEST_HOME/.local/bin" # 13a: run install.sh with local URL + isolated HOME @@ -3871,7 +3914,8 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then echo "OK 13d: no signing needed ($DL_OS)" fi - # 13e: agent configs created (at least Claude Code since we made ~/.claude) + # 13e: agent configs created (at least Claude Code: the fixture home is a + # Claude Code user's) if [ -f "$INSTALL_TEST_HOME/.claude.json" ] && grep -q 'codebase-memory-mcp' "$INSTALL_TEST_HOME/.claude.json" 2>/dev/null; then echo "OK 13e: agent configs created by install.sh" else @@ -3898,7 +3942,7 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t echo "--- Phase 13: install.ps1 E2E (Windows) ---" PS1_TEST_HOME=$(smoke_mktemp_dir) PS1_TEST_DIR=$(smoke_mktemp_dir) - mkdir -p "$PS1_TEST_HOME/.claude" + mark_claude_code_installed "$PS1_TEST_HOME" # Convert MSYS paths to Windows paths for PowerShell if command -v cygpath &>/dev/null; then From d13c0ecb732be920839b5020f71172375d7b8100 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 00:21:00 +0200 Subject: [PATCH 3/3] test(cli): mark Claude Code installations in hook fixtures Signed-off-by: Martin Vogel --- tests/test_cli.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_cli.c b/tests/test_cli.c index 60c23b8a30..507f61c87b 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9608,6 +9608,7 @@ TEST(cli_claude_user_scope_avoids_nested_mcp_json) { char dir[512]; snprintf(dir, sizeof(dir), "%s/.claude", tmpdir); test_mkdirp(dir); + test_mark_claude_code_installed(tmpdir); char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp"); bool has_user_config = json && strstr(json, "/.claude.json") != NULL; @@ -13171,6 +13172,7 @@ TEST(cli_upgrade_preserves_near_legacy_claude_hook_script) { snprintf(gate_path, sizeof(gate_path), "%s/cbm-code-discovery-gate", hooks_dir); snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", tmpdir); test_mkdirp(hooks_dir); + test_mark_claude_code_installed(tmpdir); const char *modified_legacy = "#!/usr/bin/env bash\n" "# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n" @@ -13262,6 +13264,7 @@ TEST(cli_claude_hook_script_collisions_are_not_registered) { snprintf(session, sizeof(session), "%s/cbm-session-reminder", hooks_dir); snprintf(settings, sizeof(settings), "%s/.claude/settings.json", tmpdir); test_mkdirp(hooks_dir); + test_mark_claude_code_installed(tmpdir); write_test_file(victim, "victim-owned\n"); ASSERT_EQ(symlink(victim, gate), 0); write_test_file(session, "#!/bin/sh\necho user-owned\n"); @@ -13328,6 +13331,7 @@ TEST(cli_uninstall_removes_claude_hook_scripts) { char config_dir[512]; snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir); test_mkdirp(config_dir); + test_mark_claude_code_installed(tmpdir); char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); @@ -14504,6 +14508,7 @@ TEST(cli_windows_claude_hook_scripts_migrate_and_uninstall_all_owned_shapes) { snprintf(appdata, sizeof(appdata), "%s/AppData/Roaming", tmpdir); snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir); test_mkdirp(hooks_dir); + test_mark_claude_code_installed(config_dir); const char *const env_names[] = {"HOME", "PATH", "CLAUDE_CONFIG_DIR", "APPDATA", "CODEX_HOME", "OPENCODE_CONFIG",