Files
codebase-memory-mcp/tests/test_cli.c
T
Andrew Hundt 45c11176c9 merge(upstream): retain streamlined APIs with indexed impact analysis
Merge upstream/main 7d6cdb23 into api-consolidation 0fcb7e10 as a tested capability superset.

Retain the branch's streamlined, classic, and hidden-reveal MCP surfaces; JSON/TOON output; read-only Cypher execution; semantic search; dependency indexing; PageRank; incremental indexing; and modular harness installer. Integrate upstream ObjectScript routine/UDL extraction, macro and return-type call resolution, Python alias and C++ header resolution, atomic SQLite publication, SCC/cycle reporting, Cypher deadlines, and bounded multi-source impact traversal.

Fix merge-specific defects in handle_detect_changes project validation, cbm_pipeline_release_objectscript_tables ownership, no-sanitizer test defines, focused-suite registration, portable config publication, and installed streamlined guidance. The guidance keeps auto-index/get_code/query_graph discovery while adding coverage tiers and removes the stale fixed row ceiling.

Verification: ASan/UBSan full runner 7,155 passed with 27 sandbox-loopback cases separately passing in the escalated HTTP suite (44 passed, 1 Windows-only skip); TSan 449 passed; incremental leak gate 514 passed with 0 leaks; MCP 261; CLI 232; extraction 313; tool consolidation 113; Cypher 179; SQLite writer 16; configuration editors 127; integration 28; language contracts 38; PageRank 60. scripts/check-source-safety.sh and git diff --cached --check pass.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
2026-07-19 14:09:36 -04:00

7288 lines
282 KiB
C

