Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 70 additions & 20 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2126,27 +2136,33 @@ 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
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
Expand Down Expand Up @@ -3148,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
Expand Down Expand Up @@ -3203,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" ] ||
Expand Down Expand Up @@ -3281,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"
Expand All @@ -3307,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
Expand All @@ -3331,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"
Expand Down Expand Up @@ -3828,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
Expand Down Expand Up @@ -3865,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
Expand All @@ -3892,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
Expand Down
96 changes: 81 additions & 15 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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));
Expand All @@ -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);
Expand All @@ -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);

Expand Down
13 changes: 10 additions & 3 deletions src/cli/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 */
Expand All @@ -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. */
Expand Down
Loading
Loading