/*
* test_cli.c — Tests for CLI subcommands: install, uninstall, update, version.
*
* Port of Go test files:
* - cmd/codebase-memory-mcp/cli_test.go (11 tests)
* - cmd/codebase-memory-mcp/install_test.go (24 tests)
* - cmd/codebase-memory-mcp/update_test.go (5 tests)
* - internal/selfupdate/selfupdate_test.go (7 tests)
*
* Total: 47 Go tests → 47 C tests
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/constants.h"
#include "test_framework.h"
#include "test_helpers.h"
#include <cli/cli.h>
#include <depindex/depindex.h>
#include <mcp/mcp.h>
#include <foundation/yaml.h>
#include <pagerank/pagerank.h>
#include <pipeline/pipeline.h>
#include <store/store.h>
#include <yyjson/yyjson.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
#ifndef _WIN32
#include <sys/wait.h>
#endif
#include <errno.h>
#include <zlib.h>
/* Helper: create a file with content */
static int write_test_file(const char *path, const char *content) {
FILE *f = cbm_fopen(path, "wb");
if (!f)
return -1;
fprintf(f, "%s", content);
fclose(f);
return 0;
}
/* Helper: read a file into static buffer */
static const char *read_test_file(const char *path) {
static char buf[8192];
FILE *f = fopen(path, "r");
if (!f)
return NULL;
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
return buf;
}
static char *read_test_file_alloc(const char *path) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
if (fseek(f, 0, SEEK_END) != 0) {
fclose(f);
return NULL;
}
long len = ftell(f);
if (len < 0 || fseek(f, 0, SEEK_SET) != 0) {
fclose(f);
return NULL;
}
char *buf = malloc((size_t)len + 1U);
if (!buf) {
fclose(f);
return NULL;
}
size_t read_len = fread(buf, 1, (size_t)len, f);
fclose(f);
if (read_len != (size_t)len) {
free(buf);
return NULL;
}
buf[read_len] = '\0';
return buf;
}
static char *save_test_env(const char *name) {
const char *value = getenv(name);
return value ? cbm_strndup(value, strlen(value)) : NULL;
}
static void restore_test_env(const char *name, char *saved) {
if (saved) {
cbm_setenv(name, saved, 1);
free(saved);
} else {
cbm_unsetenv(name);
}
}
static size_t count_substr(const char *s, const char *needle) {
size_t count = 0;
if (!s || !needle) {
return 0;
}
size_t needle_len = strlen(needle);
if (needle_len == 0) {
return 0;
}
while ((s = strstr(s, needle)) != NULL) {
count++;
s += needle_len;
}
return count;
}
#define test_count_substring count_substr
typedef struct {
const char *name;
char *value;
} cli_env_snapshot_t;
static bool cli_env_snapshot(cli_env_snapshot_t *snap, const char *name) {
const char *value = getenv(name);
snap->name = name;
snap->value = value ? cbm_strndup(value, strlen(value)) : NULL;
return !value || snap->value;
}
static void cli_env_restore(cli_env_snapshot_t *snap) {
if (!snap->name) {
return;
}
if (snap->value) {
cbm_setenv(snap->name, snap->value, 1);
free(snap->value);
snap->value = NULL;
} else {
cbm_unsetenv(snap->name);
}
}
static int cli_run_help_without_home(int (*cmd)(int, char **)) {
cli_env_snapshot_t home = {0};
cli_env_snapshot_t userprofile = {0};
if (!cli_env_snapshot(&home, "HOME") || !cli_env_snapshot(&userprofile, "USERPROFILE")) {
cli_env_restore(&userprofile);
cli_env_restore(&home);
return -1;
}
cbm_unsetenv("HOME");
cbm_unsetenv("USERPROFILE");
char *args[] = {"--help"};
int rc = cmd(1, args);
cli_env_restore(&userprofile);
cli_env_restore(&home);
return rc;
}
/* Helper: mkdirp */
static int test_mkdirp(const char *path) {
char tmp[1024];
snprintf(tmp, sizeof(tmp), "%s", path);
for (char *p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = '\0';
cbm_mkdir(tmp);
*p = '/';
}
}
return cbm_mkdir(tmp) == 0 || errno == EEXIST ? 0 : -1;
}
static char *make_overlong_nested_path(const char *base, const char *leaf) {
size_t cap = (size_t)CBM_PATH_MAX * 2;
char *path = malloc(cap);
if (!path) {
return NULL;
}
int n = snprintf(path, cap, "%s", base);
if (n < 0 || (size_t)n >= cap) {
free(path);
return NULL;
}
size_t used = (size_t)n;
const char *segment = "/a";
size_t segment_len = strlen(segment);
while (used <= (size_t)CBM_PATH_MAX) {
if (used + segment_len >= cap) {
free(path);
return NULL;
}
memcpy(path + used, segment, segment_len + 1);
used += segment_len;
}
size_t leaf_len = strlen(leaf);
const char *separator = "/";
size_t separator_len = strlen(separator);
if (used + separator_len + leaf_len >= cap) {
free(path);
return NULL;
}
memcpy(path + used, separator, separator_len + 1);
used += separator_len;
if (leaf_len > 0) {
memcpy(path + used, leaf, leaf_len + 1);
} else {
path[used] = '\0';
}
return path;
}
static int test_path_exists(const char *path) {
struct stat st;
return stat(path, &st) == 0;
}
/* Helper: recursive remove */
static void test_rmdir_r(const char *path) {
th_rmtree(path);
}
/* Helper: create tar.gz with a single file */
static unsigned char *create_test_targz(const char *filename, const unsigned char *content,
int content_len, int *out_len) {
/* Build tar data: 512-byte header + content padded to 512-byte boundary + 2x512 zero blocks */
int data_blocks = (content_len + 511) / 512;
int tar_size = 512 + data_blocks * 512 + 1024; /* header + data + end-of-archive */
unsigned char *tar = calloc(1, (size_t)tar_size);
if (!tar)
return NULL;
/* Filename (bytes 0-99) */
strncpy((char *)tar, filename, 99);
/* Mode (bytes 100-107): octal 0700 */
memcpy(tar + 100, "0000700\0", 8);
/* UID/GID (bytes 108-123): 0 */
memcpy(tar + 108, "0000000\0", 8);
memcpy(tar + 116, "0000000\0", 8);
/* Size (bytes 124-135): octal */
char size_str[12];
snprintf(size_str, sizeof(size_str), "%011o", content_len);
memcpy(tar + 124, size_str, 11);
/* Mtime (bytes 136-147): 0 */
memcpy(tar + 136, "00000000000\0", 12);
/* Type flag (byte 156): '0' = regular file */
tar[156] = '0';
/* Checksum (bytes 148-155): compute over header with checksum field as spaces */
memset(tar + 148, ' ', 8);
unsigned int checksum = 0;
for (int i = 0; i < 512; i++)
checksum += tar[i];
snprintf((char *)tar + 148, 7, "%06o", checksum);
tar[154] = '\0';
/* File content */
memcpy(tar + 512, content, (size_t)content_len);
/* Compress with gzip */
z_stream strm = {0};
if (deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8,
Z_DEFAULT_STRATEGY) != Z_OK) {
free(tar);
return NULL;
}
size_t gz_cap = (size_t)tar_size + 256;
unsigned char *gz = malloc(gz_cap);
if (!gz) {
deflateEnd(&strm);
free(tar);
return NULL;
}
strm.next_in = tar;
strm.avail_in = (unsigned int)tar_size;
strm.next_out = gz;
strm.avail_out = (unsigned int)gz_cap;
deflate(&strm, Z_FINISH);
*out_len = (int)(gz_cap - strm.avail_out);
deflateEnd(&strm);
free(tar);
return gz;
}
/* ═══════════════════════════════════════════════════════════════════
* Version comparison tests (port of selfupdate_test.go)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_compare_versions) {
/* Port of TestCompareVersions — 13 cases */
ASSERT(cbm_compare_versions("0.2.1", "0.2.0") > 0);
ASSERT_EQ(cbm_compare_versions("0.2.0", "0.2.0"), 0);
ASSERT(cbm_compare_versions("0.1.9", "0.2.0") < 0);
ASSERT(cbm_compare_versions("0.10.0", "0.2.0") > 0);
ASSERT(cbm_compare_versions("1.0.0", "0.99.99") > 0);
ASSERT(cbm_compare_versions("0.0.1", "0.0.2") < 0);
ASSERT_EQ(cbm_compare_versions("v0.2.1", "0.2.1"), 0);
ASSERT_EQ(cbm_compare_versions("0.2.1", "v0.2.1"), 0);
ASSERT(cbm_compare_versions("0.2.1-dev", "0.2.1") < 0);
ASSERT(cbm_compare_versions("0.2.1", "0.2.1-dev") > 0);
ASSERT_EQ(cbm_compare_versions("0.2.1-dev", "0.2.1-dev"), 0);
ASSERT(cbm_compare_versions("0.3.0", "0.2.1-dev") > 0);
ASSERT(cbm_compare_versions("0.2.0", "0.2.1-dev") < 0);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Version get/set (port of TestCLI_Version)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_version_get_set) {
cbm_cli_set_version("1.2.3");
ASSERT_STR_EQ(cbm_cli_get_version(), "1.2.3");
cbm_cli_set_version("dev");
ASSERT_STR_EQ(cbm_cli_get_version(), "dev");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Shell RC detection (port of TestDetectShellRC + BashWithBashrc)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_detect_shell_rc_zsh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-rc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
/* Save and override SHELL — must strdup because setenv may realloc env block */
const char *raw = getenv("SHELL");
char *old_shell = raw ? strdup(raw) : NULL;
cbm_setenv("SHELL", "/bin/zsh", 1);
const char *rc = cbm_detect_shell_rc(tmpdir);
ASSERT_NOT_NULL(rc);
ASSERT(strstr(rc, ".zshrc") != NULL);
if (old_shell) {
cbm_setenv("SHELL", old_shell, 1);
free(old_shell);
} else
cbm_unsetenv("SHELL");
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_detect_shell_rc_bash) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-rc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *raw = getenv("SHELL");
char *old_shell = raw ? strdup(raw) : NULL;
cbm_setenv("SHELL", "/bin/bash", 1);
/* No .bashrc → falls back to .bash_profile */
const char *rc = cbm_detect_shell_rc(tmpdir);
ASSERT_NOT_NULL(rc);
ASSERT(strstr(rc, ".bash_profile") != NULL);
if (old_shell) {
cbm_setenv("SHELL", old_shell, 1);
free(old_shell);
} else
cbm_unsetenv("SHELL");
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_detect_shell_rc_bash_with_bashrc) {
/* Port of TestDetectShellRC_BashWithBashrc */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-rc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *raw = getenv("SHELL");
char *old_shell = raw ? strdup(raw) : NULL;
cbm_setenv("SHELL", "/bin/bash", 1);
/* Create .bashrc */
char bashrc[512];
snprintf(bashrc, sizeof(bashrc), "%s/.bashrc", tmpdir);
write_test_file(bashrc, "# test\n");
const char *rc = cbm_detect_shell_rc(tmpdir);
ASSERT_STR_EQ(rc, bashrc);
cbm_unlink(bashrc);
if (old_shell) {
cbm_setenv("SHELL", old_shell, 1);
free(old_shell);
} else
cbm_unsetenv("SHELL");
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_detect_shell_rc_fish) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-rc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *raw = getenv("SHELL");
char *old_shell = raw ? strdup(raw) : NULL;
cbm_setenv("SHELL", "/usr/bin/fish", 1);
const char *rc = cbm_detect_shell_rc(tmpdir);
ASSERT(strstr(rc, ".config/fish/config.fish") != NULL);
if (old_shell) {
cbm_setenv("SHELL", old_shell, 1);
free(old_shell);
} else
cbm_unsetenv("SHELL");
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_detect_shell_rc_default) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-rc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *raw = getenv("SHELL");
char *old_shell = raw ? strdup(raw) : NULL;
cbm_setenv("SHELL", "/bin/sh", 1);
const char *rc = cbm_detect_shell_rc(tmpdir);
ASSERT(strstr(rc, ".profile") != NULL);
if (old_shell) {
cbm_setenv("SHELL", old_shell, 1);
free(old_shell);
} else
cbm_unsetenv("SHELL");
cbm_rmdir(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* CLI binary detection (port of TestFindCLI_*)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_find_cli_not_found) {
/* Port of TestFindCLI_NotFound */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-find-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *raw = getenv("PATH");
char *old_path = raw ? strdup(raw) : NULL;
cbm_setenv("PATH", tmpdir, 1);
const char *result = cbm_find_cli("nonexistent-binary-xyz", tmpdir);
ASSERT_STR_EQ(result, "");
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
}
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_find_cli_on_path) {
/* Port of TestFindCLI_FoundOnPATH */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-find-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char fakecli[512];
snprintf(fakecli, sizeof(fakecli), "%s/fakecli", tmpdir);
write_test_file(fakecli, "#!/bin/sh\n");
th_make_executable(fakecli);
#ifdef _WIN32
cbm_rmdir(tmpdir);
SKIP_PLATFORM("Windows: PATH-based CLI lookup uses POSIX semantics");
#endif
const char *raw = getenv("PATH");
char *old_path = raw ? strdup(raw) : NULL;
cbm_setenv("PATH", tmpdir, 1);
const char *result = cbm_find_cli("fakecli", tmpdir);
ASSERT(result[0] != '\0');
ASSERT(strstr(result, "fakecli") != NULL);
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
}
cbm_unlink(fakecli);
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_find_cli_fallback_paths) {
/* Port of TestFindCLI_FallbackPaths */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-find-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
#ifdef _WIN32
cbm_rmdir(tmpdir);
SKIP_PLATFORM("Windows: fallback path lookup uses POSIX semantics");
#endif
char localbin[512];
snprintf(localbin, sizeof(localbin), "%s/.local/bin", tmpdir);
test_mkdirp(localbin);
char fakecli[512];
snprintf(fakecli, sizeof(fakecli), "%s/testcli", localbin);
write_test_file(fakecli, "#!/bin/sh\n");
th_make_executable(fakecli);
const char *raw = getenv("PATH");
char *old_path = raw ? strdup(raw) : NULL;
cbm_setenv("PATH", "/nonexistent", 1);
const char *result = cbm_find_cli("testcli", tmpdir);
ASSERT_STR_EQ(result, fakecli);
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_find_cli_fallback_scans_cargo_bin) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-find-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
#ifdef _WIN32
cbm_rmdir(tmpdir);
SKIP_PLATFORM("Windows: fallback path lookup uses POSIX semantics");
#endif
char cargobin[512];
snprintf(cargobin, sizeof(cargobin), "%s/.cargo/bin", tmpdir);
test_mkdirp(cargobin);
char fakecli[512];
snprintf(fakecli, sizeof(fakecli), "%s/testcargo", cargobin);
write_test_file(fakecli, "#!/bin/sh\n");
th_make_executable(fakecli);
const char *raw = getenv("PATH");
char *old_path = raw ? strdup(raw) : NULL;
cbm_setenv("PATH", "/nonexistent", 1);
const char *result = cbm_find_cli("testcargo", tmpdir);
ASSERT_STR_EQ(result, fakecli);
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
}
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Dry-run flag parsing (port of TestDryRun)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_dry_run_flags) {
/* Port of TestDryRun — just verifies the pattern */
bool dry_run = false, force = false;
const char *args[] = {"--dry-run", "--force"};
for (int i = 0; i < 2; i++) {
if (strcmp(args[i], "--dry-run") == 0)
dry_run = true;
if (strcmp(args[i], "--force") == 0)
force = true;
}
ASSERT_TRUE(dry_run);
ASSERT_TRUE(force);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Skill file management tests (port of install_test.go skill tests)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_skill_creation) {
/* Port of TestInstallSkillCreation */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
int written = cbm_install_skills(skills_dir, false, false);
ASSERT_EQ(written, CBM_SKILL_COUNT);
/* Verify all 4 skills exist and have content */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
const char *data = read_test_file(path);
ASSERT_NOT_NULL(data);
ASSERT(strlen(data) > 0);
/* Check YAML frontmatter */
ASSERT(strncmp(data, "---\n", 4) == 0);
/* Check name field */
ASSERT(strstr(data, sk[i].name) != NULL);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_skill_idempotent) {
/* Port of TestInstallIdempotent */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
/* Install twice */
cbm_install_skills(skills_dir, false, false);
int second = cbm_install_skills(skills_dir, false, false);
/* Second install should write 0 (skills exist, no force) */
ASSERT_EQ(second, 0);
/* All skills should still exist */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
struct stat st;
ASSERT_EQ(stat(path, &st), 0);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_skill_force_overwrite) {
/* Port of TestCLI_InstallForceOverwrites */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
cbm_install_skills(skills_dir, false, false);
int force_count = cbm_install_skills(skills_dir, true, false);
/* Force should overwrite all */
ASSERT_EQ(force_count, CBM_SKILL_COUNT);
test_rmdir_r(tmpdir);
PASS();
}
#ifndef _WIN32
TEST(cli_skills_reject_symlink_and_preserve_unowned_content) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-safety-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
char target_dir[512];
char target_file[640];
char skill_path[640];
char skill_file[768];
snprintf(skills_dir, sizeof(skills_dir), "%s/skills", tmpdir);
snprintf(target_dir, sizeof(target_dir), "%s/user-target", tmpdir);
snprintf(target_file, sizeof(target_file), "%s/SKILL.md", target_dir);
snprintf(skill_path, sizeof(skill_path), "%s/codebase-memory", skills_dir);
snprintf(skill_file, sizeof(skill_file), "%s/SKILL.md", skill_path);
test_mkdirp(skills_dir);
test_mkdirp(target_dir);
const char *sentinel = "user-owned sentinel\n";
write_test_file(target_file, sentinel);
ASSERT_EQ(symlink(target_dir, skill_path), 0);
int installed = cbm_install_skills(skills_dir, true, false);
char *after_install = read_test_file_alloc(target_file);
bool install_safe = installed == 0 && after_install && strcmp(after_install, sentinel) == 0;
free(after_install);
/* Restore the target in case the red implementation followed the link, so
* uninstall behavior is independently observable. */
write_test_file(target_file, sentinel);
int removed_link = cbm_remove_skills(skills_dir, false);
char *after_remove = read_test_file_alloc(target_file);
bool remove_safe = removed_link == 0 && after_remove && strcmp(after_remove, sentinel) == 0;
free(after_remove);
(void)cbm_unlink(skill_path);
test_mkdirp(skill_path);
write_test_file(skill_file, sentinel);
int skipped = cbm_install_skills(skills_dir, false, false);
int removed_user = cbm_remove_skills(skills_dir, false);
char *user_after = read_test_file_alloc(skill_file);
bool preserves_skipped =
skipped == 0 && removed_user == 0 && user_after && strcmp(user_after, sentinel) == 0;
free(user_after);
test_rmdir_r(tmpdir);
if (!install_safe || !remove_safe || !preserves_skipped)
FAIL("skill install/uninstall must reject links and preserve user-owned content");
PASS();
}
TEST(cli_legacy_skill_cleanup_rejects_links_and_user_content) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-legacy-skill-safety-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
char target_dir[512];
char target_file[640];
char legacy_link[640];
snprintf(skills_dir, sizeof(skills_dir), "%s/skills", tmpdir);
snprintf(target_dir, sizeof(target_dir), "%s/user-target", tmpdir);
snprintf(target_file, sizeof(target_file), "%s/sentinel.txt", target_dir);
snprintf(legacy_link, sizeof(legacy_link), "%s/codebase-memory-exploring", skills_dir);
test_mkdirp(skills_dir);
test_mkdirp(target_dir);
const char *sentinel = "user-owned legacy target\n";
write_test_file(target_file, sentinel);
ASSERT_EQ(symlink(target_dir, legacy_link), 0);
(void)cbm_install_skills(skills_dir, false, false);
char *after_install = read_test_file_alloc(target_file);
bool install_link_safe = after_install && strcmp(after_install, sentinel) == 0;
free(after_install);
(void)cbm_unlink(legacy_link);
char old_dir[640];
char old_file[768];
snprintf(old_dir, sizeof(old_dir), "%s/codebase-memory-tracing", skills_dir);
snprintf(old_file, sizeof(old_file), "%s/user-notes.md", old_dir);
test_mkdirp(old_dir);
write_test_file(old_file, sentinel);
(void)cbm_install_skills(skills_dir, false, false);
char *after_directory_cleanup = read_test_file_alloc(old_file);
bool user_directory_safe =
after_directory_cleanup && strcmp(after_directory_cleanup, sentinel) == 0;
free(after_directory_cleanup);
char monolithic_link[640];
snprintf(monolithic_link, sizeof(monolithic_link), "%s/codebase-memory-mcp", skills_dir);
ASSERT_EQ(symlink(target_dir, monolithic_link), 0);
bool reported_removed = cbm_remove_old_monolithic_skill(skills_dir, false);
char *after_remove = read_test_file_alloc(target_file);
bool remove_link_safe =
!reported_removed && after_remove && strcmp(after_remove, sentinel) == 0;
free(after_remove);
test_rmdir_r(tmpdir);
if (!install_link_safe || !user_directory_safe || !remove_link_safe)
FAIL("legacy skill cleanup must not follow links or delete unowned content");
PASS();
}
#endif
TEST(cli_uninstall_removes_skills) {
/* Port of TestUninstallRemovesSkills */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
cbm_install_skills(skills_dir, false, false);
int removed = cbm_remove_skills(skills_dir, false);
ASSERT_EQ(removed, CBM_SKILL_COUNT);
/* Verify all removed */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s", skills_dir, sk[i].name);
struct stat st;
ASSERT(stat(path, &st) != 0);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_old_monolithic_skill) {
/* Port of TestRemoveOldMonolithicSkill */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-skill-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
/* Only an empty legacy directory is safe to remove automatically. */
char old_dir[1024];
snprintf(old_dir, sizeof(old_dir), "%s/codebase-memory-mcp", skills_dir);
test_mkdirp(old_dir);
bool removed = cbm_remove_old_monolithic_skill(skills_dir, false);
ASSERT_TRUE(removed);
struct stat st;
ASSERT(stat(old_dir, &st) != 0);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_skill_files_content) {
/* Consolidated skill: all 4 former skills merged into one. */
const cbm_skill_t *sk = cbm_get_skills();
ASSERT_EQ(CBM_SKILL_COUNT, 1);
ASSERT(strcmp(sk[0].name, "codebase-memory") == 0);
/* Exploring capabilities */
ASSERT(strstr(sk[0].content, "search_graph") != NULL);
ASSERT(strstr(sk[0].content, "get_graph_schema") != NULL);
/* Tracing capabilities */
ASSERT(strstr(sk[0].content, "trace_path") != NULL);
ASSERT(strstr(sk[0].content, "direction") != NULL);
ASSERT(strstr(sk[0].content, "detect_changes") != NULL);
/* Quality capabilities */
ASSERT(strstr(sk[0].content, "max_degree=0") != NULL);
ASSERT(strstr(sk[0].content, "exclude_entry_points") != NULL);
/* Reference capabilities */
ASSERT(strstr(sk[0].content, "query_graph") != NULL);
ASSERT(strstr(sk[0].content, "Cypher") != NULL);
ASSERT(strstr(sk[0].content, "MCP Tools") != NULL);
ASSERT(strstr(sk[0].content, "index_dependencies") != NULL);
ASSERT(strstr(sk[0].content, "auto_index=true") != NULL);
ASSERT(strstr(sk[0].content, "_hidden_tools") != NULL);
ASSERT(strstr(sk[0].content, "problem-specific Cypher") != NULL);
ASSERT(strstr(sk[0].content, "query_max_output_bytes") != NULL);
ASSERT(strstr(sk[0].content, "## 15 MCP Tools") == NULL);
/* Gotchas section */
ASSERT(strstr(sk[0].content, "Gotchas") != NULL);
PASS();
}
TEST(cli_codex_instructions) {
/* Port of TestCodexInstructionsCreation */
const char *instr = cbm_get_codex_instructions();
ASSERT_NOT_NULL(instr);
ASSERT(strstr(instr, "Codebase Knowledge Graph") != NULL);
ASSERT(strstr(instr, "trace_path") != NULL);
ASSERT(strstr(instr, "effective, computationally efficient") != NULL);
ASSERT(strstr(instr, "examples and LIMIT are optional guidance") != NULL);
ASSERT(strstr(instr, "retry that operation with escalation") != NULL);
ASSERT(strstr(instr, "MCP approval and shell sandbox authorization are separate") != NULL);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Editor MCP config tests (Cursor/Windsurf/Gemini)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_editor_mcp_install) {
/* Port of TestEditorMCPInstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.cursor/mcp.json", tmpdir);
int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "mcpServers") != NULL);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_editor_mcp_idempotent) {
/* Port of TestEditorMCPInstallIdempotent */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.cursor/mcp.json", tmpdir);
cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
/* Should still parse as valid JSON with only 1 server */
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* Count occurrences of "codebase-memory-mcp" (should be exactly 1 in mcpServers) */
int count = 0;
const char *p = data;
while ((p = strstr(p, "\"codebase-memory-mcp\"")) != NULL) {
count++;
p += 20;
}
/* The key appears once as key name */
ASSERT_EQ(count, 1);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_editor_mcp_preserves_others) {
/* Port of TestEditorMCPPreservesOtherServers */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.cursor/mcp.json", tmpdir);
test_mkdirp(tmpdir);
char dir[512];
snprintf(dir, sizeof(dir), "%s/.cursor", tmpdir);
test_mkdirp(dir);
/* Write config with existing server */
write_test_file(configpath,
"{\"mcpServers\": {\"other-server\": {\"command\": \"/usr/bin/other\"}}}");
cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "other-server") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_editor_mcp_uninstall) {
/* Port of TestEditorMCPUninstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.cursor/mcp.json", tmpdir);
cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
int rc = cbm_remove_editor_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* codebase-memory-mcp should be removed */
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_junie_mcp_install_issue651) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.junie/mcp/mcp.json", tmpdir);
int rc = cbm_upsert_junie_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "mcpServers") != NULL);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") != NULL);
ASSERT(strstr(data, "\"codebase-memory-analysis\"") != NULL);
ASSERT(strstr(data, "\"codebase-memory-scout\"") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "--tool-profile=analysis") != NULL);
ASSERT(strstr(data, "--tool-profile=scout") != NULL);
rc = cbm_upsert_junie_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
int count = 0;
const char *p = data;
while ((p = strstr(p, "\"codebase-memory-mcp\"")) != NULL) {
count++;
p += 20;
}
ASSERT_EQ(count, 1);
count = 0;
p = data;
while ((p = strstr(p, "\"codebase-memory-scout\"")) != NULL) {
count++;
p += strlen("\"codebase-memory-scout\"");
}
ASSERT_EQ(count, 1);
count = 0;
p = data;
while ((p = strstr(p, "\"codebase-memory-analysis\"")) != NULL) {
count++;
p += strlen("\"codebase-memory-analysis\"");
}
ASSERT_EQ(count, 1);
rc = cbm_remove_junie_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
ASSERT(strstr(data, "\"codebase-memory-analysis\"") == NULL);
ASSERT(strstr(data, "\"codebase-memory-scout\"") == NULL);
const char *partly_foreign =
"{\"mcpServers\":{"
"\"codebase-memory-mcp\":{\"command\":\"/usr/local/bin/codebase-memory-mcp\","
"\"args\":[]},"
"\"codebase-memory-scout\":{\"command\":\"/usr/local/bin/codebase-memory-mcp\","
"\"args\":[\"--tool-profile=scout\"]},"
"\"codebase-memory-analysis\":{\"command\":\"/opt/user-tool\","
"\"args\":[\"--private\"]}}}\n";
write_test_file(configpath, partly_foreign);
rc = cbm_remove_junie_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
ASSERT(strstr(data, "\"codebase-memory-scout\"") == NULL);
ASSERT(strstr(data, "\"codebase-memory-analysis\"") != NULL);
ASSERT(strstr(data, "/opt/user-tool") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_gemini_mcp_install) {
/* Port of TestGeminiMCPInstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.gemini/settings.json", tmpdir);
/* Gemini uses same mcpServers format as Cursor */
int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "mcpServers") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_openclaw_mcp_install_uses_nested_servers) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.openclaw/openclaw.json", tmpdir);
int rc = cbm_install_openclaw_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
yyjson_doc *doc = yyjson_read(data, strlen(data), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *mcp = yyjson_obj_get(root, "mcp");
yyjson_val *servers = yyjson_obj_get(mcp, "servers");
yyjson_val *entry = yyjson_obj_get(servers, "codebase-memory-mcp");
ASSERT(entry && yyjson_is_obj(entry));
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(entry, "command")),
"/usr/local/bin/codebase-memory-mcp");
yyjson_val *args = yyjson_obj_get(entry, "args");
ASSERT(args && yyjson_is_arr(args));
ASSERT_EQ(yyjson_arr_size(args), 0U);
ASSERT_NULL(yyjson_obj_get(root, "mcpServers"));
yyjson_doc_free(doc);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_openclaw_mcp_preserves_existing_config) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.openclaw", tmpdir);
test_mkdirp(dir);
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/openclaw.json", dir);
write_test_file(configpath,
"{\"theme\":\"dark\",\"mcp\":{\"servers\":{\"other\":{\"command\":\"x\"}}}}");
int rc = cbm_install_openclaw_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "theme") != NULL);
ASSERT(strstr(data, "other") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "\"mcpServers\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_openclaw_mcp_preserves_valid_json5) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-json5-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.openclaw", tmpdir);
test_mkdirp(dir);
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/openclaw.json", dir);
write_test_file(configpath,
"{ theme: 'dark', mcp: { servers: { other: { command: 'x' } } } }\n");
int rc = cbm_install_openclaw_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
char *data = read_test_file_alloc(configpath);
bool preserved_theme = data && strstr(data, "theme") && strstr(data, "dark");
bool preserved_server = data && strstr(data, "other") && strstr(data, "command");
bool installed = data && strstr(data, "codebase-memory-mcp");
free(data);
test_rmdir_r(tmpdir);
if (rc != 0 || !preserved_theme || !preserved_server || !installed)
FAIL("OpenClaw MCP install must preserve valid JSON5 settings and sibling servers");
PASS();
}
TEST(cli_openclaw_mcp_uninstall_uses_nested_servers) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.openclaw/openclaw.json", tmpdir);
ASSERT_EQ(cbm_install_openclaw_mcp("/usr/local/bin/codebase-memory-mcp", configpath), 0);
ASSERT_EQ(cbm_remove_openclaw_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath), 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"mcp\"") != NULL);
ASSERT(strstr(data, "\"servers\"") != NULL);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
ASSERT(strstr(data, "\"mcpServers\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_openclaw_compaction_preserves_user_owned_section) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-compaction-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.openclaw", tmpdir);
snprintf(config_path, sizeof(config_path), "%s/openclaw.json", config_dir);
test_mkdirp(config_dir);
write_test_file(config_path,
"{\"agents\":{\"defaults\":{\"compaction\":{"
"\"postCompactionSections\":[\"Codebase Memory\",\"User Notes\"]}}}}\n");
const char *const env_names[] = {"HOME",
"PATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_WORKSPACE_DIR",
"OPENCLAW_PROFILE"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
char *installed = read_test_file_alloc(config_path);
bool installed_owned =
installed && strstr(installed, "Codebase Knowledge Graph (codebase-memory-mcp)");
bool retained_existing =
installed && strstr(installed, "Codebase Memory") && strstr(installed, "User Notes");
free(installed);
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *uninstalled = read_test_file_alloc(config_path);
bool preserved_user =
uninstalled && strstr(uninstalled, "Codebase Memory") && strstr(uninstalled, "User Notes");
bool removed_owned =
uninstalled && !strstr(uninstalled, "Codebase Knowledge Graph (codebase-memory-mcp)");
free(uninstalled);
for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
if (!installed_owned || !retained_existing || rc != 0 || !preserved_user || !removed_owned)
FAIL("OpenClaw uninstall must remove only its namespaced compaction section");
PASS();
}
TEST(cli_openclaw_profile_uses_profile_state_and_default_workspace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-profile-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char profile_dir[512];
char config_path[640];
snprintf(profile_dir, sizeof(profile_dir), "%s/.openclaw-work", tmpdir);
snprintf(config_path, sizeof(config_path), "%s/openclaw.json", profile_dir);
test_mkdirp(profile_dir);
write_test_file(config_path, "{}\n");
const char *const env_names[] = {"PATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_WORKSPACE_DIR",
"OPENCLAW_PROFILE"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
cbm_setenv("PATH", tmpdir, 1);
cbm_setenv("OPENCLAW_PROFILE", "work", 1);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
char *plan = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
bool correct = agents.openclaw && plan && strstr(plan, "/.openclaw-work/openclaw.json") &&
strstr(plan, "/.openclaw/workspace-work/AGENTS.md") &&
!strstr(plan, "/.openclaw-work/workspace-work/AGENTS.md");
free(plan);
for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
if (!correct)
FAIL("OpenClaw profiles must use ~/.openclaw-<profile> state and ~/.openclaw workspace");
PASS();
}
TEST(cli_openclaw_uninstall_removes_compaction_when_workspace_is_ambiguous) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-openclaw-uninstall-ambiguous-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.openclaw", tmpdir);
snprintf(config_path, sizeof(config_path), "%s/openclaw.json", config_dir);
test_mkdirp(config_dir);
write_test_file(config_path,
"{\"$include\":[\"one.json\",\"two.json\"],\"agents\":{\"defaults\":{"
"\"compaction\":{\"postCompactionSections\":["
"\"Codebase Knowledge Graph (codebase-memory-mcp)\"]}}}}\n");
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *after = read_test_file_alloc(config_path);
bool removed = after && !strstr(after, "Codebase Knowledge Graph (codebase-memory-mcp)");
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (rc != 0 || !removed)
FAIL("OpenClaw compaction cleanup must not depend on resolving a workspace");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* VS Code MCP config tests
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_vscode_mcp_install) {
/* Port of TestVSCodeMCPInstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/Code/User/mcp.json", tmpdir);
int rc = cbm_install_vscode_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"servers\"") != NULL);
ASSERT(strstr(data, "\"type\"") != NULL);
ASSERT(strstr(data, "\"stdio\"") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_vscode_mcp_uninstall) {
/* Port of TestVSCodeMCPUninstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/Code/User/mcp.json", tmpdir);
cbm_install_vscode_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
int rc = cbm_remove_vscode_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_vscode_profile_mcp_uninstall) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-vscode-profile-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char code_user[640];
#ifdef __APPLE__
snprintf(code_user, sizeof(code_user), "%s/Library/Application Support/Code/User", tmpdir);
#elif defined(_WIN32)
snprintf(code_user, sizeof(code_user), "%s/AppData/Roaming/Code/User", tmpdir);
#else
snprintf(code_user, sizeof(code_user), "%s/.config/Code/User", tmpdir);
#endif
char profile_dir[768];
char base_config[768];
char profile_config[896];
snprintf(profile_dir, sizeof(profile_dir), "%s/profiles/profile-one", code_user);
snprintf(base_config, sizeof(base_config), "%s/mcp.json", code_user);
snprintf(profile_config, sizeof(profile_config), "%s/mcp.json", profile_dir);
test_mkdirp(profile_dir);
char installed_binary[640];
#ifdef _WIN32
snprintf(installed_binary, sizeof(installed_binary), "%s/.local/bin/codebase-memory-mcp.exe",
tmpdir);
#else
snprintf(installed_binary, sizeof(installed_binary), "%s/.local/bin/codebase-memory-mcp",
tmpdir);
#endif
ASSERT_EQ(cbm_install_vscode_mcp(installed_binary, base_config), 0);
ASSERT_EQ(cbm_install_vscode_mcp(installed_binary, profile_config), 0);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_appdata = save_test_env("APPDATA");
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
char xdg_dir[640];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolvers prefer XDG */
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
#ifdef _WIN32
char appdata[512];
snprintf(appdata, sizeof(appdata), "%s/AppData/Roaming", tmpdir);
cbm_setenv("APPDATA", appdata, 1);
#endif
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *base = read_test_file_alloc(base_config);
char *profile = read_test_file_alloc(profile_config);
bool removed = base && profile && !strstr(base, "codebase-memory-mcp") &&
!strstr(profile, "codebase-memory-mcp");
free(base);
free(profile);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("APPDATA", saved_appdata);
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
test_rmdir_r(tmpdir);
if (rc != 0 || !removed)
FAIL("VS Code uninstall must remove MCP entries from every existing profile");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Zed MCP config tests
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_zed_mcp_install) {
/* Port of TestZedMCPInstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.config/zed/settings.json", tmpdir);
int rc = cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"context_servers\"") != NULL);
ASSERT(strstr(data, "\"command\"") != NULL);
ASSERT(strstr(data, "\"args\"") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_zed_mcp_preserves_settings) {
/* Port of TestZedMCPPreservesSettings */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.config/zed/settings.json", tmpdir);
char dir[512];
snprintf(dir, sizeof(dir), "%s/.config/zed", tmpdir);
test_mkdirp(dir);
/* Pre-existing Zed settings */
write_test_file(configpath, "{\"theme\": \"One Dark\", \"vim_mode\": true}");
cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* Original settings preserved */
ASSERT(strstr(data, "One Dark") != NULL);
ASSERT(strstr(data, "vim_mode") != NULL);
/* MCP server added */
ASSERT(strstr(data, "context_servers") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_zed_mcp_uninstall) {
/* Port of TestZedMCPUninstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.config/zed/settings.json", tmpdir);
cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
int rc = cbm_remove_zed_mcp_owned("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"codebase-memory-mcp\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_zed_mcp_jsonc_comments) {
/* Issue #24: Zed settings.json uses JSONC (comments + trailing commas) */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/.config/zed/settings.json", tmpdir);
char dir[512];
snprintf(dir, sizeof(dir), "%s/.config/zed", tmpdir);
test_mkdirp(dir);
/* JSONC with comments and trailing commas — must not fail */
write_test_file(configpath, "// Zed settings\n"
"{\n"
" \"theme\": \"One Dark\",\n"
" /* multi-line\n"
" comment */\n"
" \"vim_mode\": true,\n" /* trailing comma */
"}\n");
int rc = cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* Original settings preserved */
ASSERT(strstr(data, "One Dark") != NULL);
ASSERT(strstr(data, "vim_mode") != NULL);
/* MCP server added */
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "context_servers") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* PATH management tests (port of TestCLI_InstallPATHAppend)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_ensure_path_append) {
/* Port of TestCLI_InstallPATHAppend */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/.zshrc", tmpdir);
write_test_file(rcfile, "# existing content\n");
int rc = cbm_ensure_path("/usr/local/bin", rcfile, false);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(rcfile);
ASSERT(strstr(data, "export PATH=\"/usr/local/bin:$PATH\"") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_ensure_path_already_present) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/.zshrc", tmpdir);
write_test_file(rcfile, "export PATH=\"/usr/local/bin:$PATH\"\n");
int rc = cbm_ensure_path("/usr/local/bin", rcfile, false);
ASSERT_EQ(rc, 1); /* 1 = already present */
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_ensure_path_dry_run) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/.zshrc", tmpdir);
write_test_file(rcfile, "# clean\n");
int rc = cbm_ensure_path("/usr/local/bin", rcfile, true);
ASSERT_EQ(rc, 0);
/* File should NOT be modified */
const char *data = read_test_file(rcfile);
ASSERT(strstr(data, "export PATH") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_owned_path_block_preserves_user_content) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-remove-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/.zshrc", tmpdir);
write_test_file(rcfile, "# user prefix\nexport KEEP_ME=1\n");
ASSERT_EQ(cbm_ensure_path("/usr/local/bin", rcfile, false), 0);
ASSERT_EQ(cbm_remove_owned_path("/usr/local/bin", rcfile, false), 0);
const char *data = read_test_file(rcfile);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "export KEEP_ME=1") != NULL);
ASSERT(strstr(data, "Added by codebase-memory-mcp install") == NULL);
ASSERT(strstr(data, "export PATH=\"/usr/local/bin:$PATH\"") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_owned_path_dry_run_preserves_block) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-remove-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/config.fish", tmpdir);
write_test_file(rcfile, "# user prefix\n");
ASSERT_EQ(cbm_ensure_path("/usr/local/bin", rcfile, false), 0);
ASSERT_EQ(cbm_remove_owned_path("/usr/local/bin", rcfile, true), 0);
const char *data = read_test_file(rcfile);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "Added by codebase-memory-mcp install") != NULL);
ASSERT(strstr(data, "fish_add_path /usr/local/bin") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #319: a fish config must get fish-native syntax, never `export PATH=`
* (which is a syntax error in fish and breaks config.fish). */
TEST(cli_ensure_path_fish_syntax_issue319) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-path-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char rcfile[512];
snprintf(rcfile, sizeof(rcfile), "%s/config.fish", tmpdir);
write_test_file(rcfile, "# existing fish config\n");
int rc = cbm_ensure_path("/usr/local/bin", rcfile, false);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(rcfile);
ASSERT_NOT_NULL(data);
/* fish-native form, and NO sh-style export. */
ASSERT(strstr(data, "fish_add_path /usr/local/bin") != NULL);
ASSERT(strstr(data, "export PATH") == NULL);
/* Idempotent: a second call detects the existing fish line. */
int rc2 = cbm_ensure_path("/usr/local/bin", rcfile, false);
ASSERT_EQ(rc2, 1);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* File copy tests (port of update_test.go)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_copy_file) {
/* Port of TestCopyFile */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-copy-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char src[512], dst[512];
snprintf(src, sizeof(src), "%s/source", tmpdir);
snprintf(dst, sizeof(dst), "%s/dest", tmpdir);
write_test_file(src, "test content for copy");
int rc = cbm_copy_file(src, dst);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(dst);
ASSERT_STR_EQ(data, "test content for copy");
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_copy_file_source_not_found) {
/* Port of TestCopyFile_SourceNotFound */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-copy-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char src[512], dst[512];
snprintf(src, sizeof(src), "%s/nonexistent", tmpdir);
snprintf(dst, sizeof(dst), "%s/dest", tmpdir);
int rc = cbm_copy_file(src, dst);
ASSERT(rc != 0);
cbm_rmdir(tmpdir);
PASS();
}
/* #472: install --force must copy the freshly-built binary to the target and
* make it executable — previously it re-signed whatever was already there. */
TEST(cli_install_copies_binary_to_target_issue472) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-binswap-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char src[512], dst[512];
snprintf(src, sizeof(src), "%s/new-build", tmpdir);
snprintf(dst, sizeof(dst), "%s/installed", tmpdir);
write_test_file(src, "fresh build bytes");
/* Target does not exist yet → must be created with the source content. */
int rc = cbm_copy_binary_to_target(src, dst);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(dst);
ASSERT_STR_EQ(data, "fresh build bytes");
#ifndef _WIN32
/* The exec bit is set via chmod, which is POSIX-only; on Windows it is not
* meaningful and MinGW stat() derives it from the file extension. */
struct stat st;
ASSERT_EQ(stat(dst, &st), 0);
ASSERT((st.st_mode & S_IXUSR) != 0); /* executable bit set */
#endif
/* Overwrite an existing (stale) target with new content. */
write_test_file(dst, "STALE");
#ifndef _WIN32
struct stat stale_st;
ASSERT_EQ(stat(dst, &stale_st), 0);
#endif
write_test_file(src, "upgraded build bytes");
rc = cbm_copy_binary_to_target(src, dst);
ASSERT_EQ(rc, 0);
data = read_test_file(dst);
ASSERT_STR_EQ(data, "upgraded build bytes");
#ifndef _WIN32
/* Replacement must publish a completed temporary file with rename(2),
* not truncate the installed executable in place. A new inode proves the
* old file can remain mapped by a just-stopped MCP process safely. */
struct stat upgraded_st;
ASSERT_EQ(stat(dst, &upgraded_st), 0);
ASSERT_NEQ(stale_st.st_ino, upgraded_st.st_ino);
#endif
test_rmdir_r(tmpdir);
PASS();
}
/* #472: copying the running binary onto itself must NOT truncate it. */
TEST(cli_install_same_file_guard_issue472) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-samefile-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char path[512];
snprintf(path, sizeof(path), "%s/self", tmpdir);
write_test_file(path, "must survive self-copy");
int rc = cbm_copy_binary_to_target(path, path);
ASSERT_EQ(rc, 0); /* skipped, not failed */
const char *data = read_test_file(path);
ASSERT_STR_EQ(data, "must survive self-copy"); /* intact, not zeroed */
#ifndef _WIN32
/* Distinct path strings resolving to the same inode (a symlink — exactly
* what a non-canonical cbm_detect_self_path vs the hardcoded target can
* produce) must also be detected as same-file and skipped, not truncated. */
char link[512];
snprintf(link, sizeof(link), "%s/self-link", tmpdir);
if (symlink(path, link) == 0) {
rc = cbm_copy_binary_to_target(link, path);
ASSERT_EQ(rc, 0);
data = read_test_file(path);
ASSERT_STR_EQ(data, "must survive self-copy"); /* still intact via symlink */
}
#endif
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Tar.gz extraction tests (port of update_test.go)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_extract_binary_from_targz) {
/* Port of TestExtractBinaryFromTarGz */
const char *content = "fake binary content";
int gz_len;
unsigned char *gz =
create_test_targz("codebase-memory-mcp-linux-amd64", (const unsigned char *)content,
(int)strlen(content), &gz_len);
ASSERT_NOT_NULL(gz);
int out_len;
unsigned char *extracted = cbm_extract_binary_from_targz(gz, gz_len, &out_len);
ASSERT_NOT_NULL(extracted);
ASSERT_EQ(out_len, (int)strlen(content));
ASSERT_MEM_EQ(extracted, content, out_len);
free(extracted);
free(gz);
PASS();
}
TEST(cli_extract_binary_from_targz_not_found) {
/* Port of TestExtractBinaryFromTarGz_NotFound */
const char *content = "hello";
int gz_len;
unsigned char *gz = create_test_targz("some-other-file", (const unsigned char *)content,
(int)strlen(content), &gz_len);
ASSERT_NOT_NULL(gz);
int out_len;
unsigned char *extracted = cbm_extract_binary_from_targz(gz, gz_len, &out_len);
ASSERT_NULL(extracted);
free(gz);
PASS();
}
TEST(cli_extract_binary_from_targz_invalid_data) {
/* Port of TestExtractBinaryFromTarGz_InvalidData */
const unsigned char bad_data[] = "not a valid tar.gz";
int out_len;
unsigned char *extracted = cbm_extract_binary_from_targz(bad_data, sizeof(bad_data), &out_len);
ASSERT_NULL(extracted);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Zip extraction tests
* ═══════════════════════════════════════════════════════════════════ */
/* Build a minimal zip file with one stored (uncompressed) entry. */
static unsigned char *create_test_zip_stored(const char *filename, const unsigned char *content,
int content_len, int *out_len) {
/* Local file header (30 bytes) + filename + content + central dir + EOCD */
int name_len = (int)strlen(filename);
int local_hdr_sz = 30 + name_len;
int cd_hdr_sz = 46 + name_len;
int eocd_sz = 22;
int total = local_hdr_sz + content_len + cd_hdr_sz + eocd_sz;
unsigned char *zip = calloc(1, (size_t)total);
if (!zip)
return NULL;
int pos = 0;
/* Local file header */
zip[pos] = 0x50;
zip[pos + 1] = 0x4B;
zip[pos + 2] = 0x03;
zip[pos + 3] = 0x04; /* signature */
zip[pos + 4] = 20;
zip[pos + 5] = 0; /* version needed = 2.0 */
zip[pos + 8] = 0;
zip[pos + 9] = 0; /* compression = stored */
zip[pos + 18] = (unsigned char)(content_len & 0xFF);
zip[pos + 19] = (unsigned char)((content_len >> 8) & 0xFF);
zip[pos + 20] = (unsigned char)((content_len >> 16) & 0xFF);
zip[pos + 21] = (unsigned char)((content_len >> 24) & 0xFF);
zip[pos + 22] = zip[pos + 18];
zip[pos + 23] = zip[pos + 19];
zip[pos + 24] = zip[pos + 20];
zip[pos + 25] = zip[pos + 21];
zip[pos + 26] = (unsigned char)(name_len & 0xFF);
zip[pos + 27] = (unsigned char)((name_len >> 8) & 0xFF);
memcpy(zip + pos + 30, filename, (size_t)name_len);
pos += 30 + name_len;
memcpy(zip + pos, content, (size_t)content_len);
pos += content_len;
int cd_start = pos;
/* Central directory header */
zip[pos] = 0x50;
zip[pos + 1] = 0x4B;
zip[pos + 2] = 0x01;
zip[pos + 3] = 0x02;
zip[pos + 10] = 0;
zip[pos + 11] = 0; /* compression = stored */
zip[pos + 20] = (unsigned char)(content_len & 0xFF);
zip[pos + 21] = (unsigned char)((content_len >> 8) & 0xFF);
zip[pos + 22] = (unsigned char)((content_len >> 16) & 0xFF);
zip[pos + 23] = (unsigned char)((content_len >> 24) & 0xFF);
zip[pos + 24] = zip[pos + 20];
zip[pos + 25] = zip[pos + 21];
zip[pos + 26] = zip[pos + 22];
zip[pos + 27] = zip[pos + 23];
zip[pos + 28] = (unsigned char)(name_len & 0xFF);
zip[pos + 29] = (unsigned char)((name_len >> 8) & 0xFF);
pos += 46 + name_len;
/* EOCD */
zip[pos] = 0x50;
zip[pos + 1] = 0x4B;
zip[pos + 2] = 0x05;
zip[pos + 3] = 0x06;
zip[pos + 8] = 1; /* num entries this disk */
zip[pos + 10] = 1; /* total entries */
int cd_size = pos - cd_start;
zip[pos + 12] = (unsigned char)(cd_size & 0xFF);
zip[pos + 13] = (unsigned char)((cd_size >> 8) & 0xFF);
zip[pos + 16] = (unsigned char)(cd_start & 0xFF);
zip[pos + 17] = (unsigned char)((cd_start >> 8) & 0xFF);
*out_len = total;
return zip;
}
TEST(cli_extract_binary_from_zip) {
const char *content = "#!/bin/sh\necho test\n";
int zip_len = 0;
unsigned char *zip = create_test_zip_stored(
"codebase-memory-mcp", (const unsigned char *)content, (int)strlen(content), &zip_len);
ASSERT_NOT_NULL(zip);
int out_len = 0;
unsigned char *extracted = cbm_extract_binary_from_zip(zip, zip_len, &out_len);
ASSERT_NOT_NULL(extracted);
ASSERT_EQ(out_len, (int)strlen(content));
ASSERT_MEM_EQ(extracted, content, (size_t)out_len);
free(extracted);
free(zip);
PASS();
}
TEST(cli_extract_binary_from_zip_not_found) {
const char *content = "data";
int zip_len = 0;
unsigned char *zip = create_test_zip_stored("other-file.txt", (const unsigned char *)content,
(int)strlen(content), &zip_len);
ASSERT_NOT_NULL(zip);
int out_len = 0;
unsigned char *extracted = cbm_extract_binary_from_zip(zip, zip_len, &out_len);
ASSERT_NULL(extracted);
free(zip);
PASS();
}
TEST(cli_extract_binary_from_zip_path_traversal) {
const char *content = "malicious";
int zip_len = 0;
unsigned char *zip =
create_test_zip_stored("../../etc/codebase-memory-mcp", (const unsigned char *)content,
(int)strlen(content), &zip_len);
ASSERT_NOT_NULL(zip);
int out_len = 0;
unsigned char *extracted = cbm_extract_binary_from_zip(zip, zip_len, &out_len);
ASSERT_NULL(extracted);
free(zip);
PASS();
}
TEST(cli_extract_binary_from_zip_invalid) {
const unsigned char bad_data[] = "not a zip file";
int out_len = 0;
unsigned char *extracted = cbm_extract_binary_from_zip(bad_data, sizeof(bad_data), &out_len);
ASSERT_NULL(extracted);
PASS();
}
TEST(cli_extract_binary_from_zip_rejects_truncated_deflate_size_over_int_max) {
const char *filename = "codebase-memory-mcp";
const unsigned char deflated[] = {0xAB, 0x00, 0x00}; /* raw DEFLATE for "x" */
size_t name_len = strlen(filename);
size_t zip_len = 30 + name_len + sizeof(deflated);
unsigned char *zip = calloc(1, zip_len);
ASSERT_NOT_NULL(zip);
uint32_t comp_size = 0xFFFF0000U;
uint32_t uncomp_size = 1U;
zip[0] = 0x50;
zip[1] = 0x4B;
zip[2] = 0x03;
zip[3] = 0x04;
zip[8] = 8;
zip[9] = 0;
zip[18] = (unsigned char)(comp_size & 0xFF);
zip[19] = (unsigned char)((comp_size >> 8) & 0xFF);
zip[20] = (unsigned char)((comp_size >> 16) & 0xFF);
zip[21] = (unsigned char)((comp_size >> 24) & 0xFF);
zip[22] = (unsigned char)(uncomp_size & 0xFF);
zip[23] = (unsigned char)((uncomp_size >> 8) & 0xFF);
zip[24] = (unsigned char)((uncomp_size >> 16) & 0xFF);
zip[25] = (unsigned char)((uncomp_size >> 24) & 0xFF);
zip[26] = (unsigned char)(name_len & 0xFF);
zip[27] = (unsigned char)((name_len >> 8) & 0xFF);
memcpy(zip + 30, filename, name_len);
memcpy(zip + 30 + name_len, deflated, sizeof(deflated));
int out_len = 0;
unsigned char *extracted = cbm_extract_binary_from_zip(zip, (int)zip_len, &out_len);
if (extracted) {
free(extracted);
free(zip);
FAIL("accepted a truncated deflated zip entry with a wrapped compressed size");
}
ASSERT_EQ(out_len, 0);
free(zip);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Skill dry-run tests
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_install_dry_run) {
/* Port of TestCLI_InstallDryRun */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-dry-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
int count = cbm_install_skills(skills_dir, false, true);
ASSERT_EQ(count, CBM_SKILL_COUNT);
/* Skills should NOT be created */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
struct stat st;
ASSERT(stat(path, &st) != 0);
}
cbm_rmdir(tmpdir);
PASS();
}
TEST(cli_uninstall_dry_run) {
/* Port of TestCLI_UninstallDryRun */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-dry-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
cbm_install_skills(skills_dir, false, false);
int removed = cbm_remove_skills(skills_dir, true);
ASSERT_EQ(removed, CBM_SKILL_COUNT);
/* Skills should still exist */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
struct stat st;
ASSERT_EQ(stat(path, &st), 0);
}
test_rmdir_r(tmpdir);
PASS();
}
/* A dry-run is a read-only preview even when -y is supplied. In particular,
* it must not route the automatic answer through the destructive index-removal
* branch. */
TEST(cli_uninstall_dry_run_preserves_indexes) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-uninstall-preview-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cache_dir[512];
char project_db[768];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
snprintf(project_db, sizeof(project_db), "%s/project.db", cache_dir);
ASSERT_EQ(test_mkdirp(cache_dir), 0);
ASSERT_EQ(write_test_file(project_db, "indexed graph"), 0);
cli_env_snapshot_t home = {0};
cli_env_snapshot_t cache = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
ASSERT_TRUE(cli_env_snapshot(&cache, "CBM_CACHE_DIR"));
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
char *args[] = {"--dry-run", "-y"};
ASSERT_EQ(cbm_cmd_uninstall(2, args), 0);
struct stat st;
ASSERT_EQ(stat(project_db, &st), 0);
/* parse_auto_answer() is process-global; do not leak this test's -y into
* later lifecycle tests. */
extern void cbm_set_auto_answer_for_test(int value);
cbm_set_auto_answer_for_test(0);
cli_env_restore(&cache);
cli_env_restore(&home);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_uninstall_removes_codex_json_hook_only) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char codex_dir[512];
char hooks_path[768];
snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir);
snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir);
ASSERT_EQ(test_mkdirp(codex_dir), 0);
ASSERT_EQ(write_test_file(hooks_path, "{\"hooks\":{\"SessionStart\":[{\"matcher\":\"startup\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo user-hook\"}]}]}}"),
0);
ASSERT_EQ(cbm_upsert_gemini_session_hooks(hooks_path), 0);
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
const char *contents = read_test_file(hooks_path);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "echo user-hook") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp reminder") == NULL);
extern void cbm_set_auto_answer_for_test(int value);
cbm_set_auto_answer_for_test(0);
cli_env_restore(&home);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_uninstall_removes_owned_claude_hook_scripts) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-claude-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
ASSERT_EQ(test_mkdirp(hooks_dir), 0);
#ifdef _WIN32
const char *names[] = {"cbm-code-discovery-gate.cmd", "cbm-session-reminder.cmd",
"cbm-subagent-reminder.cmd"};
#else
const char *names[] = {"cbm-code-discovery-gate", "cbm-session-reminder",
"cbm-subagent-reminder"};
#endif
char paths[3][768];
for (size_t i = 0; i < 3; i++) {
snprintf(paths[i], sizeof(paths[i]), "%s/%s", hooks_dir, names[i]);
}
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char binary[768];
#ifdef _WIN32
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir);
#else
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
#endif
ASSERT_EQ(cbm_install_agent_configs(tmpdir, binary, false, false), 0);
struct stat st;
for (size_t i = 0; i < 3; i++)
ASSERT_EQ(stat(paths[i], &st), 0);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
for (size_t i = 0; i < 3; i++)
ASSERT_NEQ(stat(paths[i], &st), 0);
extern void cbm_set_auto_answer_for_test(int value);
cbm_set_auto_answer_for_test(0);
cli_env_restore(&home);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_uninstall_removes_vscode_profile_mcp_only) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-vscode-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char profile_dir[768];
char profile_mcp[1024];
#ifdef __APPLE__
snprintf(profile_dir, sizeof(profile_dir),
"%s/Library/Application Support/Code/User/profiles/profile-a", tmpdir);
#else
snprintf(profile_dir, sizeof(profile_dir), "%s/.config/Code/User/profiles/profile-a", tmpdir);
#endif
snprintf(profile_mcp, sizeof(profile_mcp), "%s/mcp.json", profile_dir);
ASSERT_EQ(test_mkdirp(profile_dir), 0);
ASSERT_EQ(
write_test_file(profile_mcp, "{\"servers\":{\"user-server\":{\"command\":\"user\"}}}"), 0);
char binary[768];
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
ASSERT_EQ(cbm_install_vscode_mcp(binary, profile_mcp), 0);
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
const char *contents = read_test_file(profile_mcp);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "user-server") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp") == NULL);
extern void cbm_set_auto_answer_for_test(int value);
cbm_set_auto_answer_for_test(0);
cli_env_restore(&home);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_install_help_does_not_require_home) {
ASSERT_EQ(cli_run_help_without_home(cbm_cmd_install), 0);
PASS();
}
TEST(cli_uninstall_help_does_not_require_home) {
ASSERT_EQ(cli_run_help_without_home(cbm_cmd_uninstall), 0);
PASS();
}
TEST(cli_update_help_does_not_require_home) {
ASSERT_EQ(cli_run_help_without_home(cbm_cmd_update), 0);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Full install + uninstall lifecycle
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_install_and_uninstall) {
/* Port of TestCLI_InstallAndUninstall */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-full-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char skills_dir[512];
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
/* Install */
int written = cbm_install_skills(skills_dir, false, false);
ASSERT_EQ(written, CBM_SKILL_COUNT);
/* Verify */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
struct stat st;
ASSERT_EQ(stat(path, &st), 0);
}
/* Uninstall */
int removed = cbm_remove_skills(skills_dir, false);
ASSERT_EQ(removed, CBM_SKILL_COUNT);
/* Verify removed */
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
char path[1024];
snprintf(path, sizeof(path), "%s/%s", skills_dir, sk[i].name);
struct stat st;
ASSERT(stat(path, &st) != 0);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_agent_install_reports_safe_editor_refusal) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-refusal-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.openclaw", tmpdir);
snprintf(config_path, sizeof(config_path), "%s/openclaw.json", config_dir);
test_mkdirp(config_dir);
const char *malformed = "{ invalid config\n";
write_test_file(config_path, malformed);
char *saved_path = save_test_env("PATH");
cbm_setenv("PATH", tmpdir, 1);
int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
char *after = read_test_file_alloc(config_path);
bool preserved = after && strcmp(after, malformed) == 0;
free(after);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (rc == 0 || !preserved)
FAIL("agent install must return failure when a safe editor refuses a config");
PASS();
}
TEST(cli_agent_uninstall_reports_safe_editor_refusal) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-uninstall-refusal-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.openclaw", tmpdir);
snprintf(config_path, sizeof(config_path), "%s/openclaw.json", config_dir);
test_mkdirp(config_dir);
const char *malformed = "{ invalid config\n";
write_test_file(config_path, malformed);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *after = read_test_file_alloc(config_path);
bool preserved = after && strcmp(after, malformed) == 0;
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (rc == 0 || !preserved)
FAIL("agent uninstall must return failure when a safe editor refuses a config");
PASS();
}
TEST(cli_special_hook_failures_propagate_from_install_and_uninstall) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-special-hook-refusal-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char factory_dir[512];
char hooks_path[640];
snprintf(factory_dir, sizeof(factory_dir), "%s/.factory", tmpdir);
snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", factory_dir);
test_mkdirp(factory_dir);
write_test_file(hooks_path, "[]\n");
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char *args[] = {"-n"};
int uninstall_rc = cbm_cmd_uninstall(1, args);
char *after = read_test_file_alloc(hooks_path);
bool unchanged = after && strcmp(after, "[]\n") == 0;
#ifdef _WIN32
bool results_ok = install_rc == 0 && uninstall_rc != 0;
#else
bool results_ok = install_rc != 0 && uninstall_rc != 0;
#endif
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (!results_ok || !unchanged)
FAIL("special hook editor failures must propagate without changing foreign content");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* YAML parser unit tests
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_yaml_parse_simple) {
/* Basic key-value parsing */
const char *yaml = "name: test\nversion: 1.0\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
ASSERT_STR_EQ(cbm_yaml_get_str(root, "name"), "test");
ASSERT_STR_EQ(cbm_yaml_get_str(root, "version"), "1.0");
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_parse_nested) {
/* Nested map */
const char *yaml = "parent:\n"
" child: value\n"
" number: 42\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
ASSERT_STR_EQ(cbm_yaml_get_str(root, "parent.child"), "value");
ASSERT_FLOAT_EQ(cbm_yaml_get_float(root, "parent.number", 0), 42.0, 0.001);
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_parse_list) {
/* String list */
const char *yaml = "items:\n"
" - alpha\n"
" - beta\n"
" - gamma\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
const char *items[8];
int count = cbm_yaml_get_str_list(root, "items", items, 8);
ASSERT_EQ(count, 3);
ASSERT_STR_EQ(items[0], "alpha");
ASSERT_STR_EQ(items[1], "beta");
ASSERT_STR_EQ(items[2], "gamma");
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_parse_bool) {
const char *yaml = "enabled: true\n"
"disabled: false\n"
"on_flag: yes\n"
"off_flag: no\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
ASSERT_TRUE(cbm_yaml_get_bool(root, "enabled", false));
ASSERT_FALSE(cbm_yaml_get_bool(root, "disabled", true));
ASSERT_TRUE(cbm_yaml_get_bool(root, "on_flag", false));
ASSERT_FALSE(cbm_yaml_get_bool(root, "off_flag", true));
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_parse_comments) {
const char *yaml = "# This is a comment\n"
"key: value # inline comment\n"
"\n"
"# Another comment\n"
"other: data\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
ASSERT_STR_EQ(cbm_yaml_get_str(root, "key"), "value");
ASSERT_STR_EQ(cbm_yaml_get_str(root, "other"), "data");
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_parse_empty) {
cbm_yaml_node_t *root = cbm_yaml_parse("", 0);
ASSERT_NOT_NULL(root);
ASSERT_NULL(cbm_yaml_get_str(root, "anything"));
cbm_yaml_free(root);
PASS();
}
TEST(cli_yaml_has) {
const char *yaml = "a:\n b: c\n";
cbm_yaml_node_t *root = cbm_yaml_parse(yaml, (int)strlen(yaml));
ASSERT_NOT_NULL(root);
ASSERT_TRUE(cbm_yaml_has(root, "a"));
ASSERT_TRUE(cbm_yaml_has(root, "a.b"));
ASSERT_FALSE(cbm_yaml_has(root, "a.c"));
ASSERT_FALSE(cbm_yaml_has(root, "x"));
cbm_yaml_free(root);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group A: Agent Detection
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_detect_agents_finds_claude) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.claude", tmpdir);
test_mkdirp(dir);
/* 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. */
const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR");
char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL;
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.claude_code);
if (saved_ccd_copy) {
cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1);
free(saved_ccd_copy);
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_finds_claude_via_env) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
/* Config dir lives OUTSIDE home_dir/.claude, pointed at by CLAUDE_CONFIG_DIR. */
char ccd[512];
snprintf(ccd, sizeof(ccd), "%s/custom-claude", tmpdir);
test_mkdirp(ccd);
const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR");
char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL;
cbm_setenv("CLAUDE_CONFIG_DIR", ccd, 1);
/* home_dir has no .claude, but detection must still find Claude via the env var. */
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.claude_code);
if (saved_ccd_copy) {
cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1);
free(saved_ccd_copy);
} else {
cbm_unsetenv("CLAUDE_CONFIG_DIR");
}
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_finds_codex) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.codex", tmpdir);
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.codex);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_standalone_kilo_install_plan_and_uninstall_preserve_foreign_entries) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-kilo-standalone-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[768];
snprintf(config_dir, sizeof(config_dir), "%s/.config/kilo", tmpdir);
ASSERT_EQ(test_mkdirp(config_dir), 0);
snprintf(config_path, sizeof(config_path), "%s/kilo.jsonc", config_dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.kilo_cli);
char *plan = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(plan);
ASSERT(strstr(plan, "\"kilo-cli\"") != NULL);
ASSERT(strstr(plan, ".config/kilo/kilo.jsonc") != NULL);
free(plan);
ASSERT_EQ(write_test_file(
config_path,
"{\n // user-owned server\n \"mcp\": {\n \"foreign\": {\"type\": \"local\", "
"\"command\": [\"keep-me\"]},\n },\n}\n"),
0);
char binary[768];
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
ASSERT_EQ(cbm_upsert_opencode_mcp(binary, config_path), 0);
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
cli_env_restore(&home);
const char *contents = read_test_file(config_path);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "keep-me") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #222: Cursor (~/.cursor/) must be detected so install/update registers
* the MCP server in ~/.cursor/mcp.json — previously it was never discovered. */
TEST(cli_detect_agents_finds_cursor_issue222) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.cursor", tmpdir);
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.cursor);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #388: `install --plan` must emit a machine-readable receipt of planned
* writes WITHOUT mutating any config (the pre-mutation trust primitive). */
TEST(cli_install_plan_receipt_no_mutation_issue388) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-plan-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
/* Make Claude Code + Cursor + Codex "detected". */
char dir[512];
snprintf(dir, sizeof(dir), "%s/.claude", tmpdir);
test_mkdirp(dir);
snprintf(dir, sizeof(dir), "%s/.cursor", tmpdir);
test_mkdirp(dir);
snprintf(dir, sizeof(dir), "%s/.codex", tmpdir);
test_mkdirp(dir);
char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "agent.install.plan.v1") != NULL);
ASSERT(strstr(json, "writes_started") != NULL);
ASSERT(strstr(json, "next_safe_command") != NULL);
ASSERT(strstr(json, "cursor") != NULL);
ASSERT(strstr(json, "skill_dirs_planned") != NULL);
ASSERT(strstr(json, ".claude/skills") != NULL);
ASSERT(strstr(json, ".cursor/mcp.json") != NULL);
ASSERT(strstr(json, ".codex/config.toml") != NULL);
const char *instrs = strstr(json, "\"instruction_files_planned\"");
ASSERT_NOT_NULL(instrs);
const char *skill_dirs = strstr(json, "\"skill_dirs_planned\"");
ASSERT_NOT_NULL(skill_dirs);
ASSERT(instrs < skill_dirs);
const char *misclassified = strstr(instrs, ".claude/skills");
ASSERT(misclassified == NULL || misclassified > skill_dirs);
free(json);
/* Critical: building the plan must NOT have created any config file. */
char cfg[512];
struct stat st;
snprintf(cfg, sizeof(cfg), "%s/.cursor/mcp.json", tmpdir);
ASSERT(stat(cfg, &st) != 0); /* must not exist */
snprintf(cfg, sizeof(cfg), "%s/.codex/config.toml", tmpdir);
ASSERT(stat(cfg, &st) != 0); /* must not exist */
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_reference_harnesses_are_planned_without_mutation) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-reference-harnesses-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *dirs[] = {".qwen", ".codeium/windsurf"};
for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
char path[512];
snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]);
ASSERT_EQ(test_mkdirp(path), 0);
}
char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, ".qwen/settings.json") != NULL);
ASSERT(strstr(json, ".qwen/QWEN.md") != NULL);
ASSERT(strstr(json, ".codeium/windsurf/mcp_config.json") != NULL);
const char *detected = strstr(json, "\"agents_detected\"");
const char *configs = strstr(json, "\"config_files_planned\"");
ASSERT_NOT_NULL(detected);
ASSERT_NOT_NULL(configs);
const char *qwen_detected = strstr(detected, "\"qwen\"");
const char *windsurf_detected = strstr(detected, "\"windsurf\"");
ASSERT(qwen_detected != NULL && qwen_detected < configs);
ASSERT(windsurf_detected != NULL && windsurf_detected < configs);
/* Plan mode must not publish any of the planned files. */
char path[768];
struct stat st;
snprintf(path, sizeof(path), "%s/.qwen/settings.json", tmpdir);
ASSERT_NEQ(stat(path, &st), 0);
snprintf(path, sizeof(path), "%s/.codeium/windsurf/mcp_config.json", tmpdir);
ASSERT_NEQ(stat(path, &st), 0);
free(json);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_claude_desktop_plan_and_uninstall_preserve_foreign_entries) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-claude-desktop-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char config_path[768];
#ifdef __APPLE__
snprintf(config_dir, sizeof(config_dir), "%s/Library/Application Support/Claude", tmpdir);
#elif defined(_WIN32)
snprintf(config_dir, sizeof(config_dir), "%s/AppData/Roaming/Claude", tmpdir);
#else
snprintf(config_dir, sizeof(config_dir), "%s/.config/Claude", tmpdir);
#endif
ASSERT_EQ(test_mkdirp(config_dir), 0);
snprintf(config_path, sizeof(config_path), "%s/claude_desktop_config.json", config_dir);
char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"claude-desktop\"") != NULL);
ASSERT(strstr(json, "claude_desktop_config.json") != NULL);
struct stat st;
ASSERT_NEQ(stat(config_path, &st), 0);
free(json);
ASSERT_EQ(
write_test_file(config_path, "{\"mcpServers\":{\"foreign\":{\"command\":\"keep-me\"}}}"),
0);
char binary[768];
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
ASSERT_EQ(cbm_install_editor_mcp(binary, config_path), 0);
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
cli_env_restore(&home);
const char *contents = read_test_file(config_path);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "keep-me") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_reference_harnesses_uninstall_owned_entries_only) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-reference-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *dirs[] = {".qwen", ".codeium/windsurf"};
for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
char path[512];
snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]);
ASSERT_EQ(test_mkdirp(path), 0);
}
const char *config_rel[] = {".qwen/settings.json", ".codeium/windsurf/mcp_config.json"};
char binary[768];
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
char config_paths[2][768];
for (size_t i = 0; i < 2; i++) {
snprintf(config_paths[i], sizeof(config_paths[i]), "%s/%s", tmpdir, config_rel[i]);
ASSERT_EQ(write_test_file(
config_paths[i],
"{\"mcpServers\":{\"foreign\":{\"command\":\"keep-me\"}}}"),
0);
ASSERT_EQ(cbm_install_editor_mcp(binary, config_paths[i]), 0);
}
const char *instruction_rel[] = {".qwen/QWEN.md"};
char instruction_paths[1][768];
for (size_t i = 0; i < 1; i++) {
snprintf(instruction_paths[i], sizeof(instruction_paths[i]), "%s/%s", tmpdir,
instruction_rel[i]);
ASSERT_EQ(write_test_file(instruction_paths[i], "user-authored guidance\n"), 0);
ASSERT_EQ(cbm_upsert_instructions(instruction_paths[i], cbm_get_agent_instructions()), 0);
}
cli_env_snapshot_t home = {0};
ASSERT_TRUE(cli_env_snapshot(&home, "HOME"));
cbm_setenv("HOME", tmpdir, 1);
char *args[] = {"-n"};
ASSERT_EQ(cbm_cmd_uninstall(1, args), 0);
for (size_t i = 0; i < 2; i++) {
const char *contents = read_test_file(config_paths[i]);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "keep-me") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp") == NULL);
}
for (size_t i = 0; i < 1; i++) {
const char *contents = read_test_file(instruction_paths[i]);
ASSERT_NOT_NULL(contents);
ASSERT(strstr(contents, "user-authored guidance") != NULL);
ASSERT(strstr(contents, "codebase-memory-mcp:start") == NULL);
}
extern void cbm_set_auto_answer_for_test(int value);
cbm_set_auto_answer_for_test(0);
cli_env_restore(&home);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #330: Codex SessionStart reminder hook in config.toml — installed,
* idempotent, preserves other content, and cleanly removed. */
TEST(cli_codex_session_hook_issue330) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codexhook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/config.toml", tmpdir);
write_test_file(cfg, "[mcp_servers.other]\ncommand = \"x\"\n");
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT(strstr(d, "[[hooks.SessionStart]]") != NULL);
ASSERT(strstr(d, "[[hooks.SessionStart.hooks]]") != NULL);
ASSERT(strstr(d, "codebase-memory-mcp hook-augment") != NULL);
ASSERT(strstr(d, "[mcp_servers.other]") != NULL); /* pre-existing content preserved */
/* Idempotent: a second upsert leaves exactly ONE hook block. */
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
d = read_test_file(cfg);
const char *first = strstr(d, "[[hooks.SessionStart]]");
ASSERT_NOT_NULL(first);
ASSERT_NULL(strstr(first + 1, "[[hooks.SessionStart]]"));
ASSERT_EQ(cbm_remove_codex_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "hooks.SessionStart"));
ASSERT_NULL(strstr(d, "hooks.SubagentStart"));
ASSERT(strstr(d, "[mcp_servers.other]") != NULL); /* still preserved after removal */
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_codex_mcp_and_hook_upserts_are_idempotent) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codexhook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/config.toml", tmpdir);
write_test_file(cfg, "model = \"gpt-5\"\n");
ASSERT_EQ(cbm_upsert_codex_mcp("/usr/local/bin/codebase-memory-mcp", cfg), 0);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
ASSERT_EQ(cbm_upsert_codex_mcp("/usr/local/bin/codebase-memory-mcp", cfg), 0);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT_EQ(count_substr(d, "[mcp_servers.codebase-memory-mcp]"), 1);
ASSERT_EQ(count_substr(d, "# >>> codebase-memory-mcp SessionStart >>>"), 1);
ASSERT_EQ(count_substr(d, "# <<< codebase-memory-mcp SessionStart <<<"), 1);
ASSERT_EQ(count_substr(d, "[[hooks.SessionStart]]"), 1);
ASSERT_EQ(count_substr(d, "[[hooks.SessionStart.hooks]]"), 1);
ASSERT(strstr(d, "model = \"gpt-5\"") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_codex_hook_upsert_rejects_orphan_end_sentinel) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codexhook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/config.toml", tmpdir);
write_test_file(cfg, "model = \"gpt-5\"\n"
"\n"
"[[hooks.SessionStart]]\n"
"[[hooks.SessionStart.hooks]]\n"
"type = \"command\"\n"
"command = 'echo old'\n"
"# <<< codebase-memory-mcp SessionStart <<<\n"
"\n"
"# >>> codebase-memory-mcp SessionStart >>>\n"
"[[hooks.SessionStart]]\n"
"[[hooks.SessionStart.hooks]]\n"
"type = \"command\"\n"
"command = 'echo duplicate'\n"
"# <<< codebase-memory-mcp SessionStart <<<\n");
const char *before = read_test_file(cfg);
ASSERT_NOT_NULL(before);
char *snapshot = strdup(before);
ASSERT_NOT_NULL(snapshot);
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), -1);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT_STR_EQ(d, snapshot);
free(snapshot);
test_rmdir_r(tmpdir);
PASS();
}
/* Gemini/Antigravity SessionStart reminder parity (settings.json JSON path). */
TEST(cli_gemini_session_hook_parity) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-gemhook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/settings.json", tmpdir);
ASSERT_EQ(cbm_upsert_gemini_session_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT(strstr(d, "SessionStart") != NULL);
ASSERT(strstr(d, "search_graph") != NULL);
ASSERT(strstr(d, "\"matcher\": \"startup\"") != NULL);
ASSERT(strstr(d, "\"matcher\": \"resume\"") != NULL);
ASSERT(strstr(d, "\"matcher\": \"clear\"") != NULL);
ASSERT(strstr(d, "startup|resume|clear") == NULL);
ASSERT_EQ(cbm_remove_gemini_session_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "SessionStart"));
test_rmdir_r(tmpdir);
PASS();
}
/* Claude SubagentStart reminder: subagents spawned via the Agent tool do not
* fire SessionStart, so this hook is their code-discovery channel. Verify the
* install shape, idempotent re-install, and clean removal. */
TEST(cli_claude_subagent_hook) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-subhook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/settings.json", tmpdir);
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT(strstr(d, "SubagentStart") != NULL);
ASSERT(strstr(d, "\"*\"") != NULL); /* match-all matcher */
ASSERT(strstr(d, "cbm-subagent-reminder") != NULL); /* points at the hook script */
/* Idempotent: a second upsert must not duplicate our entry. */
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
int count = 0;
for (const char *p = d; (p = strstr(p, "cbm-subagent-reminder")) != NULL; p++)
count++;
ASSERT_EQ(count, 1);
ASSERT_EQ(cbm_remove_claude_subagent_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NULL(strstr(d, "SubagentStart"));
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_claude_hook_mutation_converges_mixed_owned_duplicates) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX fixture for platform-neutral hook mutation");
#else
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-duplicates-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char cfg[640];
char current_command[1024];
char released_command[1024];
char original[8192];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
snprintf(cfg, sizeof(cfg), "%s/settings.json", config_dir);
snprintf(released_command, sizeof(released_command), "%s/hooks/cbm-subagent-reminder",
config_dir);
test_mkdirp(config_dir);
char *saved_config = save_test_env("CLAUDE_CONFIG_DIR");
cbm_setenv("CLAUDE_CONFIG_DIR", config_dir, 1);
ASSERT_EQ(cbm_resolve_claude_hook_command_for_testing("cbm-subagent-reminder", false,
current_command, sizeof(current_command)),
0);
snprintf(original, sizeof(original),
"{\"hooks\":{\"SubagentStart\":["
"{\"matcher\":\"*\",\"hooks\":[{\"type\":\"command\",\"command\":\"%s\"}]},"
"{\"matcher\":\"*\",\"hooks\":[{\"type\":\"command\",\"command\":\"%s\"}]},"
"{\"matcher\":\"*\",\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo user-subagent-hook\"}]}]}}\n",
current_command, released_command);
write_test_file(cfg, original);
int upsert_rc = cbm_upsert_claude_subagent_hooks(cfg);
char *after_upsert = read_test_file_alloc(cfg);
bool converged = upsert_rc == 0 && after_upsert &&
test_count_substring(after_upsert, "cbm-subagent-reminder") == 1U &&
strstr(after_upsert, "echo user-subagent-hook");
free(after_upsert);
write_test_file(cfg, original);
int remove_rc = cbm_remove_claude_subagent_hooks(cfg);
char *after_remove = read_test_file_alloc(cfg);
bool removed_all = remove_rc == 0 && after_remove &&
!strstr(after_remove, "cbm-subagent-reminder") &&
strstr(after_remove, "echo user-subagent-hook");
free(after_remove);
restore_test_env("CLAUDE_CONFIG_DIR", saved_config);
test_rmdir_r(tmpdir);
if (!converged || !removed_all)
FAIL("hook mutation must converge mixed exact-owned duplicates while preserving foreign "
"siblings");
#endif
PASS();
}
/* A user's own catch-all ("*") SubagentStart hook must survive CMM install and
* uninstall: ownership is keyed on the command, not just the "*" matcher. */
TEST(cli_claude_subagent_hook_preserves_user_entry) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-subuser-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cfg[512];
snprintf(cfg, sizeof(cfg), "%s/settings.json", tmpdir);
/* Pre-existing user SubagentStart hook, also matcher "*", different command. */
write_test_file(
cfg, "{\"hooks\":{\"SubagentStart\":[{\"matcher\":\"*\","
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo user-subagent-hook\"}]}]}}");
/* Install CMM's hook: the user's "*" entry must remain, ours added alongside. */
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
const char *d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT(strstr(d, "echo user-subagent-hook") != NULL); /* user's hook untouched */
ASSERT(strstr(d, "cbm-subagent-reminder") != NULL); /* ours added */
/* Remove CMM's hook: the user's entry must still be intact, ours gone. */
ASSERT_EQ(cbm_remove_claude_subagent_hooks(cfg), 0);
d = read_test_file(cfg);
ASSERT_NOT_NULL(d);
ASSERT(strstr(d, "echo user-subagent-hook") != NULL); /* user's hook preserved */
ASSERT_NULL(strstr(d, "cbm-subagent-reminder")); /* only ours removed */
test_rmdir_r(tmpdir);
PASS();
}
/* SessionStart source matchers are common user choices. Matching a source is
* not ownership proof: install must retain a foreign command with the same
* matcher and add the codebase-memory hook alongside it. */
TEST(cli_claude_session_hook_preserves_user_entry) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-session-user-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char settings_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
test_mkdirp(config_dir);
write_test_file(settings_path, "{\"hooks\":{\"SessionStart\":[{\"matcher\":\"startup\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo user-session-hook\"}]}]}}\n");
char *saved_path = save_test_env("PATH");
char *saved_config = save_test_env("CLAUDE_CONFIG_DIR");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
char *installed = read_test_file_alloc(settings_path);
bool preserved = installed && strstr(installed, "echo user-session-hook") &&
strstr(installed, "cbm-session-reminder");
free(installed);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_config);
test_rmdir_r(tmpdir);
if (!preserved)
FAIL("Claude SessionStart install must preserve foreign hooks sharing a matcher");
PASS();
}
/* Session/subagent augmentation must use the same bounded compiled path as the
* PreToolUse augmenter. Static shell payloads cannot resolve the active graph
* project and drift independently from the tested hook JSON contract. */
TEST(cli_claude_lifecycle_hooks_delegate_to_augmenter) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-lifecycle-hooks-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
test_mkdirp(config_dir);
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_codex = save_test_env("CODEX_HOME");
char *saved_opencode = save_test_env("OPENCODE_CONFIG");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("CODEX_HOME");
cbm_unsetenv("OPENCODE_CONFIG");
const char *binary = "/opt/codebase memory/bin/codebase-memory-mcp";
cbm_install_agent_configs(tmpdir, binary, false, false);
char session_path[640];
char subagent_path[640];
char settings_path[640];
#ifdef _WIN32
snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder.cmd", config_dir);
snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder.cmd",
config_dir);
#else
snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder", config_dir);
snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder", config_dir);
#endif
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
char *session = read_test_file_alloc(session_path);
char *subagent = read_test_file_alloc(subagent_path);
char *settings = read_test_file_alloc(settings_path);
bool delegates = session && subagent && strstr(session, binary) && strstr(subagent, binary) &&
strstr(session, "hook-augment") && strstr(subagent, "hook-augment");
bool no_static_payload =
session && subagent && !strstr(session, "cat <<") && !strstr(subagent, "cat <<");
bool events_installed =
settings && strstr(settings, "SessionStart") && strstr(settings, "SubagentStart");
const char *session_event = settings ? strstr(settings, "\"SessionStart\"") : NULL;
const char *subagent_event = settings ? strstr(settings, "\"SubagentStart\"") : NULL;
const char *session_timeout = session_event ? strstr(session_event, "\"timeout\": 5") : NULL;
const char *subagent_timeout = subagent_event ? strstr(subagent_event, "\"timeout\": 5") : NULL;
bool lifecycle_timeouts =
session_timeout && subagent_event && session_timeout < subagent_event && subagent_timeout;
free(session);
free(subagent);
free(settings);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
restore_test_env("CODEX_HOME", saved_codex);
restore_test_env("OPENCODE_CONFIG", saved_opencode);
test_rmdir_r(tmpdir);
if (!delegates || !no_static_payload || !events_installed || !lifecycle_timeouts)
FAIL("SessionStart and SubagentStart must delegate to the compiled augmenter with bounded "
"timeouts");
PASS();
}
TEST(cli_copilot_install_preserves_foreign_named_manifest) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-copilot-foreign-install-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char manifest_path[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.copilot/hooks", tmpdir);
snprintf(manifest_path, sizeof(manifest_path), "%s/codebase-memory-mcp.json", hooks_dir);
test_mkdirp(hooks_dir);
const char *foreign = "{\"version\":1,\"hooks\":{\"sessionStart\":[{\"type\":\"command\","
"\"bash\":\"user-hook\"}]},\"owner\":\"user\"}\n";
write_test_file(manifest_path, foreign);
char *saved_path = save_test_env("PATH");
char *saved_copilot = save_test_env("COPILOT_HOME");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("COPILOT_HOME");
cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
char *after = read_test_file_alloc(manifest_path);
bool preserved = after && strcmp(after, foreign) == 0;
free(after);
restore_test_env("PATH", saved_path);
restore_test_env("COPILOT_HOME", saved_copilot);
test_rmdir_r(tmpdir);
if (!preserved)
FAIL("Copilot install must fail closed on a foreign same-named hook manifest");
PASS();
}
TEST(cli_copilot_uninstall_preserves_foreign_named_manifest) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-copilot-foreign-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char manifest_path[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.copilot/hooks", tmpdir);
snprintf(manifest_path, sizeof(manifest_path), "%s/codebase-memory-mcp.json", hooks_dir);
test_mkdirp(hooks_dir);
const char *foreign = "{\"version\":1,\"hooks\":{\"sessionStart\":[{\"type\":\"command\","
"\"bash\":\"user-hook\"}]},\"owner\":\"user\"}\n";
write_test_file(manifest_path, foreign);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_copilot = save_test_env("COPILOT_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("COPILOT_HOME");
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *after = read_test_file_alloc(manifest_path);
bool preserved = after && strcmp(after, foreign) == 0;
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("COPILOT_HOME", saved_copilot);
test_rmdir_r(tmpdir);
if (rc != 0 || !preserved)
FAIL("Copilot uninstall must preserve a foreign same-named hook manifest");
PASS();
}
TEST(cli_copilot_uninstall_preserves_canonical_shaped_foreign_manifest) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-copilot-canonical-foreign-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char manifest_path[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.copilot/hooks", tmpdir);
snprintf(manifest_path, sizeof(manifest_path), "%s/codebase-memory-mcp.json", hooks_dir);
test_mkdirp(hooks_dir);
const char *foreign =
"{\"version\":1,\"hooks\":{"
"\"sessionStart\":[{\"type\":\"command\","
"\"bash\":\"/opt/foreign/cbm hook-augment --event SessionStart --dialect copilot\","
"\"powershell\":\"& /opt/foreign/cbm hook-augment --event SessionStart --dialect "
"copilot\",\"timeoutSec\":5}],"
"\"subagentStart\":[{\"type\":\"command\","
"\"bash\":\"/opt/foreign/cbm hook-augment --event SubagentStart --dialect copilot\","
"\"powershell\":\"& /opt/foreign/cbm hook-augment --event SubagentStart --dialect "
"copilot\",\"timeoutSec\":5}]}}\n";
write_test_file(manifest_path, foreign);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_copilot = save_test_env("COPILOT_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("COPILOT_HOME");
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
char *after = read_test_file_alloc(manifest_path);
bool preserved = after && strcmp(after, foreign) == 0;
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("COPILOT_HOME", saved_copilot);
test_rmdir_r(tmpdir);
if (rc != 0 || !preserved)
FAIL("Copilot uninstall must not claim a canonical-shaped manifest for another binary");
PASS();
}
TEST(cli_vscode_only_installs_copilot_durable_context) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-vscode-durable-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char code_user[640];
#ifdef __APPLE__
snprintf(code_user, sizeof(code_user), "%s/Library/Application Support/Code/User", tmpdir);
#elif defined(_WIN32)
snprintf(code_user, sizeof(code_user), "%s/AppData/Roaming/Code/User", tmpdir);
#else
snprintf(code_user, sizeof(code_user), "%s/.config/Code/User", tmpdir);
#endif
test_mkdirp(code_user);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_copilot = save_test_env("COPILOT_HOME");
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
char *saved_appdata = save_test_env("APPDATA");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("COPILOT_HOME");
#if !defined(__APPLE__) && !defined(_WIN32)
char xdg[512];
snprintf(xdg, sizeof(xdg), "%s/.config", tmpdir);
cbm_setenv("XDG_CONFIG_HOME", xdg, 1);
#elif defined(_WIN32)
char appdata[512];
snprintf(appdata, sizeof(appdata), "%s/AppData/Roaming", tmpdir);
cbm_setenv("APPDATA", appdata, 1);
#endif
char binary[640];
#ifdef _WIN32
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir);
#else
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
#endif
cbm_install_agent_configs(tmpdir, binary, false, false);
int second_install_rc = cbm_install_agent_configs(tmpdir, binary, false, false);
char hook_path[640];
char skill_path[640];
char agent_path[640];
char copilot_mcp_path[640];
char copilot_instructions_path[640];
snprintf(hook_path, sizeof(hook_path), "%s/.copilot/hooks/codebase-memory-mcp.json", tmpdir);
snprintf(skill_path, sizeof(skill_path), "%s/.copilot/skills/codebase-memory/SKILL.md", tmpdir);
snprintf(agent_path, sizeof(agent_path), "%s/.copilot/agents/codebase-memory.agent.md", tmpdir);
snprintf(copilot_mcp_path, sizeof(copilot_mcp_path), "%s/.copilot/mcp-config.json", tmpdir);
snprintf(copilot_instructions_path, sizeof(copilot_instructions_path),
"%s/.copilot/copilot-instructions.md", tmpdir);
char *hook = read_test_file_alloc(hook_path);
char *skill = read_test_file_alloc(skill_path);
char *agent = read_test_file_alloc(agent_path);
struct stat absent_mcp;
bool hook_installed = hook && strstr(hook, "sessionStart") && strstr(hook, "subagentStart") &&
strstr(hook, "--dialect copilot");
bool skill_installed = skill && strstr(skill, "search_graph");
bool agent_installed = agent && strstr(agent, "search_graph") && strstr(agent, "tools:");
bool mcp_absent = stat(copilot_mcp_path, &absent_mcp) != 0;
bool instructions_absent = stat(copilot_instructions_path, &absent_mcp) != 0;
bool installed = second_install_rc == 0 && hook_installed && skill_installed &&
agent_installed && mcp_absent && instructions_absent;
free(hook);
free(skill);
free(agent);
const char *modified = "user-modified-vscode-agent\n";
write_test_file(agent_path, modified);
char *argv[] = {"uninstall", "--yes"};
int rc = cbm_cmd_uninstall(2, argv);
struct stat removed_hook;
struct stat removed_skill;
char *preserved = read_test_file_alloc(agent_path);
bool hook_removed = stat(hook_path, &removed_hook) != 0;
bool skill_removed = stat(skill_path, &removed_skill) != 0;
bool modified_agent_preserved = preserved && strcmp(preserved, modified) == 0;
bool ownership_safe = hook_removed && skill_removed && modified_agent_preserved;
free(preserved);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("COPILOT_HOME", saved_copilot);
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
restore_test_env("APPDATA", saved_appdata);
test_rmdir_r(tmpdir);
if (rc != 0 || !installed || !ownership_safe) {
fprintf(stderr,
"VS Code durable diag install_rc=%d hook=%d skill=%d agent=%d mcp_absent=%d "
"instructions_absent=%d uninstall_rc=%d hook_removed=%d skill_removed=%d "
"agent_preserved=%d\n",
second_install_rc, hook_installed, skill_installed, agent_installed, mcp_absent,
instructions_absent, rc, hook_removed, skill_removed, modified_agent_preserved);
FAIL("VS Code-only installs must receive current user skill, read-only agent, and "
"SessionStart/SubagentStart context without a Copilot CLI MCP config");
}
PASS();
}
TEST(cli_lifecycle_hooks_preserve_foreign_substring_commands) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-ownership-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char qwen_dir[512];
char factory_dir[512];
char qwen_settings[640];
char factory_hooks[640];
char binary_path[640];
snprintf(qwen_dir, sizeof(qwen_dir), "%s/.qwen", tmpdir);
snprintf(factory_dir, sizeof(factory_dir), "%s/.factory", tmpdir);
snprintf(qwen_settings, sizeof(qwen_settings), "%s/settings.json", qwen_dir);
snprintf(factory_hooks, sizeof(factory_hooks), "%s/hooks.json", factory_dir);
#ifdef _WIN32
snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir);
#else
snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp", tmpdir);
#endif
test_mkdirp(qwen_dir);
test_mkdirp(factory_dir);
const char *qwen_foreign =
"{\"hooks\":{"
"\"SessionStart\":[{\"matcher\":\"startup|resume|clear|compact\",\"hooks\":[{"
"\"type\":\"command\",\"command\":\"/opt/user-codebase-memory-mcp-wrapper "
"--keep-session\"}]}],"
"\"SubagentStart\":[{\"matcher\":\"*\",\"hooks\":[{\"type\":\"command\","
"\"command\":\"/opt/user-codebase-memory-mcp-wrapper --keep-subagent\"}]}]}}\n";
const char *factory_foreign =
"{\"hooks\":{\"SessionStart\":[{\"hooks\":[{\"type\":\"command\","
"\"command\":\"/opt/user-codebase-memory-mcp-wrapper --keep-factory\"}]}]}}\n";
write_test_file(qwen_settings, qwen_foreign);
write_test_file(factory_hooks, factory_foreign);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
int install_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false);
char *qwen_after_install = read_test_file_alloc(qwen_settings);
char *factory_after_install = read_test_file_alloc(factory_hooks);
bool qwen_install_preserved =
qwen_after_install && strstr(qwen_after_install, "--keep-session") &&
strstr(qwen_after_install, "--keep-subagent") && strstr(qwen_after_install, "hook-augment");
#ifdef _WIN32
bool factory_install_preserved = factory_after_install &&
strcmp(factory_after_install, factory_foreign) == 0 &&
!strstr(factory_after_install, "hook-augment");
#else
bool factory_install_preserved = factory_after_install &&
strstr(factory_after_install, "--keep-factory") &&
strstr(factory_after_install, "hook-augment");
#endif
bool install_preserved = qwen_install_preserved && factory_install_preserved;
free(qwen_after_install);
free(factory_after_install);
char *argv[] = {"uninstall", "--yes"};
int uninstall_rc = cbm_cmd_uninstall(2, argv);
char *qwen_after_uninstall = read_test_file_alloc(qwen_settings);
char *factory_after_uninstall = read_test_file_alloc(factory_hooks);
bool qwen_uninstall_preserved = qwen_after_uninstall &&
strstr(qwen_after_uninstall, "--keep-session") &&
strstr(qwen_after_uninstall, "--keep-subagent") &&
!strstr(qwen_after_uninstall, "hook-augment");
#ifdef _WIN32
bool factory_uninstall_preserved =
factory_after_uninstall && strcmp(factory_after_uninstall, factory_foreign) == 0;
#else
bool factory_uninstall_preserved = factory_after_uninstall &&
strstr(factory_after_uninstall, "--keep-factory") &&
!strstr(factory_after_uninstall, "hook-augment");
#endif
bool uninstall_preserved = qwen_uninstall_preserved && factory_uninstall_preserved;
free(qwen_after_uninstall);
free(factory_after_uninstall);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (install_rc != 0 || uninstall_rc != 0 || !install_preserved || !uninstall_preserved)
FAIL("Qwen and Factory hooks must preserve foreign commands that merely contain the "
"product name");
PASS();
}
TEST(cli_read_only_agents_do_not_receive_mutating_mcp_server) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-readonly-agent-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char qoder_dir[512];
char junie_dir[512];
char kiro_dir[512];
snprintf(qoder_dir, sizeof(qoder_dir), "%s/.qoder", tmpdir);
snprintf(junie_dir, sizeof(junie_dir), "%s/.junie", tmpdir);
snprintf(kiro_dir, sizeof(kiro_dir), "%s/.kiro", tmpdir);
test_mkdirp(qoder_dir);
test_mkdirp(junie_dir);
test_mkdirp(kiro_dir);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_kiro = save_test_env("KIRO_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("KIRO_HOME");
int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char qoder_agent[640];
char junie_agent[640];
char kiro_agent[640];
snprintf(qoder_agent, sizeof(qoder_agent), "%s/agents/codebase-memory.md", qoder_dir);
snprintf(junie_agent, sizeof(junie_agent), "%s/agents/codebase-memory.md", junie_dir);
snprintf(kiro_agent, sizeof(kiro_agent), "%s/agents/codebase-memory.json", kiro_dir);
char *qoder = read_test_file_alloc(qoder_agent);
char *junie = read_test_file_alloc(junie_agent);
char *kiro = read_test_file_alloc(kiro_agent);
bool qoder_confined = qoder && strstr(qoder, "mcpServers:") &&
strstr(qoder, "- codebase-memory-mcp") &&
strstr(qoder, "mcp__codebase-memory-mcp__search_graph") &&
strstr(qoder, "check_index_coverage") && !strstr(qoder, "Bash") &&
!strstr(qoder, "Write") && !strstr(qoder, "Edit");
bool junie_confined = junie && strstr(junie, "mcpServers: [\"codebase-memory-analysis\"]") &&
strstr(junie, "hard-enforces the analysis tool profile") &&
strstr(junie, "tools: [\"Read\", \"Grep\", \"Glob\"]") &&
strstr(junie, "check_index_coverage") && !strstr(junie, "Bash") &&
!strstr(junie, "Write") && !strstr(junie, "Edit");
bool kiro_confined =
kiro && strstr(kiro, "\"mcpServers\"") && strstr(kiro, "\"includeMcpJson\": false") &&
strstr(kiro, "@codebase-memory-mcp/search_graph") && strstr(kiro, "--tool-profile") &&
strstr(kiro, "analysis") && strstr(kiro, "check_index_coverage") &&
!strstr(kiro, "\"@codebase-memory-mcp\"") && !strstr(kiro, "delete_project") &&
!strstr(kiro, "manage_adr") && !strstr(kiro, "index_repository") &&
!strstr(kiro, "ingest_traces");
bool confined = qoder_confined && junie_confined && kiro_confined;
free(qoder);
free(junie);
free(kiro);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("KIRO_HOME", saved_kiro);
test_rmdir_r(tmpdir);
if (rc != 0 || !confined)
FAIL("Kiro, Junie, and Qoder graph agents must remain least privilege");
PASS();
}
TEST(cli_junie_foreign_analysis_alias_falls_back_to_parent_handoff) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-junie-alias-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char junie_dir[512];
char mcp_dir[640];
char config_path[768];
char agent_path[768];
snprintf(junie_dir, sizeof(junie_dir), "%s/.junie", tmpdir);
snprintf(mcp_dir, sizeof(mcp_dir), "%s/mcp", junie_dir);
snprintf(config_path, sizeof(config_path), "%s/mcp.json", mcp_dir);
snprintf(agent_path, sizeof(agent_path), "%s/agents/codebase-memory.md", junie_dir);
test_mkdirp(mcp_dir);
const char *foreign =
"{\"mcpServers\":{\"codebase-memory-analysis\":{\"command\":\"/opt/user-tool\","
"\"args\":[\"--private\"]}},\"theme\":\"dark\"}\n";
write_test_file(config_path, foreign);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char *config = read_test_file_alloc(config_path);
char *agent = read_test_file_alloc(agent_path);
bool safe = rc != 0 && config && strcmp(config, foreign) == 0 && agent &&
strstr(agent, "parent agent must supply") && strstr(agent, "coverage evidence") &&
!strstr(agent, "mcpServers") && !strstr(agent, "codebase-memory-analysis");
free(config);
free(agent);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (!safe)
FAIL("foreign Junie analysis aliases must be preserved and force parent handoff");
PASS();
}
TEST(cli_mcp_installers_preserve_foreign_same_name_entries) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-foreign-mcp-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char json_path[512];
char toml_path[512];
snprintf(json_path, sizeof(json_path), "%s/settings.json", tmpdir);
snprintf(toml_path, sizeof(toml_path), "%s/config.toml", tmpdir);
const char *foreign_json =
"{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":"
"\"/opt/custom/codebase-memory-mcp\",\"args\":[]}},\"theme\":\"dark\"}\n";
const char *foreign_toml = "[mcp_servers.codebase-memory-mcp]\n"
"command = \"/opt/user-tool\"\n"
"args = [\"--private\"]\n"
"env = { KEEP = \"yes\" }\n";
write_test_file(json_path, foreign_json);
int json_install_rc = cbm_install_editor_mcp("/opt/codebase-memory-mcp", json_path);
char *json_after_install = read_test_file_alloc(json_path);
bool json_install_preserved =
json_after_install && strcmp(json_after_install, foreign_json) == 0;
free(json_after_install);
write_test_file(json_path, foreign_json);
int json_remove_rc = cbm_remove_editor_mcp(json_path);
char *json_after_remove = read_test_file_alloc(json_path);
bool json_remove_preserved = json_after_remove && strcmp(json_after_remove, foreign_json) == 0;
free(json_after_remove);
write_test_file(toml_path, foreign_toml);
int toml_install_rc = cbm_upsert_codex_mcp("/opt/codebase-memory-mcp", toml_path);
char *toml_after_install = read_test_file_alloc(toml_path);
bool toml_install_preserved =
toml_after_install && strcmp(toml_after_install, foreign_toml) == 0;
free(toml_after_install);
write_test_file(toml_path, foreign_toml);
int toml_remove_rc = cbm_remove_codex_mcp(toml_path);
char *toml_after_remove = read_test_file_alloc(toml_path);
bool toml_remove_preserved = toml_after_remove && strcmp(toml_after_remove, foreign_toml) == 0;
free(toml_after_remove);
test_rmdir_r(tmpdir);
if (json_install_rc == 0 || json_remove_rc != 0 || toml_install_rc == 0 ||
toml_remove_rc != 0 || !json_install_preserved || !json_remove_preserved ||
!toml_install_preserved || !toml_remove_preserved)
FAIL("generic JSON and Codex MCP installers must fail closed on foreign same-name "
"entries and never remove them");
PASS();
}
TEST(cli_installer_rejects_symlinked_agent_roots) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX symlink parent-chain contract");
#else
char tmpdir[256];
char qoder_target[256];
char junie_target[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-linked-roots-XXXXXX");
snprintf(qoder_target, sizeof(qoder_target), "/tmp/cli-linked-qoder-XXXXXX");
snprintf(junie_target, sizeof(junie_target), "/tmp/cli-linked-junie-XXXXXX");
if (!cbm_mkdtemp(tmpdir) || !cbm_mkdtemp(qoder_target) || !cbm_mkdtemp(junie_target))
FAIL("cbm_mkdtemp failed");
char qoder_link[512];
char junie_link[512];
snprintf(qoder_link, sizeof(qoder_link), "%s/.qoder", tmpdir);
snprintf(junie_link, sizeof(junie_link), "%s/.junie", tmpdir);
if (symlink(qoder_target, qoder_link) != 0 || symlink(junie_target, junie_link) != 0)
FAIL("symlink failed");
char qoder_executable[512];
snprintf(qoder_executable, sizeof(qoder_executable), "%s/qodercli", tmpdir);
write_test_file(qoder_executable, "#!/bin/sh\nexit 0\n");
if (chmod(qoder_executable, 0700) != 0)
FAIL("chmod qodercli failed");
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
(void)cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char outside_qoder_settings[512];
char outside_qoder_skill[512];
char outside_junie_mcp[512];
char outside_junie_agent[512];
snprintf(outside_qoder_settings, sizeof(outside_qoder_settings), "%s/settings.json",
qoder_target);
snprintf(outside_qoder_skill, sizeof(outside_qoder_skill), "%s/skills/codebase-memory/SKILL.md",
qoder_target);
snprintf(outside_junie_mcp, sizeof(outside_junie_mcp), "%s/mcp/mcp.json", junie_target);
snprintf(outside_junie_agent, sizeof(outside_junie_agent), "%s/agents/codebase-memory.md",
junie_target);
struct stat state;
bool refused = stat(outside_qoder_settings, &state) != 0 &&
stat(outside_qoder_skill, &state) != 0 && stat(outside_junie_mcp, &state) != 0 &&
stat(outside_junie_agent, &state) != 0;
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
cbm_unlink(qoder_link);
cbm_unlink(junie_link);
test_rmdir_r(tmpdir);
test_rmdir_r(qoder_target);
test_rmdir_r(junie_target);
if (!refused)
FAIL("installer must not follow symlinked agent roots outside the selected home");
PASS();
#endif
}
TEST(cli_claude_hook_scripts_shell_quote_binary_path) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX shell quoting contract");
#endif
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-quote-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
test_mkdirp(config_dir);
char copilot_dir[512];
snprintf(copilot_dir, sizeof(copilot_dir), "%s/.copilot", tmpdir);
test_mkdirp(copilot_dir);
char copilot_marker[640];
snprintf(copilot_marker, sizeof(copilot_marker), "%s/mcp-config.json", copilot_dir);
write_test_file(copilot_marker, "{}\n");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_copilot = save_test_env("COPILOT_HOME");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("COPILOT_HOME");
const char *binary = "/opt/$(touch cbm-hook-pwned)/it's codebase-memory-mcp";
cbm_install_agent_configs(tmpdir, binary, false, false);
const char *const names[] = {
"cbm-code-discovery-gate",
"cbm-session-reminder",
"cbm-subagent-reminder",
};
bool safely_quoted = true;
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
char path[640];
snprintf(path, sizeof(path), "%s/hooks/%s", config_dir, names[i]);
char *script = read_test_file_alloc(path);
safely_quoted = safely_quoted && script && strstr(script, "BIN='") &&
strstr(script, "'\\''") && !strstr(script, "BIN=\"");
free(script);
}
char manifest_path[640];
snprintf(manifest_path, sizeof(manifest_path), "%s/hooks/codebase-memory-mcp.json",
copilot_dir);
char *manifest = read_test_file_alloc(manifest_path);
yyjson_doc *manifest_doc = manifest ? yyjson_read(manifest, strlen(manifest), 0) : NULL;
if (manifest_doc) {
yyjson_val *hooks = yyjson_obj_get(yyjson_doc_get_root(manifest_doc), "hooks");
yyjson_val *session = hooks ? yyjson_obj_get(hooks, "sessionStart") : NULL;
yyjson_val *entry = session && yyjson_is_arr(session) ? yyjson_arr_get(session, 0U) : NULL;
const char *bash = entry ? yyjson_get_str(yyjson_obj_get(entry, "bash")) : NULL;
const char *powershell = entry ? yyjson_get_str(yyjson_obj_get(entry, "powershell")) : NULL;
safely_quoted = safely_quoted && bash && powershell && strstr(bash, "'\\''") &&
strstr(bash, "--dialect copilot") && strstr(powershell, "& '") &&
strstr(powershell, "it''s") && strstr(powershell, "--dialect copilot");
yyjson_doc_free(manifest_doc);
} else {
safely_quoted = false;
}
free(manifest);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
restore_test_env("COPILOT_HOME", saved_copilot);
test_rmdir_r(tmpdir);
if (!safely_quoted)
FAIL("hook scripts must shell-quote paths without command substitution");
PASS();
}
TEST(cli_claude_hook_commands_shell_quote_custom_config_dir) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX shell quoting contract");
#endif
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-config-quote-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[640];
snprintf(config_dir, sizeof(config_dir), "%s/custom claude;$(touch cbm-hook-path-pwned)",
tmpdir);
test_mkdirp(config_dir);
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
cbm_setenv("PATH", tmpdir, 1);
cbm_setenv("CLAUDE_CONFIG_DIR", config_dir, 1);
cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char settings_path[768];
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
char *settings = read_test_file_alloc(settings_path);
char quoted_prefix[704];
snprintf(quoted_prefix, sizeof(quoted_prefix), "'%s/hooks/", config_dir);
bool quoted = settings && strstr(settings, quoted_prefix) &&
strstr(settings, "cbm-code-discovery-gate'") &&
strstr(settings, "cbm-session-reminder'") &&
strstr(settings, "cbm-subagent-reminder'");
free(settings);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
test_rmdir_r(tmpdir);
if (!quoted)
FAIL("Claude settings must shell-quote the complete custom hook script path");
PASS();
}
TEST(cli_codex_migrates_to_single_hook_representation) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-hook-migrate-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char codex_dir[512];
snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir);
test_mkdirp(codex_dir);
char *saved_path = save_test_env("PATH");
char *saved_codex = save_test_env("CODEX_HOME");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CODEX_HOME");
cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char hooks_path[640];
char config_path[640];
snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir);
snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir);
write_test_file(hooks_path, "{}\n");
cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char *toml = read_test_file_alloc(config_path);
char *hooks = read_test_file_alloc(hooks_path);
bool migrated = toml && !strstr(toml, "codebase-memory-mcp SessionStart") && hooks &&
strstr(hooks, "SessionStart") && strstr(hooks, "SubagentStart");
free(toml);
free(hooks);
restore_test_env("PATH", saved_path);
restore_test_env("CODEX_HOME", saved_codex);
test_rmdir_r(tmpdir);
if (!migrated)
FAIL("Codex install must leave exactly one lifecycle hook representation");
PASS();
}
/* The PreToolUse augmenter parses search_graph's format:"json" payload to
* build additionalContext. This test feeds it the REAL envelope from a live
* in-memory server, so any drift between the response shape and the parser
* fails HERE — not only in the Windows CI guard (which caught exactly that:
* the json-tree reshape left the parser reading a key that no longer exists,
* and the hook silently emitted nothing). */
TEST(cli_hook_augment_context_tracks_search_json_shape) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "hookproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/hookproj");
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "someIndexedSymbol",
.qualified_name = "hookproj.mod.someIndexedSymbol",
.file_path = "mod.py",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
/* The exact request ha_build_args produces: format:"json". */
char *envelope =
cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"hookproj\",\"name_pattern\":\".*someIndexedSymbol.*\","
"\"limit\":5,\"format\":\"json\"}");
ASSERT_NOT_NULL(envelope);
bool is_error = true;
char *ctx =
cbm_hook_augment_format_context_for_testing(envelope, "someIndexedSymbol", &is_error);
ASSERT_FALSE(is_error);
ASSERT_NOT_NULL(ctx); /* one hit MUST produce context — empty = broken hook */
ASSERT_NOT_NULL(strstr(ctx, "someIndexedSymbol"));
ASSERT_NOT_NULL(strstr(ctx, "mod.py"));
free(ctx);
free(envelope);
cbm_mcp_server_free(srv);
PASS();
}
TEST(cli_hook_augment_lifecycle_output_contract) {
static const struct {
const char *event;
const char *scope;
} cases[] = {
{"SessionStart", "Session context"},
{"SubagentStart", "Subagent context"},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char input[512];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"%s\","
"\"cwd\":\"/definitely-not-indexed/cbm-secret-path\"}",
cases[i].event);
char *output = cbm_hook_augment_lifecycle_json(input);
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *specific = yyjson_obj_get(root, "hookSpecificOutput");
ASSERT(specific && yyjson_is_obj(specific));
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(specific, "hookEventName")), cases[i].event);
const char *context = yyjson_get_str(yyjson_obj_get(specific, "additionalContext"));
ASSERT_NOT_NULL(context);
ASSERT(strstr(context, cases[i].scope) != NULL);
ASSERT(strstr(context, "search_graph") != NULL);
ASSERT(strstr(context, "trace_path") != NULL);
ASSERT(strstr(context, "check_index_coverage") != NULL);
ASSERT(strstr(context, "grep") != NULL);
ASSERT(strstr(context, "cbm-secret-path") == NULL);
if (strcmp(cases[i].event, "SessionStart") == 0)
ASSERT(strstr(context, "Active tier: Tier 2") != NULL);
yyjson_doc_free(doc);
free(output);
}
ASSERT_NULL(
cbm_hook_augment_lifecycle_json("{\"hook_event_name\":\"PostToolUse\",\"cwd\":\"/tmp\"}"));
ASSERT_NULL(cbm_hook_augment_lifecycle_json("not-json"));
char *copilot = cbm_hook_augment_lifecycle_json_for(
"{\"cwd\":\"/definitely-not-indexed/cbm-secret-path\"}", "SubagentStart", true);
ASSERT_NOT_NULL(copilot);
yyjson_doc *copilot_doc = yyjson_read(copilot, strlen(copilot), 0);
ASSERT_NOT_NULL(copilot_doc);
yyjson_val *copilot_root = yyjson_doc_get_root(copilot_doc);
const char *copilot_context = yyjson_get_str(yyjson_obj_get(copilot_root, "additionalContext"));
ASSERT_NOT_NULL(copilot_context);
ASSERT(strstr(copilot_context, "Subagent context") != NULL);
ASSERT(strstr(copilot_context, "search_graph") != NULL);
ASSERT(strstr(copilot_context, "cbm-secret-path") == NULL);
ASSERT_NULL(yyjson_obj_get(copilot_root, "hookSpecificOutput"));
yyjson_doc_free(copilot_doc);
free(copilot);
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for("{}", "PostToolUse", true));
PASS();
}
TEST(cli_hook_augment_subagent_tier_router_contract) {
static const struct {
const char *agent_type;
const char *tier;
const char *mode;
} cases[] = {
{"scout", "Tier 1", "quick"},
{"verify", "Tier 2", "verification"},
{"auditor", "Tier 3", "full graph"},
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
char input[512];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"SubagentStart\",\"agent_type\":\"%s\","
"\"cwd\":\"/definitely-not-indexed/tier-router\"}",
cases[i].agent_type);
char *output = cbm_hook_augment_lifecycle_json(input);
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *specific = yyjson_obj_get(yyjson_doc_get_root(doc), "hookSpecificOutput");
const char *context =
specific ? yyjson_get_str(yyjson_obj_get(specific, "additionalContext")) : NULL;
ASSERT_NOT_NULL(context);
char active[64];
snprintf(active, sizeof(active), "Active tier: %s", cases[i].tier);
ASSERT(strstr(context, active) != NULL);
ASSERT(strstr(context, cases[i].mode) != NULL);
ASSERT(strstr(context, "check_index_coverage") != NULL);
ASSERT(strstr(context, "missed") != NULL);
yyjson_doc_free(doc);
free(output);
}
PASS();
}
TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) {
const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart");
const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart");
ASSERT_NOT_NULL(session);
ASSERT_NOT_NULL(subagent);
ASSERT(strstr(session, "Run index_repository") != NULL);
ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL);
ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL);
ASSERT(strstr(subagent, "Run index_repository") == NULL);
PASS();
}
TEST(cli_hook_augment_post_read_event_and_path_contract) {
static const struct {
const char *dialect;
const char *input;
const char *event;
const char *path;
} cases[] = {
{NULL,
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"file_path\":\"src/a.c\"},\"cwd\":\"/repo\"}",
"PostToolUse", "/repo/src/a.c"},
{"gemini",
"{\"hook_event_name\":\"AfterTool\",\"tool_name\":\"read_file\","
"\"tool_input\":{\"file_path\":\"src/b.c\"},\"cwd\":\"/repo\"}",
"AfterTool", "/repo/src/b.c"},
{"qwen",
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"ReadFile\","
"\"tool_input\":{\"path\":\"src\\\\c.c\"},\"cwd\":\"C:/repo\"}",
"PostToolUse", "C:/repo/src/c.c"},
{"qoder",
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"path\":\"src/d.c\"},\"cwd\":\"/repo\"}",
"PostToolUse", "/repo/src/d.c"},
{"factory",
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"file_path\":\"src/e.c\"},\"cwd\":\"/repo\"}",
"PostToolUse", "/repo/src/e.c"},
{"augment",
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"view\","
"\"tool_input\":{\"path\":\"src/f.c\"},\"cwd\":\"/repo\"}",
"PostToolUse", "/repo/src/f.c"},
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
char path[4096];
char *output = cbm_hook_augment_tool_json_for_testing(
cases[i].input, cases[i].dialect, "coverage-context", path, sizeof(path));
ASSERT_NOT_NULL(output);
ASSERT_STR_EQ(path, cases[i].path);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *specific = yyjson_obj_get(yyjson_doc_get_root(doc), "hookSpecificOutput");
ASSERT(specific && yyjson_is_obj(specific));
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(specific, "hookEventName")), cases[i].event);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(specific, "additionalContext")),
"coverage-context");
yyjson_doc_free(doc);
free(output);
}
char path[64];
ASSERT_NULL(cbm_hook_augment_tool_json_for_testing(
"{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"file_path\":\"a.c\"},\"cwd\":\"/repo\"}",
NULL, "context", path, sizeof(path)));
ASSERT_NULL(cbm_hook_augment_tool_json_for_testing(
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"file_path\":\"a.c\"},\"cwd\":\"relative\"}",
NULL, "context", path, sizeof(path)));
PASS();
}
TEST(cli_hook_augment_hermes_dialect_contract) {
const char *input =
"{\"hook_event_name\":\"pre_llm_call\",\"cwd\":\"/unindexed/hermes-project\","
"\"session_id\":\"session-1\",\"user_message\":\"inspect code\"}";
char *output = cbm_hook_augment_lifecycle_json_for_dialect(input, "pre_llm_call", "hermes");
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
ASSERT(root && yyjson_is_obj(root));
yyjson_val *context = yyjson_obj_get(root, "context");
ASSERT(context && yyjson_is_str(context));
ASSERT(strstr(yyjson_get_str(context), "search_graph") != NULL);
ASSERT_EQ(yyjson_obj_size(root), 1U);
ASSERT_NULL(yyjson_obj_get(root, "additionalContext"));
ASSERT_NULL(yyjson_obj_get(root, "hookSpecificOutput"));
ASSERT_NULL(yyjson_obj_get(root, "decision"));
ASSERT_NULL(yyjson_obj_get(root, "permissionDecision"));
yyjson_doc_free(doc);
free(output);
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect("not-json", "pre_llm_call", "hermes"));
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect(
"{\"hook_event_name\":\"post_llm_call\",\"cwd\":\"/tmp\"}", "post_llm_call", "hermes"));
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect(input, "pre_llm_call", "unknown"));
PASS();
}
TEST(cli_hook_augment_qoder_lifecycle_contract) {
const char *input =
"{\"hook_event_name\":\"SessionStart\",\"cwd\":\"/unindexed/qoder-project\","
"\"session_id\":\"session-2\",\"source\":\"compact\"}";
char *output = cbm_hook_augment_lifecycle_json_for_dialect(input, "SessionStart", "qoder");
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *specific = root ? yyjson_obj_get(root, "hookSpecificOutput") : NULL;
ASSERT(specific && yyjson_is_obj(specific));
yyjson_val *event = yyjson_obj_get(specific, "hookEventName");
yyjson_val *context = yyjson_obj_get(specific, "additionalContext");
ASSERT(event && yyjson_is_str(event));
ASSERT_STR_EQ(yyjson_get_str(event), "SessionStart");
ASSERT(context && yyjson_is_str(context));
ASSERT(strstr(yyjson_get_str(context), "search_graph") != NULL);
ASSERT(strstr(yyjson_get_str(context), "Tier 2") != NULL);
ASSERT(strstr(yyjson_get_str(context), "check_index_coverage") != NULL);
ASSERT_NULL(yyjson_obj_get(specific, "permissionDecision"));
ASSERT_NULL(yyjson_obj_get(specific, "permissionDecisionReason"));
ASSERT_NULL(yyjson_obj_get(specific, "updatedInput"));
ASSERT_NULL(yyjson_obj_get(root, "decision"));
ASSERT_NULL(yyjson_obj_get(root, "context"));
yyjson_doc_free(doc);
free(output);
char *subagent = cbm_hook_augment_lifecycle_json_for_dialect(
"{\"hook_event_name\":\"SubagentStart\",\"agent_type\":\"auditor\","
"\"cwd\":\"/tmp\"}",
"SubagentStart", "qoder");
ASSERT_NOT_NULL(subagent);
ASSERT(strstr(subagent, "Tier 3") != NULL);
free(subagent);
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect(
"{\"hook_event_name\":\"UserPromptSubmit\",\"cwd\":\"/tmp\"}", "UserPromptSubmit",
"qoder"));
PASS();
}
#ifndef _WIN32
TEST(cli_qoder_migrates_user_prompt_hook_to_lifecycle_and_read) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-qoder-hook-migrate-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settings[512];
snprintf(settings, sizeof(settings), "%s/settings.json", tmpdir);
const char *binary = "/opt/codebase-memory-mcp";
char command[1024];
char shell[32];
ASSERT_EQ(cbm_build_qoder_hook_command_for_testing(binary, false, command, sizeof(command),
shell, sizeof(shell)),
0);
char legacy[4096];
int written = snprintf(legacy, sizeof(legacy),
"{\"hooks\":{\"UserPromptSubmit\":[{\"hooks\":[{\"type\":\"command\","
"\"command\":\"%s\"}]}]}}",
command);
ASSERT(written > 0 && (size_t)written < sizeof(legacy));
write_test_file(settings, legacy);
ASSERT_EQ(cbm_upsert_qoder_context_hooks_for_testing(settings, binary), 0);
char *upgraded = read_test_file_alloc(settings);
ASSERT_NOT_NULL(upgraded);
ASSERT(strstr(upgraded, "UserPromptSubmit") == NULL);
ASSERT(strstr(upgraded, "SessionStart") != NULL);
ASSERT(strstr(upgraded, "SubagentStart") != NULL);
ASSERT(strstr(upgraded, "PostToolUse") != NULL);
ASSERT(strstr(upgraded, "\"matcher\": \"Read\"") != NULL);
free(upgraded);
ASSERT_EQ(cbm_remove_qoder_context_hooks_for_testing(settings, binary), 0);
char *removed = read_test_file_alloc(settings);
ASSERT_NOT_NULL(removed);
ASSERT(strstr(removed, "--dialect qoder") == NULL);
free(removed);
test_rmdir_r(tmpdir);
PASS();
}
#endif
TEST(cli_hook_augment_kimi_user_prompt_contract) {
const char *input =
"{\"hook_event_name\":\"UserPromptSubmit\",\"cwd\":\"/unindexed/kimi-project\","
"\"session_id\":\"session-3\",\"prompt\":\"inspect code\"}";
char *output = cbm_hook_augment_lifecycle_json_for_dialect(input, "UserPromptSubmit", "kimi");
ASSERT_NOT_NULL(output);
ASSERT(strstr(output, "[codebase-memory] Prompt context") != NULL);
ASSERT(strstr(output, "index_repository") != NULL);
ASSERT(strstr(output, "search_graph") != NULL);
ASSERT(strchr(output, '{') == NULL);
ASSERT(strstr(output, "hookSpecificOutput") == NULL);
free(output);
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect(
"{\"hook_event_name\":\"SessionStart\",\"cwd\":\"/tmp\"}", "SessionStart", "kimi"));
PASS();
}
TEST(cli_hook_augment_devin_lifecycle_contract) {
static const struct {
const char *event;
const char *payload;
const char *scope;
} cases[] = {
{"SessionStart", "\"source\":\"startup\"", "Session context"},
{"UserPromptSubmit", "\"prompt\":\"inspect code\"", "Prompt context"},
{"PostCompaction", "\"summary\":null", "Compaction context"},
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
char input[512];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"%s\",\"cwd\":\"/unindexed/devin\",%s}", cases[i].event,
cases[i].payload);
char *output = cbm_hook_augment_lifecycle_json_for_dialect(input, cases[i].event, "devin");
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *specific = root ? yyjson_obj_get(root, "hookSpecificOutput") : NULL;
const char *event =
specific ? yyjson_get_str(yyjson_obj_get(specific, "hookEventName")) : NULL;
const char *context =
specific ? yyjson_get_str(yyjson_obj_get(specific, "additionalContext")) : NULL;
ASSERT_NOT_NULL(event);
ASSERT_STR_EQ(event, cases[i].event);
ASSERT_NOT_NULL(context);
ASSERT(strstr(context, cases[i].scope) != NULL);
ASSERT(strstr(context, "search_graph") != NULL);
ASSERT_NULL(yyjson_obj_get(root, "decision"));
yyjson_doc_free(doc);
free(output);
}
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect(
"{\"hook_event_name\":\"SubagentStart\"}", "SubagentStart", "devin"));
PASS();
}
TEST(cli_hook_augment_cline_lifecycle_contract) {
static const char *const events[] = {"TaskStart", "TaskResume", "UserPromptSubmit",
"PreCompact"};
for (size_t i = 0U; i < sizeof(events) / sizeof(events[0]); i++) {
char input[512];
snprintf(input, sizeof(input),
"{\"hookName\":\"%s\",\"workspaceRoots\":[\"/unindexed/cline\"]}", events[i]);
char *output = cbm_hook_augment_lifecycle_json_for_dialect(input, events[i], "cline");
ASSERT_NOT_NULL(output);
yyjson_doc *doc = yyjson_read(output, strlen(output), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *cancel = root ? yyjson_obj_get(root, "cancel") : NULL;
yyjson_val *context = root ? yyjson_obj_get(root, "contextModification") : NULL;
yyjson_val *error = root ? yyjson_obj_get(root, "errorMessage") : NULL;
ASSERT(cancel && yyjson_is_bool(cancel) && !yyjson_get_bool(cancel));
ASSERT(context && yyjson_is_str(context));
ASSERT(strstr(yyjson_get_str(context), "search_graph") != NULL);
ASSERT(error && yyjson_is_str(error) && strcmp(yyjson_get_str(error), "") == 0);
ASSERT_NULL(yyjson_obj_get(root, "decision"));
yyjson_doc_free(doc);
free(output);
}
ASSERT_NULL(cbm_hook_augment_lifecycle_json_for_dialect("{\"hookName\":\"SubagentStart\"}",
"SubagentStart", "cline"));
PASS();
}
/* A malformed user-owned hook config must never be treated as an absent file:
* doing so replaces the user's bytes with a fresh hooks object. */
TEST(cli_hook_upsert_rejects_malformed_settings) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-malformed-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settings_path[512];
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", tmpdir);
const char *original = "{ this is not valid JSON\n";
write_test_file(settings_path, original);
int rc = cbm_upsert_claude_hooks(settings_path);
char *after = read_test_file_alloc(settings_path);
bool unchanged = after && strcmp(after, original) == 0;
free(after);
test_rmdir_r(tmpdir);
if (rc != -1 || !unchanged)
FAIL("malformed hook config must fail closed without changing user bytes");
PASS();
}
typedef struct {
const char *content;
int result;
} cli_hook_prewrite_change_t;
static void cli_hook_replace_before_editor(const char *settings_path, void *context) {
cli_hook_prewrite_change_t *change = context;
change->result = write_test_file(settings_path, change->content);
}
TEST(cli_hook_upsert_rejects_concurrent_same_event_update) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-race-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settings[512];
snprintf(settings, sizeof(settings), "%s/settings.json", tmpdir);
write_test_file(settings, "{\"hooks\":{\"BeforeTool\":[{\"matcher\":\"user\",\"hooks\":[{"
"\"type\":\"command\",\"command\":\"echo existing\"}]}]}}\n");
const char *concurrent =
"{\"hooks\":{\"BeforeTool\":[{\"matcher\":\"user\",\"hooks\":[{\"type\":"
"\"command\",\"command\":\"echo existing\"}]},{\"matcher\":\"concurrent\","
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo concurrent\"}]}]}}\n";
cli_hook_prewrite_change_t change = {.content = concurrent, .result = -1};
cbm_set_hook_json_prewrite_hook_for_testing(cli_hook_replace_before_editor, &change);
int result = cbm_upsert_gemini_hooks(settings);
cbm_set_hook_json_prewrite_hook_for_testing(NULL, NULL);
char *after = read_test_file_alloc(settings);
bool preserved = after && strcmp(after, concurrent) == 0;
free(after);
test_rmdir_r(tmpdir);
if (change.result != 0 || result != -1 || !preserved)
FAIL("hook mutation must reject a concurrent same-event update without losing it");
PASS();
}
static const char test_released_session_hook_script[] =
"#!/usr/bin/env bash\n"
"# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n"
"# Installed by codebase-memory-mcp. Fires on startup/resume/clear/compact.\n"
"cat << 'REMINDER'\n"
"CRITICAL - Code Discovery Protocol:\n"
"1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\n"
" - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\n"
" - trace_path(function_name, mode=calls|data_flow|cross_service) for call chains\n"
" - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\n"
" - query_graph(query) for complex Cypher patterns\n"
" - get_architecture(aspects) for project structure\n"
" - search_code(pattern) for text search (graph-augmented grep)\n"
"2. Use Grep/Glob/Read freely for text, configs, non-code files, and\n"
" always Read a file before editing it.\n"
"3. If a project is not indexed yet, run index_repository FIRST.\n"
"REMINDER\n";
static const char test_released_subagent_hook_script[] =
"#!/usr/bin/env bash\n"
"# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n"
"# Installed by codebase-memory-mcp. Fires when any subagent is spawned.\n"
"# SubagentStart injects context via JSON additionalContext, not plain stdout.\n"
"cat << 'REMINDER'\n"
"{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\","
"\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools "
"(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, "
"search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for "
"text, configs, and non-code files.\"}}\n"
"REMINDER\n";
static bool test_build_released_gate_hook_script(const char *binary_path, char *script,
size_t script_size) {
int written = snprintf(script, script_size,
"#!/usr/bin/env bash\n"
"# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
"# NOTE: the legacy filename is kept for zero-migration upgrades.\n"
"# Despite the name this NEVER blocks a tool call - it only adds\n"
"# graph context. Any failure is silent (exit 0, no output).\n"
"BIN=\"%s\"\n"
"[ -x \"$BIN\" ] || exit 0\n"
"\"$BIN\" hook-augment 2>/dev/null\n"
"exit 0\n",
binary_path);
return written > 0 && (size_t)written < script_size;
}
#ifndef _WIN32
TEST(cli_upgrade_migrates_released_claude_hook_scripts) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-upgrade-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char gate_path[640];
char session_path[640];
char subagent_path[640];
char settings_path[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
snprintf(gate_path, sizeof(gate_path), "%s/cbm-code-discovery-gate", hooks_dir);
snprintf(session_path, sizeof(session_path), "%s/cbm-session-reminder", hooks_dir);
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);
char legacy_gate[8192];
ASSERT_TRUE(test_build_released_gate_hook_script("/opt/codebase-memory-mcp", legacy_gate,
sizeof(legacy_gate)));
const char *legacy_session = test_released_session_hook_script;
const char *legacy_subagent = test_released_subagent_hook_script;
write_test_file(gate_path, legacy_gate);
write_test_file(session_path, legacy_session);
write_test_file(subagent_path, legacy_subagent);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_codex = save_test_env("CODEX_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("CODEX_HOME");
int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char *gate = read_test_file_alloc(gate_path);
char *session = read_test_file_alloc(session_path);
char *subagent = read_test_file_alloc(subagent_path);
char *settings = read_test_file_alloc(settings_path);
bool migrated = rc == 0 && gate && strcmp(gate, legacy_gate) != 0 && session &&
strcmp(session, legacy_session) != 0 && subagent &&
strcmp(subagent, legacy_subagent) != 0 && settings &&
strstr(settings, "cbm-code-discovery-gate") &&
strstr(settings, "cbm-session-reminder") &&
strstr(settings, "cbm-subagent-reminder");
free(gate);
free(session);
free(subagent);
free(settings);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
restore_test_env("CODEX_HOME", saved_codex);
test_rmdir_r(tmpdir);
if (!migrated)
FAIL("released Claude hook scripts must migrate byte-exactly and stay registered");
PASS();
}
TEST(cli_upgrade_preserves_near_legacy_claude_hook_script) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-near-legacy-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char gate_path[640];
char settings_path[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
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);
const char *modified_legacy =
"#!/usr/bin/env bash\n"
"# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
"# NOTE: the legacy filename is kept for zero-migration upgrades.\n"
"# Despite the name this NEVER blocks a tool call - it only adds\n"
"# graph context. Any failure is silent (exit 0, no output).\n"
"BIN=\"/opt/codebase-memory-mcp\"\n"
"[ -x \"$BIN\" ] || exit 0\n"
"\"$BIN\" hook-augment 2>/dev/null\n"
"exit 0\n"
"# user change\n";
write_test_file(gate_path, modified_legacy);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_codex = save_test_env("CODEX_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("CODEX_HOME");
(void)cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char *gate = read_test_file_alloc(gate_path);
char *settings = read_test_file_alloc(settings_path);
bool preserved = gate && strcmp(gate, modified_legacy) == 0 &&
(!settings || !strstr(settings, "cbm-code-discovery-gate"));
free(gate);
free(settings);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
restore_test_env("CODEX_HOME", saved_codex);
test_rmdir_r(tmpdir);
if (!preserved)
FAIL("near-legacy Claude hook bytes are foreign and must stay untouched/unregistered");
PASS();
}
TEST(cli_hook_upsert_rejects_linked_settings) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-links-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char target[512];
char settings[512];
snprintf(target, sizeof(target), "%s/user-settings.json", tmpdir);
snprintf(settings, sizeof(settings), "%s/settings.json", tmpdir);
const char *original = "{\"userOwned\":true}\n";
write_test_file(target, original);
ASSERT_EQ(symlink(target, settings), 0);
int symlink_rc = cbm_upsert_claude_hooks(settings);
char *after_symlink = read_test_file_alloc(target);
bool symlink_safe = symlink_rc == -1 && after_symlink && strcmp(after_symlink, original) == 0;
free(after_symlink);
(void)cbm_unlink(settings);
write_test_file(target, original);
ASSERT_EQ(link(target, settings), 0);
int hardlink_rc = cbm_upsert_claude_hooks(settings);
char *after_hardlink = read_test_file_alloc(target);
bool hardlink_safe =
hardlink_rc == -1 && after_hardlink && strcmp(after_hardlink, original) == 0;
free(after_hardlink);
test_rmdir_r(tmpdir);
if (!symlink_safe || !hardlink_safe)
FAIL("hook config edits must reject symlinks and hard links without changing targets");
PASS();
}
TEST(cli_claude_hook_script_collisions_are_not_registered) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX linked-hook ownership contract");
#endif
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-script-collision-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
char victim[640];
char gate[640];
char session[640];
char settings[640];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
snprintf(victim, sizeof(victim), "%s/victim", tmpdir);
snprintf(gate, sizeof(gate), "%s/cbm-code-discovery-gate", hooks_dir);
snprintf(session, sizeof(session), "%s/cbm-session-reminder", hooks_dir);
snprintf(settings, sizeof(settings), "%s/.claude/settings.json", tmpdir);
test_mkdirp(hooks_dir);
write_test_file(victim, "victim-owned\n");
ASSERT_EQ(symlink(victim, gate), 0);
write_test_file(session, "#!/bin/sh\necho user-owned\n");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
char *settings_data = read_test_file_alloc(settings);
char *victim_data = read_test_file_alloc(victim);
char *session_data = read_test_file_alloc(session);
bool safe = victim_data && strcmp(victim_data, "victim-owned\n") == 0 && session_data &&
strcmp(session_data, "#!/bin/sh\necho user-owned\n") == 0 &&
(!settings_data || (!strstr(settings_data, "cbm-code-discovery-gate") &&
!strstr(settings_data, "cbm-session-reminder")));
free(settings_data);
free(victim_data);
free(session_data);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
test_rmdir_r(tmpdir);
if (!safe)
FAIL("foreign or linked Claude hook scripts must be preserved and never registered");
PASS();
}
TEST(cli_codex_legacy_migration_rejects_linked_config) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-link-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char target[512];
char config[512];
snprintf(target, sizeof(target), "%s/user-config.toml", tmpdir);
snprintf(config, sizeof(config), "%s/config.toml", tmpdir);
const char *original = "user_key = true\n\n[mcp_servers.codebase-memory-mcp]\n"
"command = \"old\"\nargs = []\n";
write_test_file(target, original);
ASSERT_EQ(symlink(target, config), 0);
int rc = cbm_upsert_codex_mcp("/usr/local/bin/codebase-memory-mcp", config);
char *after = read_test_file_alloc(target);
bool safe = rc == -1 && after && strcmp(after, original) == 0;
free(after);
test_rmdir_r(tmpdir);
if (!safe)
FAIL("Codex legacy migration must reject linked config without modifying its target");
PASS();
}
#endif
/* Full uninstall owns the three Claude shims it creates and must remove them
* along with their settings.json registrations. */
TEST(cli_uninstall_removes_claude_hook_scripts) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-uninstall-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
test_mkdirp(config_dir);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_codex = save_test_env("CODEX_HOME");
char *saved_opencode = save_test_env("OPENCODE_CONFIG");
char *saved_copilot = save_test_env("COPILOT_HOME");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("CODEX_HOME");
cbm_unsetenv("OPENCODE_CONFIG");
cbm_unsetenv("COPILOT_HOME");
char binary[640];
#ifdef _WIN32
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir);
#else
snprintf(binary, sizeof(binary), "%s/.local/bin/codebase-memory-mcp", tmpdir);
#endif
cbm_install_agent_configs(tmpdir, binary, false, false);
char *args[] = {"-n"};
int rc = cbm_cmd_uninstall(1, args);
#ifdef _WIN32
const char *const names[] = {
"cbm-code-discovery-gate.cmd",
"cbm-session-reminder.cmd",
"cbm-subagent-reminder.cmd",
};
#else
const char *const names[] = {
"cbm-code-discovery-gate",
"cbm-session-reminder",
"cbm-subagent-reminder",
};
#endif
bool removed = true;
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
char path[640];
struct stat state;
snprintf(path, sizeof(path), "%s/hooks/%s", config_dir, names[i]);
#ifdef _WIN32
removed = removed && stat(path, &state) != 0;
#else
removed = removed && lstat(path, &state) != 0;
#endif
}
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
restore_test_env("CODEX_HOME", saved_codex);
restore_test_env("OPENCODE_CONFIG", saved_opencode);
restore_test_env("COPILOT_HOME", saved_copilot);
test_rmdir_r(tmpdir);
if (rc != 0 || !removed)
FAIL("uninstall must remove every Claude hook shim owned by the installer");
PASS();
}
TEST(cli_uninstall_preserves_modified_claude_hook_script) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-preserve-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
test_mkdirp(config_dir);
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false);
char modified_path[640];
snprintf(modified_path, sizeof(modified_path), "%s/hooks/cbm-session-reminder", config_dir);
const char *sentinel = "#!/bin/sh\necho user-modified-session-hook\n";
write_test_file(modified_path, sentinel);
char *args[] = {"-n"};
(void)cbm_cmd_uninstall(1, args);
char *after = read_test_file_alloc(modified_path);
bool preserved = after && strcmp(after, sentinel) == 0;
free(after);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_claude);
test_rmdir_r(tmpdir);
if (!preserved)
FAIL("uninstall must preserve a Claude hook script modified after installation");
PASS();
}
TEST(cli_detect_agents_finds_gemini) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
char settings[640];
snprintf(dir, sizeof(dir), "%s/.gemini", tmpdir);
test_mkdirp(dir);
snprintf(settings, sizeof(settings), "%s/settings.json", dir);
write_test_file(settings, "{}\n");
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.gemini);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_finds_claude_desktop) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
#ifdef __APPLE__
snprintf(dir, sizeof(dir), "%s/Library/Application Support/Claude", tmpdir);
#elif defined(_WIN32)
snprintf(dir, sizeof(dir), "%s/AppData/Roaming/Claude", tmpdir);
#else
snprintf(dir, sizeof(dir), "%s/.config/Claude", tmpdir);
#endif
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.claude_desktop);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_finds_zed) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
#ifdef __APPLE__
snprintf(dir, sizeof(dir), "%s/Library/Application Support/Zed", tmpdir);
#elif defined(_WIN32)
snprintf(dir, sizeof(dir), "%s/AppData/Roaming/Zed", tmpdir);
#else
snprintf(dir, sizeof(dir), "%s/.config/zed", tmpdir);
#endif
test_mkdirp(dir);
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
char xdg_dir[640];
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
ASSERT_TRUE(agents.zed);
test_rmdir_r(tmpdir);
PASS();
}
#if !defined(__APPLE__) && !defined(_WIN32)
TEST(cli_detect_agents_finds_zed_via_xdg_config_home) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-zed-xdg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char xdg[512];
char zed_dir[640];
snprintf(xdg, sizeof(xdg), "%s/custom-config", tmpdir);
snprintf(zed_dir, sizeof(zed_dir), "%s/zed", xdg);
test_mkdirp(zed_dir);
char *saved = save_test_env("XDG_CONFIG_HOME");
cbm_setenv("XDG_CONFIG_HOME", xdg, 1);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
restore_test_env("XDG_CONFIG_HOME", saved);
test_rmdir_r(tmpdir);
if (!agents.zed)
FAIL("Zed detection on Linux must honor XDG_CONFIG_HOME");
PASS();
}
#endif
#ifdef _WIN32
TEST(cli_detect_agents_finds_zed_in_roaming_appdata) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "%s\\cli-zed-win", cbm_tmpdir());
test_rmdir_r(tmpdir);
test_mkdirp(tmpdir);
char zed_dir[512];
snprintf(zed_dir, sizeof(zed_dir), "%s/AppData/Roaming/Zed", tmpdir);
test_mkdirp(zed_dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
test_rmdir_r(tmpdir);
if (!agents.zed)
FAIL("Zed detection on Windows must use Roaming AppData, not Local AppData");
PASS();
}
#endif
TEST(cli_detect_agents_finds_antigravity) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
/* Antigravity CLI installs under ~/.gemini/antigravity-cli/ (2026). */
snprintf(dir, sizeof(dir), "%s/.gemini/antigravity-cli", tmpdir);
test_mkdirp(dir);
char *saved_path = save_test_env("PATH");
cbm_setenv("PATH", tmpdir, 1);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
restore_test_env("PATH", saved_path);
test_rmdir_r(tmpdir);
if (!agents.antigravity || agents.gemini)
FAIL("Antigravity detection must not imply Gemini CLI");
PASS();
}
TEST(cli_detect_agents_finds_kilocode) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
#ifdef __APPLE__
snprintf(dir, sizeof(dir),
"%s/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code", tmpdir);
#elif defined(_WIN32)
snprintf(dir, sizeof(dir), "%s/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code",
tmpdir);
#else
snprintf(dir, sizeof(dir), "%s/.config/Code/User/globalStorage/kilocode.kilo-code", tmpdir);
#endif
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.kilocode);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_finds_modern_kilo) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-kilo-modern-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.config/kilo", tmpdir);
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp");
bool modern_config = json && strstr(json, "/.config/kilo/kilo.jsonc") != NULL;
bool legacy_config =
json && strstr(json, "kilocode.kilo-code/settings/mcp_settings.json") != NULL;
free(json);
test_rmdir_r(tmpdir);
if (!agents.kilocode)
FAIL("modern Kilo installation at ~/.config/kilo must be detected");
if (!modern_config || legacy_config)
FAIL("modern Kilo install plan must target kilo.jsonc, not legacy VS Code globalStorage");
PASS();
}
TEST(cli_detect_agents_finds_kiro) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.kiro", tmpdir);
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.kiro);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #651: Junie (~/.junie/) must be detected so install registers the
* MCP server in ~/.junie/mcp/mcp.json. */
TEST(cli_detect_agents_finds_junie_issue651) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char dir[512];
snprintf(dir, sizeof(dir), "%s/.junie", tmpdir);
test_mkdirp(dir);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_TRUE(agents.junie);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_detect_agents_none_found) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
/* Empty home and isolated PATH must not inherit the host's agents. */
char *saved_ccd = save_test_env("CLAUDE_CONFIG_DIR");
char *saved_codex = save_test_env("CODEX_HOME");
char *saved_path = save_test_env("PATH");
cbm_unsetenv("CLAUDE_CONFIG_DIR");
cbm_unsetenv("CODEX_HOME");
cbm_setenv("PATH", tmpdir, 1);
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
ASSERT_FALSE(agents.claude_code);
ASSERT_FALSE(agents.claude_desktop);
ASSERT_FALSE(agents.codex);
ASSERT_FALSE(agents.gemini);
ASSERT_FALSE(agents.zed);
ASSERT_FALSE(agents.antigravity);
ASSERT_FALSE(agents.kilocode);
ASSERT_FALSE(agents.kiro);
restore_test_env("CLAUDE_CONFIG_DIR", saved_ccd);
restore_test_env("CODEX_HOME", saved_codex);
restore_test_env("PATH", saved_path);
cbm_rmdir(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group B: MCP Config Upsert — Codex TOML
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_upsert_codex_mcp_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/config.toml", tmpdir);
int rc = cbm_upsert_codex_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "[mcp_servers.codebase-memory-mcp]") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_codex_mcp_escapes_windows_path) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-winpath-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/config.toml", tmpdir);
const char *binary = "C:\\Users\\Martin Vogel\\bin\\codebase-memory-mcp.exe";
int rc = cbm_upsert_codex_mcp(binary, configpath);
char *data = read_test_file_alloc(configpath);
bool escaped_basic = data && strstr(data, "command = \"C:\\\\Users") != NULL;
bool literal = data && strstr(data, "command = 'C:\\Users") != NULL;
bool has_args = data && strstr(data, "args = []") != NULL;
free(data);
test_rmdir_r(tmpdir);
if (rc != 0 || (!escaped_basic && !literal))
FAIL("Codex MCP TOML must escape Windows backslashes or use a literal string");
if (!has_args)
FAIL("Codex MCP TOML must include the documented empty args array");
PASS();
}
TEST(cli_upsert_codex_mcp_existing) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/config.toml", tmpdir);
write_test_file(configpath, "model = \"gpt-4\"\n\n[other_setting]\nfoo = \"bar\"\n");
int rc = cbm_upsert_codex_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* Existing settings preserved */
ASSERT(strstr(data, "model = \"gpt-4\"") != NULL);
ASSERT(strstr(data, "[other_setting]") != NULL);
/* Our entry added */
ASSERT(strstr(data, "[mcp_servers.codebase-memory-mcp]") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_codex_mcp_replace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/config.toml", tmpdir);
write_test_file(configpath, "[mcp_servers.codebase-memory-mcp]\n"
"command = \"/old/path/codebase-memory-mcp\"\n"
"\n"
"[other_setting]\nfoo = \"bar\"\n");
int rc = cbm_upsert_codex_mcp("/new/path/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
/* Old path replaced */
ASSERT(strstr(data, "/old/path") == NULL);
ASSERT(strstr(data, "/new/path/codebase-memory-mcp") != NULL);
/* Other settings preserved */
ASSERT(strstr(data, "[other_setting]") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_codex_legacy_migration_ignores_header_text_in_multiline_string) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-multiline-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/config.toml", tmpdir);
const char *original = "[other]\n"
"description = \"\"\"\n"
"This is documentation, not a table:\n"
"[mcp_servers.codebase-memory-mcp]\n"
"keep this text intact\n"
"\"\"\"\n"
"enabled = true\n";
write_test_file(configpath, original);
int rc = cbm_upsert_codex_mcp("/new/codebase-memory-mcp", configpath);
char *after = read_test_file_alloc(configpath);
bool preserved = after && strstr(after, original) != NULL &&
strstr(after, "command = \"/new/codebase-memory-mcp\"") != NULL;
free(after);
test_rmdir_r(tmpdir);
if (rc != 0 || !preserved)
FAIL("Codex legacy migration must ignore table-looking text inside multiline strings");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group B: MCP Config Upsert — Zed (corrected format)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_zed_mcp_uses_args_format) {
/* Zed expects no arguments, not one real empty-string argument. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-zed-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/settings.json", tmpdir);
cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
yyjson_doc *doc = yyjson_read(data, strlen(data), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *servers = yyjson_obj_get(root, "context_servers");
yyjson_val *entry = yyjson_obj_get(servers, "codebase-memory-mcp");
yyjson_val *args = yyjson_obj_get(entry, "args");
ASSERT(args && yyjson_is_arr(args));
ASSERT_EQ(yyjson_arr_size(args), 0U);
ASSERT_NULL(yyjson_obj_get(entry, "source"));
yyjson_doc_free(doc);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_zed_mcp_preserves_jsonc_comments) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-zed-jsonc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/settings.json", tmpdir);
write_test_file(configpath,
"{\n // preserve the user's Zed setting\n \"theme\": \"Ayu Dark\",\n}\n");
int rc = cbm_install_zed_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
char *data = read_test_file_alloc(configpath);
bool preserved = data && strstr(data, "preserve the user's Zed setting") &&
strstr(data, "Ayu Dark") && strstr(data, "codebase-memory-mcp");
free(data);
test_rmdir_r(tmpdir);
if (rc != 0 || !preserved)
FAIL("Zed MCP install must preserve JSONC comments and unrelated settings");
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group B: MCP Config Upsert — OpenCode
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_upsert_opencode_mcp_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/opencode.json", tmpdir);
int rc = cbm_upsert_opencode_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
/* command must be emitted as an array, not a string */
ASSERT(strstr(data, "\"command\":[") != NULL || strstr(data, "\"command\": [") != NULL);
/* type must be explicitly set to \"local\" */
ASSERT(strstr(data, "\"type\":\"local\"") != NULL ||
strstr(data, "\"type\": \"local\"") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_opencode_mcp_preserves_jsonc_comments) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-jsonc-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/opencode.jsonc", tmpdir);
write_test_file(configpath, "{\n // keep this user explanation\n \"theme\": \"dark\",\n}\n");
int rc = cbm_upsert_opencode_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
char *data = read_test_file_alloc(configpath);
bool comment_kept = data && strstr(data, "keep this user explanation") != NULL;
bool setting_kept = data && strstr(data, "theme") && strstr(data, "dark");
bool installed = data && strstr(data, "codebase-memory-mcp");
free(data);
test_rmdir_r(tmpdir);
if (rc != 0 || !comment_kept || !setting_kept || !installed)
FAIL("OpenCode MCP upsert must preserve JSONC comments and unrelated settings");
PASS();
}
TEST(cli_upsert_opencode_mcp_existing) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ocode-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/opencode.json", tmpdir);
write_test_file(configpath, "{\"mcp\":{\"other-server\":{\"command\":\"/usr/bin/other\"}}}");
int rc = cbm_upsert_opencode_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "other-server") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group B: MCP Config Upsert — Antigravity
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_upsert_antigravity_mcp_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-anti-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/mcp_config.json", tmpdir);
int rc = cbm_upsert_antigravity_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_antigravity_mcp_replace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-anti-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char configpath[512];
snprintf(configpath, sizeof(configpath), "%s/mcp_config.json", tmpdir);
write_test_file(configpath, "{\"mcpServers\":{\"codebase-memory-mcp\":{"
"\"command\":\"codebase-memory-mcp\"}}}");
int rc = cbm_upsert_antigravity_mcp("/new/path/codebase-memory-mcp", configpath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(configpath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"command\":\"codebase-memory-mcp\"") == NULL);
ASSERT(strstr(data, "/new/path/codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_json_rejects_overlong_path_without_truncated_parent) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-json-long-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char unexpected[512];
snprintf(unexpected, sizeof(unexpected), "%s/a", tmpdir);
char *configpath = make_overlong_nested_path(tmpdir, "mcp_config.json");
ASSERT_NOT_NULL(configpath);
int rc = cbm_upsert_antigravity_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
ASSERT_NEQ(rc, 0);
ASSERT_FALSE(test_path_exists(unexpected));
free(configpath);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group C: Instructions File Upsert
* ═══════════════════════════════════════════════════════════════════ */
/* #1032: Aider has no MCP support, but its installed CONVENTIONS.md told the
* model to call MCP tools it cannot invoke (search_graph(...) style). The
* Aider variant must teach the runnable CLI form instead. */
TEST(cli_aider_instructions_are_cli_form_issue1032) {
const char *content = cbm_get_aider_instructions();
ASSERT_NOT_NULL(content);
/* Every discovery example is a runnable CLI command... */
ASSERT(strstr(content, "codebase-memory-mcp cli search_graph") != NULL);
ASSERT(strstr(content, "codebase-memory-mcp cli trace_path") != NULL);
ASSERT(strstr(content, "codebase-memory-mcp cli index_repository") != NULL);
/* ...and no bare MCP-call syntax remains to mislead the model. */
ASSERT_NULL(strstr(content, "search_graph(name_pattern"));
/* States the constraint explicitly. */
ASSERT(strstr(content, "no MCP support") != NULL);
PASS();
}
TEST(cli_upsert_instructions_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char filepath[512];
snprintf(filepath, sizeof(filepath), "%s/AGENTS.md", tmpdir);
int rc = cbm_upsert_instructions(filepath, "# Test content\nHello world\n");
ASSERT_EQ(rc, 0);
const char *data = read_test_file(filepath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "<!-- codebase-memory-mcp:start -->") != NULL);
ASSERT(strstr(data, "<!-- codebase-memory-mcp:end -->") != NULL);
ASSERT(strstr(data, "Hello world") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_instructions_rejects_overlong_path_without_truncated_parent) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-long-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char unexpected[512];
snprintf(unexpected, sizeof(unexpected), "%s/a", tmpdir);
char *filepath = make_overlong_nested_path(tmpdir, "AGENTS.md");
ASSERT_NOT_NULL(filepath);
int rc = cbm_upsert_instructions(filepath, "# Test content\n");
ASSERT_NEQ(rc, 0);
ASSERT_FALSE(test_path_exists(unexpected));
free(filepath);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_instructions_existing) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char filepath[512];
snprintf(filepath, sizeof(filepath), "%s/AGENTS.md", tmpdir);
write_test_file(filepath, "# My Project Rules\n\nDo the thing.\n");
int rc = cbm_upsert_instructions(filepath, "# CMM\nUse search_graph\n");
ASSERT_EQ(rc, 0);
const char *data = read_test_file(filepath);
ASSERT_NOT_NULL(data);
/* Original content preserved */
ASSERT(strstr(data, "My Project Rules") != NULL);
ASSERT(strstr(data, "Do the thing") != NULL);
/* CMM section appended */
ASSERT(strstr(data, "codebase-memory-mcp:start") != NULL);
ASSERT(strstr(data, "search_graph") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_instructions_replace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char filepath[512];
snprintf(filepath, sizeof(filepath), "%s/AGENTS.md", tmpdir);
write_test_file(filepath, "# Rules\n"
"<!-- codebase-memory-mcp:start -->\n"
"OLD CONTENT\n"
"<!-- codebase-memory-mcp:end -->\n"
"# Other stuff\n");
int rc = cbm_upsert_instructions(filepath, "NEW CONTENT\n");
ASSERT_EQ(rc, 0);
const char *data = read_test_file(filepath);
ASSERT_NOT_NULL(data);
/* Old content replaced */
ASSERT(strstr(data, "OLD CONTENT") == NULL);
ASSERT(strstr(data, "NEW CONTENT") != NULL);
/* Surrounding content preserved */
ASSERT(strstr(data, "# Rules") != NULL);
ASSERT(strstr(data, "# Other stuff") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_instructions_no_duplicate) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char filepath[512];
snprintf(filepath, sizeof(filepath), "%s/AGENTS.md", tmpdir);
/* Install twice */
cbm_upsert_instructions(filepath, "Content v1\n");
cbm_upsert_instructions(filepath, "Content v2\n");
const char *data = read_test_file(filepath);
ASSERT_NOT_NULL(data);
/* Only one start marker */
int count = 0;
const char *p = data;
while ((p = strstr(p, "codebase-memory-mcp:start")) != NULL) {
count++;
p += 25;
}
ASSERT_EQ(count, 1);
/* Latest content */
ASSERT(strstr(data, "Content v2") != NULL);
ASSERT(strstr(data, "Content v1") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_instructions) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-instr-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char filepath[512];
snprintf(filepath, sizeof(filepath), "%s/AGENTS.md", tmpdir);
write_test_file(filepath, "# Rules\n"
"<!-- codebase-memory-mcp:start -->\n"
"CMM Content\n"
"<!-- codebase-memory-mcp:end -->\n"
"# Other\n");
int rc = cbm_remove_instructions(filepath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(filepath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "CMM Content") == NULL);
ASSERT(strstr(data, "codebase-memory-mcp") == NULL);
ASSERT(strstr(data, "# Rules") != NULL);
ASSERT(strstr(data, "# Other") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_agent_instructions_content) {
const char *instr = cbm_get_agent_instructions();
ASSERT_NOT_NULL(instr);
ASSERT(strstr(instr, "search_graph") != NULL);
ASSERT(strstr(instr, "trace_path") != NULL);
ASSERT(strstr(instr, "get_code") != NULL);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group D: Pre-Tool Hook Upsert — Claude Code
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_upsert_claude_hook_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
int rc = cbm_upsert_claude_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "PreToolUse") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
ASSERT(strstr(data, "\"Read\"") != NULL);
ASSERT(strstr(data, "\"Grep|Glob|Read\"") == NULL);
ASSERT_EQ(test_count_substring(data, "cbm-code-discovery-gate"), 2U);
test_rmdir_r(tmpdir);
PASS();
}
/* issue #384: the PreToolUse gate shim must never use a predictable /tmp
* filename (the old `/tmp/cbm-code-discovery-gate-$PPID` was a symlink-attack
* vector). The shim is now a stateless wrapper around the compiled augmenter. */
TEST(cli_hook_gate_script_no_predictable_tmp_issue384) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-gate-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp");
char script_path[512];
#ifdef _WIN32
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate.cmd",
tmpdir);
#else
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir);
#endif
const char *data = read_test_file(script_path);
ASSERT_NOT_NULL(data);
/* No predictable temp/state file and no PPID-derived path. */
ASSERT(strstr(data, "/tmp") == NULL);
ASSERT(strstr(data, "PPID") == NULL);
/* It delegates to the stateless compiled augmenter (stdout only). */
ASSERT(strstr(data, "hook-augment") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_hook_gate_script_rejects_overlong_home_without_truncated_parent) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-gate-long-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR");
char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL;
cbm_unsetenv("CLAUDE_CONFIG_DIR");
char unexpected[512];
snprintf(unexpected, sizeof(unexpected), "%s/a", tmpdir);
char *home = make_overlong_nested_path(tmpdir, "home");
ASSERT_NOT_NULL(home);
cbm_install_hook_gate_script(home, "/usr/local/bin/codebase-memory-mcp");
ASSERT_FALSE(test_path_exists(unexpected));
free(home);
if (saved_ccd_copy) {
cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1);
free(saved_ccd_copy);
} else {
cbm_unsetenv("CLAUDE_CONFIG_DIR");
}
test_rmdir_r(tmpdir);
PASS();
}
/* #929: on Windows, extensionless bash shims under .claude/hooks trigger the
* "How do you want to open this file?" dialog when editors (Cursor) scan the
* dir, and cannot execute without bash. Windows must install .cmd scripts
* (and remove the extensionless legacy twin on upgrade); POSIX keeps the
* extensionless bash form with no .cmd twin. */
TEST(cli_hook_scripts_platform_shape_issue929) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook929-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char hooks_dir[512];
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
#ifdef _WIN32
/* Upgrade path: seed byte-exact pre-#929 owned content at the extensionless
* path. Only exact-owned bytes may be removed. */
cbm_mkdir_p(hooks_dir, 0755);
char legacy_path[512];
char seed_path[512];
snprintf(legacy_path, sizeof(legacy_path), "%s/cbm-code-discovery-gate", hooks_dir);
snprintf(seed_path, sizeof(seed_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
ASSERT_TRUE(cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp"));
char *owned_legacy = read_test_file_alloc(seed_path);
ASSERT_NOT_NULL(owned_legacy);
ASSERT_EQ(write_test_file(legacy_path, owned_legacy), 0);
free(owned_legacy);
ASSERT_EQ(cbm_unlink(seed_path), 0);
#endif
cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp");
char script_path[512];
#ifdef _WIN32
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
const char *data = read_test_file(script_path);
ASSERT_NOT_NULL(data);
ASSERT(strncmp(data, "@echo off", 9) == 0); /* cmd, not bash */
ASSERT(strstr(data, "setlocal DisableDelayedExpansion") != NULL);
ASSERT(strstr(data, "#!/usr/bin/env bash") == NULL);
ASSERT(strstr(data, "hook-augment") != NULL);
/* Legacy extensionless twin removed on upgrade. */
FILE *lf = fopen(legacy_path, "r");
if (lf) {
fclose(lf);
FAIL("legacy extensionless hook file still present after install");
}
#else
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir);
const char *data = read_test_file(script_path);
ASSERT_NOT_NULL(data);
ASSERT(strncmp(data, "#!/usr/bin/env bash", 19) == 0);
/* No .cmd twin on POSIX. */
char cmd_path[512];
snprintf(cmd_path, sizeof(cmd_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
FILE *cf = fopen(cmd_path, "r");
if (cf) {
fclose(cf);
FAIL(".cmd twin must not exist on POSIX");
}
#endif
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_claude_hooks_reject_overlong_config_dir_command) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-env-long-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *saved_ccd = getenv("CLAUDE_CONFIG_DIR");
char *saved_ccd_copy = saved_ccd ? strdup(saved_ccd) : NULL;
char *config_dir = make_overlong_nested_path(tmpdir, "claude-config");
ASSERT_NOT_NULL(config_dir);
cbm_setenv("CLAUDE_CONFIG_DIR", config_dir, 1);
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
ASSERT_NEQ(cbm_upsert_claude_hooks(settingspath), 0);
ASSERT_NEQ(cbm_upsert_claude_session_hooks(settingspath), 0);
ASSERT_FALSE(test_path_exists(settingspath));
char unexpected[512];
snprintf(unexpected, sizeof(unexpected), "%s/a", tmpdir);
ASSERT_FALSE(test_path_exists(unexpected));
free(config_dir);
if (saved_ccd_copy) {
cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1);
free(saved_ccd_copy);
} else {
cbm_unsetenv("CLAUDE_CONFIG_DIR");
}
test_rmdir_r(tmpdir);
PASS();
}
#ifdef _WIN32
TEST(cli_windows_claude_lifecycle_migrates_only_exact_owned_legacy_state) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-windows-legacy-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char hooks_dir[640];
char settings_path[640];
char appdata[512];
char binary_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
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);
const char *const script_names[] = {
"cbm-code-discovery-gate",
"cbm-session-reminder",
"cbm-subagent-reminder",
};
const char *foreign_script = "@echo off\r\necho user-owned-hook\r\n";
for (size_t i = 0U; i < sizeof(script_names) / sizeof(script_names[0]); i++) {
char path[768];
snprintf(path, sizeof(path), "%s/%s", hooks_dir, script_names[i]);
write_test_file(path, foreign_script);
}
const char *const env_names[] = {"HOME", "PATH", "CLAUDE_CONFIG_DIR", "APPDATA"};
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_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_setenv("CLAUDE_CONFIG_DIR", config_dir, 1);
cbm_setenv("APPDATA", appdata, 1);
char session_current[1024] = {0};
char session_previous[1024] = {0};
char session_released[1024] = {0};
char subagent_current[1024] = {0};
char subagent_previous[1024] = {0};
char subagent_released[1024] = {0};
bool commands_ready =
cbm_resolve_claude_hook_command_for_testing(
"cbm-session-reminder.cmd", true, session_current, sizeof(session_current)) == 0 &&
cbm_resolve_claude_hook_command_for_testing("cbm-session-reminder", false, session_previous,
sizeof(session_previous)) == 0 &&
cbm_resolve_claude_hook_command_for_testing(
"cbm-subagent-reminder.cmd", true, subagent_current, sizeof(subagent_current)) == 0 &&
cbm_resolve_claude_hook_command_for_testing(
"cbm-subagent-reminder", false, subagent_previous, sizeof(subagent_previous)) == 0;
snprintf(session_released, sizeof(session_released), "%s/cbm-session-reminder", hooks_dir);
snprintf(subagent_released, sizeof(subagent_released), "%s/cbm-subagent-reminder", hooks_dir);
const char *foreign_command = "cmd.exe /d /s /c user-owned-hook.cmd";
yyjson_mut_doc *initial_doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = initial_doc ? yyjson_mut_obj(initial_doc) : NULL;
yyjson_mut_val *hooks = initial_doc ? yyjson_mut_obj(initial_doc) : NULL;
yyjson_mut_val *session_entries = initial_doc ? yyjson_mut_arr(initial_doc) : NULL;
yyjson_mut_val *subagent_entries = initial_doc ? yyjson_mut_arr(initial_doc) : NULL;
if (initial_doc && root) {
yyjson_mut_doc_set_root(initial_doc, root);
}
bool json_ready =
commands_ready && initial_doc && root && hooks && session_entries && subagent_entries &&
yyjson_mut_obj_add_val(initial_doc, root, "hooks", hooks) &&
yyjson_mut_obj_add_val(initial_doc, hooks, "SessionStart", session_entries) &&
yyjson_mut_obj_add_val(initial_doc, hooks, "SubagentStart", subagent_entries) &&
test_append_command_hook(initial_doc, session_entries, "startup", session_current) &&
test_append_command_hook(initial_doc, session_entries, "startup", session_previous) &&
test_append_command_hook(initial_doc, session_entries, "startup", session_released) &&
test_append_command_hook(initial_doc, session_entries, "startup", foreign_command) &&
test_append_command_hook(initial_doc, subagent_entries, "*", subagent_current) &&
test_append_command_hook(initial_doc, subagent_entries, "*", subagent_previous) &&
test_append_command_hook(initial_doc, subagent_entries, "*", subagent_released) &&
test_append_command_hook(initial_doc, subagent_entries, "*", foreign_command);
char *initial_json =
json_ready ? yyjson_mut_write(initial_doc, YYJSON_WRITE_PRETTY, NULL) : NULL;
bool seeded = initial_json && write_test_file(settings_path, initial_json) == 0;
free(initial_json);
yyjson_mut_doc_free(initial_doc);
int install_rc = seeded ? cbm_install_agent_configs(tmpdir, binary_path, false, false) : -1;
char *installed_settings = read_test_file_alloc(settings_path);
yyjson_doc *installed_doc =
installed_settings ? yyjson_read(installed_settings, strlen(installed_settings), 0) : NULL;
yyjson_val *installed_root = installed_doc ? yyjson_doc_get_root(installed_doc) : NULL;
bool commands_migrated =
install_rc == 0 &&
test_count_hook_command(installed_root, "SessionStart", session_current) == 4U &&
test_count_hook_command(installed_root, "SessionStart", session_previous) == 0U &&
test_count_hook_command(installed_root, "SessionStart", session_released) == 0U &&
test_count_hook_command(installed_root, "SessionStart", foreign_command) == 1U &&
test_count_hook_command(installed_root, "SubagentStart", subagent_current) == 1U &&
test_count_hook_command(installed_root, "SubagentStart", subagent_previous) == 0U &&
test_count_hook_command(installed_root, "SubagentStart", subagent_released) == 0U &&
test_count_hook_command(installed_root, "SubagentStart", foreign_command) == 1U;
yyjson_doc_free(installed_doc);
free(installed_settings);
bool foreign_scripts_preserved = true;
for (size_t i = 0U; i < sizeof(script_names) / sizeof(script_names[0]); i++) {
char path[768];
snprintf(path, sizeof(path), "%s/%s", hooks_dir, script_names[i]);
char *data = read_test_file_alloc(path);
foreign_scripts_preserved =
foreign_scripts_preserved && data && strcmp(data, foreign_script) == 0;
free(data);
}
char *uninstall_argv[] = {"uninstall", "--yes"};
int uninstall_rc = cbm_cmd_uninstall(2, uninstall_argv);
char *uninstalled_settings = read_test_file_alloc(settings_path);
yyjson_doc *uninstalled_doc =
uninstalled_settings ? yyjson_read(uninstalled_settings, strlen(uninstalled_settings), 0)
: NULL;
yyjson_val *uninstalled_root = uninstalled_doc ? yyjson_doc_get_root(uninstalled_doc) : NULL;
bool commands_clean =
uninstall_rc == 0 &&
test_count_hook_command(uninstalled_root, "SessionStart", session_current) == 0U &&
test_count_hook_command(uninstalled_root, "SessionStart", session_previous) == 0U &&
test_count_hook_command(uninstalled_root, "SessionStart", session_released) == 0U &&
test_count_hook_command(uninstalled_root, "SessionStart", foreign_command) == 1U &&
test_count_hook_command(uninstalled_root, "SubagentStart", subagent_current) == 0U &&
test_count_hook_command(uninstalled_root, "SubagentStart", subagent_previous) == 0U &&
test_count_hook_command(uninstalled_root, "SubagentStart", subagent_released) == 0U &&
test_count_hook_command(uninstalled_root, "SubagentStart", foreign_command) == 1U;
yyjson_doc_free(uninstalled_doc);
free(uninstalled_settings);
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);
if (!commands_migrated || !foreign_scripts_preserved || !commands_clean)
FAIL("Windows lifecycle migration must converge exact-owned commands and preserve foreign "
"extensionless scripts");
PASS();
}
TEST(cli_windows_claude_hook_scripts_migrate_and_uninstall_all_owned_shapes) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-windows-owned-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char hooks_dir[640];
char appdata[512];
char binary_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
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);
const char *const env_names[] = {"HOME", "PATH", "CLAUDE_CONFIG_DIR",
"APPDATA", "CODEX_HOME", "OPENCODE_CONFIG",
"COPILOT_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_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_setenv("CLAUDE_CONFIG_DIR", config_dir, 1);
cbm_setenv("APPDATA", appdata, 1);
cbm_unsetenv("CODEX_HOME");
cbm_unsetenv("OPENCODE_CONFIG");
cbm_unsetenv("COPILOT_HOME");
const char *const legacy_names[] = {
"cbm-code-discovery-gate",
"cbm-session-reminder",
"cbm-subagent-reminder",
};
const char *const current_names[] = {
"cbm-code-discovery-gate.cmd",
"cbm-session-reminder.cmd",
"cbm-subagent-reminder.cmd",
};
char *current_scripts[sizeof(current_names) / sizeof(current_names[0])] = {0};
int initial_install_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false);
bool current_scripts_ready = initial_install_rc == 0;
for (size_t i = 0U; i < sizeof(current_names) / sizeof(current_names[0]); i++) {
char current_path[768];
char legacy_path[768];
snprintf(current_path, sizeof(current_path), "%s/%s", hooks_dir, current_names[i]);
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
current_scripts[i] = read_test_file_alloc(current_path);
current_scripts_ready = current_scripts_ready && current_scripts[i] &&
write_test_file(legacy_path, current_scripts[i]) == 0;
}
int current_upgrade_rc =
current_scripts_ready ? cbm_install_agent_configs(tmpdir, binary_path, false, false) : -1;
bool current_legacy_removed = current_upgrade_rc == 0;
for (size_t i = 0U; i < sizeof(legacy_names) / sizeof(legacy_names[0]); i++) {
char current_path[768];
char legacy_path[768];
struct stat state;
snprintf(current_path, sizeof(current_path), "%s/%s", hooks_dir, current_names[i]);
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
current_legacy_removed = current_legacy_removed && stat(legacy_path, &state) != 0 &&
stat(current_path, &state) == 0;
}
char released_gate[8192];
bool released_ready =
test_build_released_gate_hook_script(binary_path, released_gate, sizeof(released_gate));
const char *const released_scripts[] = {
released_gate,
test_released_session_hook_script,
test_released_subagent_hook_script,
};
for (size_t i = 0U; i < sizeof(legacy_names) / sizeof(legacy_names[0]); i++) {
char legacy_path[768];
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
released_ready = released_ready && write_test_file(legacy_path, released_scripts[i]) == 0;
}
int released_upgrade_rc =
released_ready ? cbm_install_agent_configs(tmpdir, binary_path, false, false) : -1;
bool released_legacy_removed = released_upgrade_rc == 0;
for (size_t i = 0U; i < sizeof(legacy_names) / sizeof(legacy_names[0]); i++) {
char legacy_path[768];
struct stat state;
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
released_legacy_removed = released_legacy_removed && stat(legacy_path, &state) != 0;
}
bool uninstall_seeded = current_scripts_ready;
for (size_t i = 0U; i < sizeof(legacy_names) / sizeof(legacy_names[0]); i++) {
char legacy_path[768];
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
uninstall_seeded = uninstall_seeded && current_scripts[i] &&
write_test_file(legacy_path, current_scripts[i]) == 0;
}
char *uninstall_argv[] = {"uninstall", "--yes"};
int uninstall_rc = uninstall_seeded ? cbm_cmd_uninstall(2, uninstall_argv) : -1;
bool all_owned_shapes_removed = uninstall_rc == 0;
for (size_t i = 0U; i < sizeof(legacy_names) / sizeof(legacy_names[0]); i++) {
char current_path[768];
char legacy_path[768];
struct stat state;
snprintf(current_path, sizeof(current_path), "%s/%s", hooks_dir, current_names[i]);
snprintf(legacy_path, sizeof(legacy_path), "%s/%s", hooks_dir, legacy_names[i]);
all_owned_shapes_removed = all_owned_shapes_removed && stat(current_path, &state) != 0 &&
stat(legacy_path, &state) != 0;
}
for (size_t i = 0U; i < sizeof(current_scripts) / sizeof(current_scripts[0]); i++) {
free(current_scripts[i]);
}
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);
if (!current_legacy_removed || !released_legacy_removed || !all_owned_shapes_removed)
FAIL("Windows lifecycle must migrate and uninstall current and released owned hook "
"script shapes");
PASS();
}
#endif
/* Claude may execute shell-form hooks through PowerShell when Git Bash is not
* available. Windows registrations must therefore invoke the .cmd shim via an
* explicit command interpreter instead of evaluating a quoted path string. */
TEST(cli_windows_claude_hook_command_is_shell_portable) {
char *saved_config = save_test_env("CLAUDE_CONFIG_DIR");
char command[1024];
cbm_unsetenv("CLAUDE_CONFIG_DIR");
ASSERT_EQ(cbm_resolve_claude_hook_command_for_testing("cbm-session-reminder.cmd", true, command,
sizeof(command)),
0);
ASSERT_STR_EQ(command, "cmd.exe /d /v:off /s /c '\"\"%USERPROFILE%\\.claude\\hooks\\"
"cbm-session-reminder.cmd\"\"'");
cbm_setenv("CLAUDE_CONFIG_DIR", "C:\\Users\\A & B\\.claude!100%", 1);
ASSERT_EQ(cbm_resolve_claude_hook_command_for_testing("cbm-subagent-reminder.cmd", true,
command, sizeof(command)),
0);
ASSERT_STR_EQ(command, "cmd.exe /d /v:off /s /c '\"\"%CLAUDE_CONFIG_DIR%\\hooks\\"
"cbm-subagent-reminder.cmd\"\"'");
ASSERT(strstr(command, "A & B") == NULL);
ASSERT_EQ(cbm_resolve_claude_hook_command_for_testing("../foreign.cmd", true, command,
sizeof(command)),
-1);
restore_test_env("CLAUDE_CONFIG_DIR", saved_config);
PASS();
}
/* issue #618: hook-augment was a structural no-op on Windows because its path
* guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter
* cwd (C:/repo) was rejected before any search_graph query. The predicate must
* accept POSIX and Windows drive roots alike (callers normalize '\\' to '/'). */
TEST(cli_hook_augment_path_is_abs) {
/* POSIX absolute (unchanged behavior) */
ASSERT(cbm_hook_path_is_abs("/home/u/proj"));
/* Windows drive roots — the #618 regression */
ASSERT(cbm_hook_path_is_abs("C:/Users/me/proj"));
ASSERT(cbm_hook_path_is_abs("C:/"));
ASSERT(cbm_hook_path_is_abs("C:"));
ASSERT(cbm_hook_path_is_abs("d:/lowercase/drive"));
/* Not absolute → augmenter no-ops cleanly */
ASSERT(!cbm_hook_path_is_abs("relative/path"));
ASSERT(!cbm_hook_path_is_abs("proj"));
ASSERT(!cbm_hook_path_is_abs(""));
ASSERT(!cbm_hook_path_is_abs(NULL));
PASS();
}
/* #858: a fired hook-augment deadline used to be a SILENT _exit(0) —
* indistinguishable from "no matches" — and the 300ms default self-terminated
* on real cold starts, so augmentation never appeared in real sessions
* (0/24 observed). The deadline is now env-configurable
* (CBM_HOOK_DEADLINE_MS, generous default) and a fired deadline leaves an
* observable breadcrumb in a local log. Deterministic reproduction: stdin is
* a pipe with a live writer that never sends data, so ha_read_stdin blocks
* past a 60ms deadline and the timer must fire, breadcrumb, and _exit(0). */
TEST(cli_hook_augment_deadline_breadcrumb_issue858) {
#ifdef _WIN32
SKIP_PLATFORM("in-process SIGALRM deadline is POSIX-only (settings.json timeout on Windows)");
#else
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hookdl-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char logpath[512];
snprintf(logpath, sizeof(logpath), "%s/timeouts.log", tmpdir);
int fds[2];
if (pipe(fds) != 0) {
test_rmdir_r(tmpdir);
FAIL("pipe failed");
}
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* Child: hook-augment with a 60ms deadline and stdin that blocks
* forever (parent keeps the write end open, sends nothing). */
close(fds[1]);
dup2(fds[0], 0);
close(fds[0]);
setenv("CBM_HOOK_DEADLINE_MS", "60", 1);
setenv("CBM_HOOK_TIMEOUT_LOG", logpath, 1);
alarm(10); /* backstop: never hang the suite */
_exit(cbm_cmd_hook_augment(0, NULL));
}
ASSERT_GT(pid, 0);
close(fds[0]);
int status = 0;
waitpid(pid, &status, 0);
close(fds[1]);
/* The deadline must have fired as a clean exit 0 (fail-open, no signal). */
ASSERT(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
/* RED before the fix: no breadcrumb existed — a fired deadline was
* indistinguishable from a no-match run. GREEN: the log names the
* deadline and the knob. */
FILE *f = fopen(logpath, "r");
if (!f) {
fprintf(stderr, " [858] FAIL no timeout breadcrumb written to %s\n", logpath);
}
ASSERT_NOT_NULL(f);
char line[256] = "";
char *got = fgets(line, sizeof(line), f);
fclose(f);
ASSERT_NOT_NULL(got);
ASSERT(strstr(line, "deadline_exceeded") != NULL);
ASSERT(strstr(line, "CBM_HOOK_DEADLINE_MS") != NULL);
cbm_unsetenv("CBM_HOOK_DEADLINE_MS");
cbm_unsetenv("CBM_HOOK_TIMEOUT_LOG");
test_rmdir_r(tmpdir);
PASS();
#endif
}
TEST(cli_upsert_claude_hook_existing) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
/* Pre-existing settings with other hooks */
write_test_file(settingspath,
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\","
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo firewall\"}]}]}}");
int rc = cbm_upsert_claude_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT(strstr(data, "\"Read\"") != NULL);
/* Existing hook preserved */
ASSERT(strstr(data, "Bash") != NULL);
ASSERT(strstr(data, "firewall") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_tool_hooks_preserve_foreign_same_matcher) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-owner-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char claude_path[512];
char gemini_path[512];
snprintf(claude_path, sizeof(claude_path), "%s/claude.json", tmpdir);
snprintf(gemini_path, sizeof(gemini_path), "%s/gemini.json", tmpdir);
write_test_file(claude_path, "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob|Read\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo user-claude-tool-hook\"}]},"
"{\"matcher\":\"Grep|Glob|Read\",\"hooks\":["
"{\"type\":\"command\",\"command\":"
"\"~/.claude/hooks/cbm-code-discovery-gate\"},"
"{\"type\":\"command\",\"command\":"
"\"echo user-claude-sibling\"}]}]}}\n");
write_test_file(gemini_path, "{\"hooks\":{\"BeforeTool\":[{"
"\"matcher\":\"google_web_search|grep_search\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo user-gemini-tool-hook\"}]}]}}\n");
ASSERT_EQ(cbm_upsert_claude_hooks(claude_path), 0);
ASSERT_EQ(cbm_upsert_gemini_hooks(gemini_path), 0);
char *claude = read_test_file_alloc(claude_path);
char *gemini = read_test_file_alloc(gemini_path);
bool installed = claude && strstr(claude, "user-claude-tool-hook") &&
strstr(claude, "user-claude-sibling") &&
strstr(claude, "cbm-code-discovery-gate") && gemini &&
strstr(gemini, "user-gemini-tool-hook") &&
strstr(gemini, "codebase-memory-mcp search_graph");
free(claude);
free(gemini);
ASSERT_EQ(cbm_remove_claude_hooks(claude_path), 0);
ASSERT_EQ(cbm_remove_gemini_hooks(gemini_path), 0);
claude = read_test_file_alloc(claude_path);
gemini = read_test_file_alloc(gemini_path);
bool removed_owned_only = claude && strstr(claude, "user-claude-tool-hook") &&
strstr(claude, "user-claude-sibling") &&
!strstr(claude, "cbm-code-discovery-gate") && gemini &&
strstr(gemini, "user-gemini-tool-hook") &&
!strstr(gemini, "codebase-memory-mcp search_graph");
free(claude);
free(gemini);
test_rmdir_r(tmpdir);
if (!installed || !removed_owned_only)
FAIL("tool hook ownership must include the installed command, not only its matcher");
PASS();
}
TEST(cli_upsert_claude_hook_replace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
/* Pre-existing CMM hook with an OLD matcher (pre-#963) + old message */
write_test_file(settingspath, "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"~/.claude/hooks/cbm-code-discovery-gate\"}]}]}}");
int rc = cbm_upsert_claude_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"Grep|Glob|Read\"") == NULL);
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT_EQ(test_count_substring(data, "cbm-code-discovery-gate"), 2U);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_claude_hook_preserves_others) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
write_test_file(settingspath,
"{\"apiKey\":\"sk-123\","
"\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\","
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo guard\"}]}]}}");
cbm_upsert_claude_hooks(settingspath);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
/* Non-hook settings preserved */
ASSERT(strstr(data, "apiKey") != NULL);
ASSERT(strstr(data, "sk-123") != NULL);
/* Bash hook preserved */
ASSERT(strstr(data, "Bash") != NULL);
ASSERT(strstr(data, "guard") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_claude_hooks) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
/* Install then remove */
cbm_upsert_claude_hooks(settingspath);
int rc = cbm_remove_claude_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "Grep|Glob|Read") == NULL);
ASSERT(strstr(data, "cbm-code-discovery-gate") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_claude_session_hooks_all_lifecycle_matchers) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-session-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
ASSERT_EQ(cbm_upsert_claude_session_hooks(settingspath), 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT_EQ(count_substr(data, "\"SessionStart\""), 1);
ASSERT(strstr(data, "\"startup\"") != NULL);
ASSERT(strstr(data, "\"resume\"") != NULL);
ASSERT(strstr(data, "\"clear\"") != NULL);
ASSERT(strstr(data, "\"compact\"") != NULL);
ASSERT_EQ(count_substr(data, "cbm-session-reminder"), 4);
ASSERT_EQ(cbm_remove_claude_session_hooks(settingspath), 0);
data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT_NULL(strstr(data, "SessionStart"));
ASSERT_NULL(strstr(data, "cbm-session-reminder"));
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group D: Pre-Tool Hook Upsert — Gemini CLI / Antigravity
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_upsert_gemini_hook_fresh) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ghook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
int rc = cbm_upsert_gemini_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "BeforeTool") != NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
if (!strstr(data, "google_web_search"))
FAIL("Gemini BeforeTool hook must use the current google_web_search tool name");
if (!strstr(data, "hookSpecificOutput") || !strstr(data, "additionalContext"))
FAIL("Gemini BeforeTool hook must emit JSON additionalContext, not bare stderr text");
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_gemini_hook_existing) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ghook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
write_test_file(settingspath,
"{\"hooks\":{\"BeforeTool\":[{\"matcher\":\"shell\","
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo guard\"}]}]}}");
int rc = cbm_upsert_gemini_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
/* Our hook added */
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
/* Existing hook preserved */
ASSERT(strstr(data, "shell") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_upsert_gemini_hook_replace) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ghook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
write_test_file(
settingspath,
"{\"hooks\":{\"BeforeTool\":[{\"matcher\":\"google_search|read_file|grep_search\","
"\"hooks\":[{\"type\":\"command\","
"\"command\":\"echo 'Reminder: prefer codebase-memory-mcp "
"search_graph/trace_path/get_code_snippet over grep/file search for code "
"discovery.' >&2\"}]}]}}");
int rc = cbm_upsert_gemini_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "google_search|read_file|grep_search") == NULL);
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_remove_gemini_hooks) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-ghook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settingspath[512];
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
cbm_upsert_gemini_hooks(settingspath);
int rc = cbm_remove_gemini_hooks(settingspath);
ASSERT_EQ(rc, 0);
const char *data = read_test_file(settingspath);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "codebase-memory-mcp") == NULL);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group E: Skill descriptions use directive pattern
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_skill_descriptions_directive) {
/* Verify skill description has trigger phrases for agent matching */
const cbm_skill_t *sk = cbm_get_skills();
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
ASSERT(strstr(sk[i].content, "Triggers on:") != NULL);
ASSERT(strstr(sk[i].content, "search_graph") != NULL);
}
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group F: Config store (persistent key-value)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cli_config_open_close) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
cbm_config_close(cfg);
/* DB file should exist */
char dbpath[512];
snprintf(dbpath, sizeof(dbpath), "%s/_config.db", tmpdir);
struct stat st;
ASSERT_EQ(stat(dbpath, &st), 0);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_get_set) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
/* Default when key doesn't exist */
ASSERT_STR_EQ(cbm_config_get(cfg, "foo", "default"), "default");
/* Set and get */
ASSERT_EQ(cbm_config_set(cfg, "foo", "bar"), 0);
ASSERT_STR_EQ(cbm_config_get(cfg, "foo", "default"), "bar");
/* Overwrite */
ASSERT_EQ(cbm_config_set(cfg, "foo", "baz"), 0);
ASSERT_STR_EQ(cbm_config_get(cfg, "foo", "default"), "baz");
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_get_bool) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
/* Default */
ASSERT_FALSE(cbm_config_get_bool(cfg, "auto_index", false));
ASSERT_TRUE(cbm_config_get_bool(cfg, "auto_index", true));
/* true variants */
cbm_config_set(cfg, "k1", "true");
ASSERT_TRUE(cbm_config_get_bool(cfg, "k1", false));
cbm_config_set(cfg, "k2", "1");
ASSERT_TRUE(cbm_config_get_bool(cfg, "k2", false));
cbm_config_set(cfg, "k3", "on");
ASSERT_TRUE(cbm_config_get_bool(cfg, "k3", false));
/* false variants */
cbm_config_set(cfg, "k4", "false");
ASSERT_FALSE(cbm_config_get_bool(cfg, "k4", true));
cbm_config_set(cfg, "k5", "0");
ASSERT_FALSE(cbm_config_get_bool(cfg, "k5", true));
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_get_int) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_get_int(cfg, "limit", 50000), 50000);
cbm_config_set(cfg, "limit", "20000");
ASSERT_EQ(cbm_config_get_int(cfg, "limit", 50000), 20000);
/* Non-numeric → default */
cbm_config_set(cfg, "limit", "abc");
ASSERT_EQ(cbm_config_get_int(cfg, "limit", 50000), 50000);
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_get_effective_env_overrides_db) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, "auto_index_limit", "111"), 0);
const char *old_limit = getenv("CBM_AUTO_INDEX_LIMIT");
char *old_limit_copy = old_limit ? strdup(old_limit) : NULL;
if (old_limit) {
ASSERT_NOT_NULL(old_limit_copy);
}
cbm_setenv("CBM_AUTO_INDEX_LIMIT", "222", 1);
ASSERT_STR_EQ(cbm_config_get_effective(cfg, "auto_index_limit", "50000"), "222");
ASSERT_EQ(cbm_config_get_effective_int(cfg, "auto_index_limit", 50000), 222);
if (old_limit_copy) {
cbm_setenv("CBM_AUTO_INDEX_LIMIT", old_limit_copy, 1);
free(old_limit_copy);
} else {
cbm_unsetenv("CBM_AUTO_INDEX_LIMIT");
}
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_registry_includes_dep_ranking_toggle) {
const cbm_config_entry_t *found = NULL;
for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) {
if (strcmp(CBM_CONFIG_REGISTRY[i].key, "search_disable_dep_ranking") == 0) {
found = &CBM_CONFIG_REGISTRY[i];
break;
}
}
ASSERT_NOT_NULL(found);
ASSERT_STR_EQ(found->default_val, "false");
ASSERT_STR_EQ(found->range, "true|false");
ASSERT_NOT_NULL(strstr(found->description, "search_graph"));
ASSERT_NOT_NULL(strstr(found->guidance, "dependency"));
PASS();
}
TEST(cli_config_registry_includes_query_max_rows) {
const cbm_config_entry_t *found = NULL;
for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) {
if (strcmp(CBM_CONFIG_REGISTRY[i].key, CBM_CONFIG_QUERY_MAX_ROWS) == 0) {
found = &CBM_CONFIG_REGISTRY[i];
break;
}
}
ASSERT_NOT_NULL(found);
ASSERT_STR_EQ(found->default_val, CBM_DEFAULT_QUERY_MAX_ROWS_STR);
ASSERT_STR_EQ(found->range, "0-1000000");
ASSERT_NOT_NULL(strstr(found->description, "query_graph"));
ASSERT_NOT_NULL(strstr(found->guidance, "upstream"));
PASS();
}
TEST(cli_config_registry_reindex_startup_guidance_is_precise) {
const cbm_config_entry_t *found = NULL;
for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) {
if (strcmp(CBM_CONFIG_REGISTRY[i].key, "reindex_on_startup") == 0) {
found = &CBM_CONFIG_REGISTRY[i];
break;
}
}
ASSERT_NOT_NULL(found);
ASSERT_NOT_NULL(strstr(found->guidance, "startup"));
ASSERT_NOT_NULL(strstr(found->guidance, "stale"));
ASSERT_NULL(strstr(found->guidance, "always-fresh"));
PASS();
}
TEST(cli_configuration_doc_auto_index_default_matches_registry) {
const cbm_config_entry_t *entry = NULL;
for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) {
if (strcmp(CBM_CONFIG_REGISTRY[i].key, "auto_index") == 0) {
entry = &CBM_CONFIG_REGISTRY[i];
break;
}
}
ASSERT_NOT_NULL(entry);
const char *doc = read_test_file("docs/CONFIGURATION.md");
ASSERT_NOT_NULL(doc);
char expected[128];
snprintf(expected, sizeof(expected), "| `auto_index` | `%s` |", entry->default_val);
ASSERT_NOT_NULL(strstr(doc, expected));
PASS();
}
TEST(cli_config_delete) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
cbm_config_set(cfg, "foo", "bar");
ASSERT_STR_EQ(cbm_config_get(cfg, "foo", ""), "bar");
cbm_config_delete(cfg, "foo");
ASSERT_STR_EQ(cbm_config_get(cfg, "foo", "gone"), "gone");
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_persists) {
/* Values survive close + reopen */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
cbm_config_set(cfg, "auto_index", "true");
cbm_config_close(cfg);
/* Reopen */
cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
ASSERT_TRUE(cbm_config_get_bool(cfg, "auto_index", false));
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_config_presets_apply_exact_capability_sets) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_apply_preset(cfg, "streamlined-quality"), 0);
ASSERT_STR_EQ(cbm_config_get(cfg, CBM_CONFIG_TOOL_MODE, ""), "streamlined");
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_RANK_ENABLED, false));
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, false));
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_SIMILARITY_ENABLED, false));
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_SEMANTIC_EDGES_ENABLED, false));
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_GITHISTORY_ENABLED, false));
ASSERT_TRUE(cbm_config_get_bool(cfg, CBM_CONFIG_HTTPLINKS_ENABLED, false));
ASSERT_EQ(cbm_config_apply_preset(cfg, "minimal-indexing"), 0);
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_RANK_ENABLED, true));
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, true));
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_SIMILARITY_ENABLED, true));
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_SEMANTIC_EDGES_ENABLED, true));
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_GITHISTORY_ENABLED, true));
ASSERT_FALSE(cbm_config_get_bool(cfg, CBM_CONFIG_HTTPLINKS_ENABLED, true));
ASSERT_NEQ(cbm_config_apply_preset(cfg, "unknown"), 0);
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group H: cbm_replace_binary (update command helper)
* ═══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(replace_binary_overwrites_readonly) {
/* Simulate #114: existing binary has mode 0500 (no write permission).
* cbm_replace_binary must unlink first, then create with 0755. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-replace-XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("cbm_mkdtemp failed");
}
char path[512];
snprintf(path, sizeof(path), "%s/test-binary", tmpdir);
/* Create a read-only file (simulating an installed binary with 0500) */
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs("old-content", f);
fclose(f);
th_make_executable(path); /* r-x------ */
/* Replace it with new content */
const unsigned char new_data[] = "new-content-replaced";
int rc = cbm_replace_binary(path, new_data, (int)sizeof(new_data) - 1, 0755);
ASSERT_EQ(rc, 0);
/* Verify new content was written */
FILE *check = fopen(path, "r");
ASSERT_NOT_NULL(check);
char buf[64] = {0};
fread(buf, 1, sizeof(buf) - 1, check);
fclose(check);
ASSERT_STR_EQ(buf, "new-content-replaced");
/* Verify permissions are 0755 */
struct stat st;
ASSERT_EQ(stat(path, &st), 0);
ASSERT_EQ(st.st_mode & 0777, 0755);
cbm_unlink(path);
cbm_rmdir(tmpdir);
PASS();
}
TEST(replace_binary_creates_new_file) {
/* If no existing file, cbm_replace_binary should create it. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-replace2-XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("cbm_mkdtemp failed");
}
char path[512];
snprintf(path, sizeof(path), "%s/new-binary", tmpdir);
const unsigned char data[] = "brand-new";
int rc = cbm_replace_binary(path, data, (int)sizeof(data) - 1, 0755);
ASSERT_EQ(rc, 0);
FILE *check = fopen(path, "r");
ASSERT_NOT_NULL(check);
char buf[64] = {0};
fread(buf, 1, sizeof(buf) - 1, check);
fclose(check);
ASSERT_STR_EQ(buf, "brand-new");
cbm_unlink(path);
cbm_rmdir(tmpdir);
PASS();
}
#endif /* _WIN32 */
TEST(cli_remove_indexes_preserves_config_db) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-index-clean-XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
FAIL("cbm_mkdtemp failed");
}
const char *old_cache = getenv("CBM_CACHE_DIR");
char *old_cache_copy = old_cache ? strdup(old_cache) : NULL;
if (old_cache) {
ASSERT_NOT_NULL(old_cache_copy);
}
cbm_setenv("CBM_CACHE_DIR", tmpdir, 1);
char project_db[512];
char project_tmp[512];
char config_db[512];
snprintf(project_db, sizeof(project_db), "%s/project.db", tmpdir);
snprintf(project_tmp, sizeof(project_tmp), "%s/project.db.tmp", tmpdir);
snprintf(config_db, sizeof(config_db), "%s/_config.db", tmpdir);
ASSERT_EQ(write_test_file(project_db, "project"), 0);
ASSERT_EQ(write_test_file(project_tmp, "tmp"), 0);
ASSERT_EQ(write_test_file(config_db, "config"), 0);
ASSERT_EQ(cbm_remove_indexes(NULL), 1);
struct stat st;
ASSERT_NEQ(stat(project_db, &st), 0);
ASSERT_NEQ(stat(project_tmp, &st), 0);
ASSERT_EQ(stat(config_db, &st), 0);
if (old_cache_copy) {
cbm_setenv("CBM_CACHE_DIR", old_cache_copy, 1);
free(old_cache_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
cbm_unlink(config_db);
cbm_rmdir(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* CLI tool-argument flags / per-tool --help (#680)
* ═══════════════════════════════════════════════════════════════════ */
/* A plain `--flag value` pair maps to a string property by schema type. */
TEST(cli_build_args_json_string_flag_issue680) {
char *err = NULL;
char *argv[] = {"--repo-path", "/x"};
char *json = cbm_cli_build_args_json("index_repository", 2, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT_NULL(err);
ASSERT(strstr(json, "\"repo_path\":\"/x\"") != NULL);
free(json);
PASS();
}
/* An integer-typed property serializes as a JSON NUMBER, not a quoted string. */
TEST(cli_build_args_json_integer_flag_issue680) {
char *err = NULL;
char *argv[] = {"--limit", "100"};
char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"limit\":100") != NULL);
ASSERT(strstr(json, "\"limit\":\"100\"") == NULL);
free(json);
PASS();
}
/* A bare boolean flag (no value) becomes true. */
TEST(cli_build_args_json_bare_boolean_issue680) {
char *err = NULL;
char *argv[] = {"--exclude-entry-points"};
char *json = cbm_cli_build_args_json("search_graph", 1, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"exclude_entry_points\":true") != NULL);
free(json);
PASS();
}
/* An unknown flag for a KNOWN tool must be rejected loudly, not silently
* typed as a string and dropped server-side (#997). GF1 eval: `trace_path
* --max-depth 1` was accepted, the real --depth stayed at default 3, and
* the trace silently returned hop-2/3 results — silent-wrong output. The
* error names the closest valid flag as a suggestion. */
TEST(cli_build_args_json_unknown_flag_rejected) {
char *err = NULL;
char *argv[] = {"--max-depth", "1"};
char *json = cbm_cli_build_args_json("trace_path", 2, argv, &err);
ASSERT_NULL(json);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "unknown flag") != NULL);
ASSERT(strstr(err, "max-depth") != NULL);
ASSERT(strstr(err, "--depth") != NULL); /* nearest-flag suggestion */
free(err);
PASS();
}
/* A repeated array-typed flag accumulates into a JSON array. */
TEST(cli_build_args_json_repeated_array_issue680) {
char *err = NULL;
char *argv[] = {"--semantic-query", "send", "--semantic-query", "publish"};
char *json = cbm_cli_build_args_json("search_graph", 4, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"semantic_query\":[\"send\",\"publish\"]") != NULL);
free(json);
PASS();
}
/* kebab-case flag names map to snake_case JSON keys. */
TEST(cli_build_args_json_kebab_to_snake_issue680) {
char *err = NULL;
char *argv[] = {"--name-pattern", "Foo.*"};
char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"name_pattern\":\"Foo.*\"") != NULL);
free(json);
PASS();
}
/* `--key=value` form splits on the FIRST `=`; value may contain spaces/dashes. */
TEST(cli_build_args_json_key_equals_value_issue680) {
char *err = NULL;
char *argv[] = {"--repo-path=/a b"};
char *json = cbm_cli_build_args_json("index_repository", 1, argv, &err);
ASSERT_NOT_NULL(json);
ASSERT(strstr(json, "\"repo_path\":\"/a b\"") != NULL);
free(json);
PASS();
}
/* A non-`--` positional is an error: returns NULL and sets *err_out. */
TEST(cli_build_args_json_bad_positional_errors_issue680) {
char *err = NULL;
char *argv[] = {"foo"};
char *json = cbm_cli_build_args_json("search_graph", 1, argv, &err);
ASSERT_NULL(json);
ASSERT_NOT_NULL(err);
free(err);
PASS();
}
/* Per-tool --help returns 0 for a known tool, -1 for an unknown one. */
TEST(cli_print_tool_help_issue680) {
ASSERT_EQ(cbm_cli_print_tool_help("index_repository"), 0);
ASSERT_EQ(cbm_cli_print_tool_help("nope_not_a_tool"), -1);
PASS();
}
/* The self-update path verifies a downloaded archive against a published
* checksum. That check is only meaningful if the digest is actually computed —
* a broken hash command (it once invoked `shasum -a CBM_SZ_256`, an invalid
* algorithm, from a bad macro rename inside the shell string) makes every
* digest fail, and the caller then falls through and installs unverified.
* Guard the digest itself against a known vector. */
extern int cbm_cli_sha256_file(const char *path, char *out, size_t out_size);
/* Hash `content` (len bytes) via a temp file and compare to expected hex.
* Returns 1 on match, 0 otherwise. */
static int sha256_vector_ok(const void *content, size_t len, const char *expected) {
char path[512];
snprintf(path, sizeof(path), "%s/cbm_sha_XXXXXX", cbm_tmpdir());
int fd = cbm_mkstemp(path);
if (fd < 0) {
return 0;
}
FILE *fp = fdopen(fd, "wb");
if (!fp) {
return 0;
}
if (len > 0) {
fwrite(content, 1, len, fp);
}
fclose(fp);
char digest[128] = {0};
int rc = cbm_cli_sha256_file(path, digest, sizeof(digest));
remove(path);
return rc == 0 && strcmp(digest, expected) == 0;
}
/* NIST FIPS 180-4 SHA-256 test vectors: empty input, a single block ("abc"),
* and a 56-byte input that forces the length padding into a second block. */
TEST(cli_sha256_file_matches_known_vector) {
ASSERT_TRUE(sha256_vector_ok(
"", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
ASSERT_TRUE(sha256_vector_ok(
"abc", 3, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
ASSERT_TRUE(
sha256_vector_ok("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56,
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"));
PASS();
}
TEST(cli_qwen_windows_hook_command_uses_powershell_schema) {
char command[1024];
char shell[32];
ASSERT_EQ(cbm_build_qwen_hook_command_for_testing(
"C:\\Program Files\\codebase-memory-mcp.exe", true, command, sizeof(command),
shell, sizeof(shell)),
0);
ASSERT_STR_EQ(shell, "powershell");
ASSERT(strstr(command, "& '") != NULL);
ASSERT(strstr(command, "hook-augment --dialect qwen") != NULL);
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-qwen-windows-hook-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char settings[512];
snprintf(settings, sizeof(settings), "%s/settings.json", tmpdir);
ASSERT_EQ(cbm_upsert_qwen_lifecycle_hooks_for_testing(
settings, "C:\\Program Files\\codebase-memory-mcp.exe", true),
0);
char *data = read_test_file_alloc(settings);
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"shell\": \"powershell\"") != NULL);
ASSERT(strstr(data, "\"command_windows\"") == NULL);
ASSERT(strstr(data, "SessionStart") != NULL);
ASSERT(strstr(data, "SubagentStart") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT(strstr(data, "ReadFile") != NULL);
free(data);
test_rmdir_r(tmpdir);
PASS();
}
TEST(cli_windows_optional_hooks_require_a_documented_shell) {
const char *const withheld[] = {"gitlab", "devin", "factory"};
for (size_t i = 0U; i < sizeof(withheld) / sizeof(withheld[0]); i++) {
ASSERT_FALSE(cbm_optional_hook_supported_for_testing(withheld[i], true));
ASSERT_TRUE(cbm_optional_hook_supported_for_testing(withheld[i], false));
}
ASSERT_FALSE(cbm_optional_hook_supported_for_testing("cline", true));
ASSERT_FALSE(cbm_optional_hook_supported_for_testing("cline", false));
ASSERT_TRUE(cbm_optional_hook_supported_for_testing("kimi", true));
ASSERT_TRUE(cbm_optional_hook_supported_for_testing("hermes", true));
ASSERT_TRUE(cbm_optional_hook_supported_for_testing("qoder", true));
char command[1024];
char shell[32];
ASSERT_EQ(cbm_build_qoder_hook_command_for_testing(
"C:\\Program Files\\codebase-memory-mcp.exe", true, command, sizeof(command),
shell, sizeof(shell)),
0);
ASSERT_STR_EQ(shell, "powershell");
ASSERT(strstr(command, "hook-augment --dialect qoder") != NULL);
PASS();
}
TEST(cli_installed_skill_limits_match_server_contract) {
const cbm_skill_t *installed = cbm_get_skills();
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(installed[0].content);
ASSERT(strstr(installed[0].content, "query_max_output_bytes") != NULL);
ASSERT(strstr(installed[0].content, "search_limit (50 unless configured)") != NULL);
ASSERT(strstr(installed[0].content, "100k row ceiling") == NULL);
ASSERT(strstr(installed[0].content, "200-row cap") == NULL);
ASSERT(strstr(installed[0].content, "default to 10") == NULL);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Suite definition
* ═══════════════════════════════════════════════════════════════════ */
SUITE(cli) {
RUN_TEST(cli_sha256_file_matches_known_vector);
/* Version (2 tests — selfupdate_test.go) */
RUN_TEST(cli_compare_versions);
RUN_TEST(cli_version_get_set);
/* Shell RC detection (5 tests — install_test.go) */
RUN_TEST(cli_detect_shell_rc_zsh);
RUN_TEST(cli_detect_shell_rc_bash);
RUN_TEST(cli_detect_shell_rc_bash_with_bashrc);
RUN_TEST(cli_detect_shell_rc_fish);
RUN_TEST(cli_detect_shell_rc_default);
/* CLI binary detection (3 tests — install_test.go) */
RUN_TEST(cli_find_cli_not_found);
RUN_TEST(cli_find_cli_on_path);
RUN_TEST(cli_find_cli_fallback_paths);
RUN_TEST(cli_find_cli_fallback_scans_cargo_bin);
/* Dry-run flag parsing (1 test — install_test.go) */
RUN_TEST(cli_dry_run_flags);
/* Skill management (7 tests — install_test.go) */
RUN_TEST(cli_skill_creation);
RUN_TEST(cli_skill_idempotent);
RUN_TEST(cli_skill_force_overwrite);
#ifndef _WIN32
RUN_TEST(cli_skills_reject_symlink_and_preserve_unowned_content);
RUN_TEST(cli_legacy_skill_cleanup_rejects_links_and_user_content);
#endif
RUN_TEST(cli_uninstall_removes_skills);
RUN_TEST(cli_remove_old_monolithic_skill);
RUN_TEST(cli_skill_files_content);
RUN_TEST(cli_codex_instructions);
/* Editor MCP: Cursor/Windsurf/Gemini (5 tests — install_test.go) */
RUN_TEST(cli_editor_mcp_install);
RUN_TEST(cli_editor_mcp_idempotent);
RUN_TEST(cli_editor_mcp_preserves_others);
RUN_TEST(cli_editor_mcp_uninstall);
RUN_TEST(cli_junie_mcp_install_issue651);
RUN_TEST(cli_gemini_mcp_install);
RUN_TEST(cli_openclaw_mcp_install_uses_nested_servers);
RUN_TEST(cli_openclaw_mcp_preserves_existing_config);
RUN_TEST(cli_openclaw_mcp_preserves_valid_json5);
RUN_TEST(cli_openclaw_mcp_uninstall_uses_nested_servers);
RUN_TEST(cli_openclaw_compaction_preserves_user_owned_section);
RUN_TEST(cli_openclaw_profile_uses_profile_state_and_default_workspace);
RUN_TEST(cli_openclaw_uninstall_removes_compaction_when_workspace_is_ambiguous);
/* VS Code MCP (2 tests — install_test.go) */
RUN_TEST(cli_vscode_mcp_install);
RUN_TEST(cli_vscode_mcp_uninstall);
RUN_TEST(cli_vscode_profile_mcp_uninstall);
/* Zed MCP (3 tests — install_test.go) */
RUN_TEST(cli_zed_mcp_install);
RUN_TEST(cli_zed_mcp_preserves_settings);
RUN_TEST(cli_zed_mcp_uninstall);
RUN_TEST(cli_zed_mcp_jsonc_comments);
/* PATH management (3 tests) */
RUN_TEST(cli_ensure_path_append);
RUN_TEST(cli_ensure_path_already_present);
RUN_TEST(cli_ensure_path_dry_run);
RUN_TEST(cli_remove_owned_path_block_preserves_user_content);
RUN_TEST(cli_remove_owned_path_dry_run_preserves_block);
RUN_TEST(cli_ensure_path_fish_syntax_issue319);
/* File copy (2 tests — update_test.go) */
RUN_TEST(cli_copy_file);
RUN_TEST(cli_copy_file_source_not_found);
/* Tar.gz extraction (3 tests — update_test.go) */
RUN_TEST(cli_extract_binary_from_targz);
RUN_TEST(cli_extract_binary_from_targz_not_found);
RUN_TEST(cli_extract_binary_from_targz_invalid_data);
RUN_TEST(cli_extract_binary_from_zip);
RUN_TEST(cli_extract_binary_from_zip_not_found);
RUN_TEST(cli_extract_binary_from_zip_path_traversal);
RUN_TEST(cli_extract_binary_from_zip_invalid);
RUN_TEST(cli_extract_binary_from_zip_rejects_truncated_deflate_size_over_int_max);
/* Dry-run lifecycle (2 tests) */
RUN_TEST(cli_install_dry_run);
RUN_TEST(cli_uninstall_dry_run);
RUN_TEST(cli_uninstall_dry_run_preserves_indexes);
RUN_TEST(cli_uninstall_removes_codex_json_hook_only);
RUN_TEST(cli_uninstall_removes_owned_claude_hook_scripts);
RUN_TEST(cli_uninstall_removes_vscode_profile_mcp_only);
RUN_TEST(cli_install_help_does_not_require_home);
RUN_TEST(cli_uninstall_help_does_not_require_home);
RUN_TEST(cli_update_help_does_not_require_home);
/* Full lifecycle (1 test — cli_test.go) */
RUN_TEST(cli_install_and_uninstall);
RUN_TEST(cli_agent_install_reports_safe_editor_refusal);
RUN_TEST(cli_agent_uninstall_reports_safe_editor_refusal);
RUN_TEST(cli_special_hook_failures_propagate_from_install_and_uninstall);
/* Binary swap on install --force (#472) */
RUN_TEST(cli_install_copies_binary_to_target_issue472);
RUN_TEST(cli_install_same_file_guard_issue472);
RUN_TEST(cli_remove_indexes_preserves_config_db);
/* YAML parser (7 unit tests) */
RUN_TEST(cli_yaml_parse_simple);
RUN_TEST(cli_yaml_parse_nested);
RUN_TEST(cli_yaml_parse_list);
RUN_TEST(cli_yaml_parse_bool);
RUN_TEST(cli_yaml_parse_comments);
RUN_TEST(cli_yaml_parse_empty);
RUN_TEST(cli_yaml_has);
/* 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_agents_finds_codex);
RUN_TEST(cli_standalone_kilo_install_plan_and_uninstall_preserve_foreign_entries);
RUN_TEST(cli_detect_agents_finds_cursor_issue222);
RUN_TEST(cli_install_plan_receipt_no_mutation_issue388);
RUN_TEST(cli_reference_harnesses_are_planned_without_mutation);
RUN_TEST(cli_claude_desktop_plan_and_uninstall_preserve_foreign_entries);
RUN_TEST(cli_reference_harnesses_uninstall_owned_entries_only);
RUN_TEST(cli_codex_session_hook_issue330);
RUN_TEST(cli_codex_mcp_and_hook_upserts_are_idempotent);
RUN_TEST(cli_codex_hook_upsert_rejects_orphan_end_sentinel);
RUN_TEST(cli_gemini_session_hook_parity);
RUN_TEST(cli_claude_subagent_hook);
RUN_TEST(cli_claude_hook_mutation_converges_mixed_owned_duplicates);
RUN_TEST(cli_claude_subagent_hook_preserves_user_entry);
RUN_TEST(cli_claude_session_hook_preserves_user_entry);
RUN_TEST(cli_claude_lifecycle_hooks_delegate_to_augmenter);
RUN_TEST(cli_copilot_install_preserves_foreign_named_manifest);
RUN_TEST(cli_copilot_uninstall_preserves_foreign_named_manifest);
RUN_TEST(cli_copilot_uninstall_preserves_canonical_shaped_foreign_manifest);
RUN_TEST(cli_vscode_only_installs_copilot_durable_context);
RUN_TEST(cli_lifecycle_hooks_preserve_foreign_substring_commands);
RUN_TEST(cli_read_only_agents_do_not_receive_mutating_mcp_server);
RUN_TEST(cli_junie_foreign_analysis_alias_falls_back_to_parent_handoff);
RUN_TEST(cli_mcp_installers_preserve_foreign_same_name_entries);
RUN_TEST(cli_installer_rejects_symlinked_agent_roots);
RUN_TEST(cli_claude_hook_scripts_shell_quote_binary_path);
RUN_TEST(cli_claude_hook_commands_shell_quote_custom_config_dir);
RUN_TEST(cli_codex_migrates_to_single_hook_representation);
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
RUN_TEST(cli_hook_augment_lifecycle_output_contract);
RUN_TEST(cli_hook_augment_subagent_tier_router_contract);
RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);
RUN_TEST(cli_hook_augment_post_read_event_and_path_contract);
RUN_TEST(cli_hook_augment_hermes_dialect_contract);
RUN_TEST(cli_hook_augment_qoder_lifecycle_contract);
#ifndef _WIN32
RUN_TEST(cli_qoder_migrates_user_prompt_hook_to_lifecycle_and_read);
#endif
RUN_TEST(cli_hook_augment_kimi_user_prompt_contract);
RUN_TEST(cli_hook_augment_devin_lifecycle_contract);
RUN_TEST(cli_hook_augment_cline_lifecycle_contract);
RUN_TEST(cli_hook_upsert_rejects_malformed_settings);
RUN_TEST(cli_hook_upsert_rejects_concurrent_same_event_update);
#ifndef _WIN32
RUN_TEST(cli_upgrade_migrates_released_claude_hook_scripts);
RUN_TEST(cli_upgrade_preserves_near_legacy_claude_hook_script);
RUN_TEST(cli_hook_upsert_rejects_linked_settings);
RUN_TEST(cli_claude_hook_script_collisions_are_not_registered);
RUN_TEST(cli_codex_legacy_migration_rejects_linked_config);
#endif
RUN_TEST(cli_uninstall_removes_claude_hook_scripts);
RUN_TEST(cli_uninstall_preserves_modified_claude_hook_script);
RUN_TEST(cli_detect_agents_finds_gemini);
RUN_TEST(cli_detect_agents_finds_claude_desktop);
RUN_TEST(cli_detect_agents_finds_zed);
#if !defined(__APPLE__) && !defined(_WIN32)
RUN_TEST(cli_detect_agents_finds_zed_via_xdg_config_home);
#endif
#ifdef _WIN32
RUN_TEST(cli_detect_agents_finds_zed_in_roaming_appdata);
#endif
RUN_TEST(cli_detect_agents_finds_antigravity);
RUN_TEST(cli_detect_agents_finds_kilocode);
RUN_TEST(cli_detect_agents_finds_modern_kilo);
RUN_TEST(cli_detect_agents_finds_kiro);
RUN_TEST(cli_detect_agents_finds_junie_issue651);
RUN_TEST(cli_detect_agents_none_found);
/* Codex MCP config upsert (3 tests — group B) */
RUN_TEST(cli_upsert_codex_mcp_fresh);
RUN_TEST(cli_upsert_codex_mcp_escapes_windows_path);
RUN_TEST(cli_upsert_codex_mcp_existing);
RUN_TEST(cli_upsert_codex_mcp_replace);
RUN_TEST(cli_codex_legacy_migration_ignores_header_text_in_multiline_string);
/* Zed MCP format fix (1 test — group B) */
RUN_TEST(cli_zed_mcp_uses_args_format);
RUN_TEST(cli_zed_mcp_preserves_jsonc_comments);
/* OpenCode MCP config upsert (2 tests — group B) */
RUN_TEST(cli_upsert_opencode_mcp_fresh);
RUN_TEST(cli_upsert_opencode_mcp_preserves_jsonc_comments);
RUN_TEST(cli_upsert_opencode_mcp_existing);
/* Antigravity MCP config upsert (3 tests — group B) */
RUN_TEST(cli_upsert_antigravity_mcp_fresh);
RUN_TEST(cli_upsert_antigravity_mcp_replace);
RUN_TEST(cli_upsert_json_rejects_overlong_path_without_truncated_parent);
/* Instructions file upsert (8 tests — group C) */
RUN_TEST(cli_aider_instructions_are_cli_form_issue1032);
RUN_TEST(cli_upsert_instructions_fresh);
RUN_TEST(cli_upsert_instructions_rejects_overlong_path_without_truncated_parent);
RUN_TEST(cli_upsert_instructions_existing);
RUN_TEST(cli_upsert_instructions_replace);
RUN_TEST(cli_upsert_instructions_no_duplicate);
RUN_TEST(cli_remove_instructions);
RUN_TEST(cli_agent_instructions_content);
RUN_TEST(cli_qwen_windows_hook_command_uses_powershell_schema);
RUN_TEST(cli_windows_optional_hooks_require_a_documented_shell);
RUN_TEST(cli_installed_skill_limits_match_server_contract);
/* Claude Code hooks (12 tests — group D) */
RUN_TEST(cli_hook_gate_script_no_predictable_tmp_issue384);
RUN_TEST(cli_hook_gate_script_rejects_overlong_home_without_truncated_parent);
RUN_TEST(cli_claude_hooks_reject_overlong_config_dir_command);
RUN_TEST(cli_hook_scripts_platform_shape_issue929);
#ifdef _WIN32
RUN_TEST(cli_windows_claude_lifecycle_migrates_only_exact_owned_legacy_state);
RUN_TEST(cli_windows_claude_hook_scripts_migrate_and_uninstall_all_owned_shapes);
#endif
RUN_TEST(cli_windows_claude_hook_command_is_shell_portable);
RUN_TEST(cli_hook_augment_path_is_abs);
RUN_TEST(cli_hook_augment_deadline_breadcrumb_issue858);
RUN_TEST(cli_upsert_claude_hook_fresh);
RUN_TEST(cli_upsert_claude_hook_existing);
RUN_TEST(cli_tool_hooks_preserve_foreign_same_matcher);
RUN_TEST(cli_upsert_claude_hook_replace);
RUN_TEST(cli_upsert_claude_hook_preserves_others);
RUN_TEST(cli_remove_claude_hooks);
RUN_TEST(cli_claude_session_hooks_all_lifecycle_matchers);
/* Gemini CLI hooks (4 tests — group D) */
RUN_TEST(cli_upsert_gemini_hook_fresh);
RUN_TEST(cli_upsert_gemini_hook_existing);
RUN_TEST(cli_upsert_gemini_hook_replace);
RUN_TEST(cli_remove_gemini_hooks);
/* Skill directive descriptions (1 test — group E) */
RUN_TEST(cli_skill_descriptions_directive);
/* Config store and registry - group F */
RUN_TEST(cli_config_open_close);
RUN_TEST(cli_config_get_set);
RUN_TEST(cli_config_get_bool);
RUN_TEST(cli_config_get_int);
RUN_TEST(cli_config_get_effective_env_overrides_db);
RUN_TEST(cli_config_registry_includes_dep_ranking_toggle);
RUN_TEST(cli_config_registry_includes_query_max_rows);
RUN_TEST(cli_config_registry_reindex_startup_guidance_is_precise);
RUN_TEST(cli_configuration_doc_auto_index_default_matches_registry);
RUN_TEST(cli_config_delete);
RUN_TEST(cli_config_persists);
RUN_TEST(cli_config_presets_apply_exact_capability_sets);
/* Replace binary (update command helper — group H) */
#ifndef _WIN32
RUN_TEST(replace_binary_overwrites_readonly);
RUN_TEST(replace_binary_creates_new_file);
#endif
/* CLI tool-argument flags / per-tool --help (#680) */
RUN_TEST(cli_build_args_json_string_flag_issue680);
RUN_TEST(cli_build_args_json_integer_flag_issue680);
RUN_TEST(cli_build_args_json_bare_boolean_issue680);
RUN_TEST(cli_build_args_json_unknown_flag_rejected);
RUN_TEST(cli_build_args_json_repeated_array_issue680);
RUN_TEST(cli_build_args_json_kebab_to_snake_issue680);
RUN_TEST(cli_build_args_json_key_equals_value_issue680);
RUN_TEST(cli_build_args_json_bad_positional_errors_issue680);
RUN_TEST(cli_print_tool_help_issue680);
}