mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-28 07:03:20 +08:00
Add cbm_getenv_fits so path-like environment values can fail closed when set but too long, instead of being silently truncated by bounded copies or falling back to HOME. Use checked formatting for Claude hook command/script paths, install/uninstall Claude config paths, install/update binary targets, and PATH directory writes. Overlong paths now return or report an error before mutating a truncated location. Add focused CLI regressions for overlong hook homes and CLAUDE_CONFIG_DIR, plus platform coverage for the new environment helper. Validation: make -f Makefile.cbm cbm; make -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=cli ./build/c/test-runner; CBM_ONLY_SUITE=platform ./build/c/test-runner; bash scripts/check-source-safety.sh; git diff --check. Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
3266 lines
112 KiB
C
3266 lines
112 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/constants.h"
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include <cli/cli.h>
|
|
#include <foundation/yaml.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
#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 = fopen(path, "w");
|
|
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 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;
|
|
}
|
|
|
|
/* 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");
|
|
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");
|
|
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);
|
|
|
|
unlink(bashrc);
|
|
if (old_shell) {
|
|
cbm_setenv("SHELL", old_shell, 1);
|
|
free(old_shell);
|
|
} else
|
|
cbm_unsetenv("SHELL");
|
|
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");
|
|
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");
|
|
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);
|
|
}
|
|
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
|
|
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);
|
|
}
|
|
unlink(fakecli);
|
|
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
|
|
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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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();
|
|
}
|
|
|
|
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);
|
|
|
|
/* Create old monolithic skill */
|
|
char old_dir[1024];
|
|
snprintf(old_dir, sizeof(old_dir), "%s/codebase-memory-mcp", skills_dir);
|
|
test_mkdirp(old_dir);
|
|
char old_file[1024];
|
|
snprintf(old_file, sizeof(old_file), "%s/SKILL.md", old_dir);
|
|
write_test_file(old_file, "old skill");
|
|
|
|
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);
|
|
|
|
/* 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);
|
|
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(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_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);
|
|
ASSERT(strstr(data, "\"mcp\"") != NULL);
|
|
ASSERT(strstr(data, "\"servers\"") != NULL);
|
|
ASSERT(strstr(data, "\"enabled\": true") != NULL);
|
|
ASSERT(strstr(data, "\"command\": \"/usr/local/bin/codebase-memory-mcp\"") != NULL);
|
|
ASSERT(strstr(data, "\"args\": []") != NULL);
|
|
ASSERT(strstr(data, "\"mcpServers\"") == NULL);
|
|
|
|
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_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(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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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(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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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(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();
|
|
}
|
|
|
|
/* 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);
|
|
|
|
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");
|
|
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");
|
|
|
|
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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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();
|
|
}
|
|
|
|
/* 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();
|
|
}
|
|
|
|
/* 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, "search_graph") != 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(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_strip_repairs_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");
|
|
|
|
ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0);
|
|
|
|
const char *d = read_test_file(cfg);
|
|
ASSERT_NOT_NULL(d);
|
|
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, "echo old"), 0);
|
|
ASSERT_EQ(count_substr(d, "echo duplicate"), 0);
|
|
ASSERT(strstr(d, "model = \"gpt-5\"") != NULL);
|
|
|
|
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_EQ(cbm_remove_gemini_session_hooks(cfg), 0);
|
|
d = read_test_file(cfg);
|
|
ASSERT_NULL(strstr(d, "SessionStart"));
|
|
|
|
test_rmdir_r(tmpdir);
|
|
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];
|
|
snprintf(dir, sizeof(dir), "%s/.gemini", tmpdir);
|
|
test_mkdirp(dir);
|
|
|
|
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
|
|
ASSERT_TRUE(agents.gemini);
|
|
|
|
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/Local/Zed", tmpdir);
|
|
#else
|
|
snprintf(dir, sizeof(dir), "%s/.config/zed", tmpdir);
|
|
#endif
|
|
test_mkdirp(dir);
|
|
|
|
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
|
|
ASSERT_TRUE(agents.zed);
|
|
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
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);
|
|
|
|
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
|
|
ASSERT_TRUE(agents.antigravity);
|
|
ASSERT_TRUE(agents.gemini); /* parent ~/.gemini implies gemini too */
|
|
|
|
test_rmdir_r(tmpdir);
|
|
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_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();
|
|
}
|
|
|
|
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 dir → no config dirs → no directory-based agents detected.
|
|
* Note: opencode/aider may still be detected via system fallback paths
|
|
* (e.g. /usr/local/bin) so we only assert on directory-based agents.
|
|
* Unset CLAUDE_CONFIG_DIR so the runner's real env 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_FALSE(agents.claude_code);
|
|
ASSERT_FALSE(agents.codex);
|
|
ASSERT_FALSE(agents.gemini);
|
|
ASSERT_FALSE(agents.zed);
|
|
ASSERT_FALSE(agents.antigravity);
|
|
ASSERT_FALSE(agents.kilocode);
|
|
ASSERT_FALSE(agents.kiro);
|
|
|
|
if (saved_ccd_copy) {
|
|
cbm_setenv("CLAUDE_CONFIG_DIR", saved_ccd_copy, 1);
|
|
free(saved_ccd_copy);
|
|
}
|
|
|
|
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_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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* Group B: MCP Config Upsert — Zed (corrected format)
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(cli_zed_mcp_uses_args_format) {
|
|
/* Verify Zed uses args:[""] NOT source:"custom" */
|
|
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);
|
|
ASSERT(strstr(data, "\"args\"") != NULL);
|
|
/* Must NOT have source:"custom" */
|
|
ASSERT(strstr(data, "\"source\"") == NULL);
|
|
|
|
test_rmdir_r(tmpdir);
|
|
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);
|
|
ASSERT(strstr(data, "\"enabled\":true") != NULL || strstr(data, "\"enabled\": true") != 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_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\":\"/old/path\"}}}");
|
|
|
|
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, "/old/path") == 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
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
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);
|
|
/* Matcher excludes Read per issue #362 (gating Read breaks the
|
|
* read-before-edit invariant). Assert exact matcher value AND that no
|
|
* Read-chained matcher slipped back in. */
|
|
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
|
|
ASSERT(strstr(data, "Glob|Read") == NULL);
|
|
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
|
|
|
|
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];
|
|
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir);
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
/* Our hook added with the non-blocking matcher (issue #362). */
|
|
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
|
|
ASSERT(strstr(data, "Glob|Read") == NULL);
|
|
/* Existing hook preserved */
|
|
ASSERT(strstr(data, "Bash") != NULL);
|
|
ASSERT(strstr(data, "firewall") != NULL);
|
|
|
|
test_rmdir_r(tmpdir);
|
|
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 old message */
|
|
write_test_file(settingspath,
|
|
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob|Read\","
|
|
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo old-cmm-message\"}]}]}}");
|
|
|
|
int rc = cbm_upsert_claude_hooks(settingspath);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
const char *data = read_test_file(settingspath);
|
|
ASSERT_NOT_NULL(data);
|
|
/* Old message gone, new hook script path present */
|
|
ASSERT(strstr(data, "old-cmm-message") == NULL);
|
|
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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 old-cmm\"}]}]}}");
|
|
|
|
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, "old-cmm") == 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_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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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);
|
|
|
|
remove(path);
|
|
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");
|
|
|
|
remove(path);
|
|
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");
|
|
}
|
|
remove(config_db);
|
|
rmdir(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* Suite definition
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
SUITE(cli) {
|
|
/* 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);
|
|
|
|
/* 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);
|
|
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_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_uninstall_uses_nested_servers);
|
|
|
|
/* VS Code MCP (2 tests — install_test.go) */
|
|
RUN_TEST(cli_vscode_mcp_install);
|
|
RUN_TEST(cli_vscode_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_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);
|
|
|
|
/* Dry-run lifecycle (2 tests) */
|
|
RUN_TEST(cli_install_dry_run);
|
|
RUN_TEST(cli_uninstall_dry_run);
|
|
|
|
/* Full lifecycle (1 test — cli_test.go) */
|
|
RUN_TEST(cli_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_detect_agents_finds_cursor_issue222);
|
|
RUN_TEST(cli_install_plan_receipt_no_mutation_issue388);
|
|
RUN_TEST(cli_codex_session_hook_issue330);
|
|
RUN_TEST(cli_codex_mcp_and_hook_upserts_are_idempotent);
|
|
RUN_TEST(cli_codex_hook_strip_repairs_orphan_end_sentinel);
|
|
RUN_TEST(cli_gemini_session_hook_parity);
|
|
RUN_TEST(cli_detect_agents_finds_gemini);
|
|
RUN_TEST(cli_detect_agents_finds_zed);
|
|
RUN_TEST(cli_detect_agents_finds_antigravity);
|
|
RUN_TEST(cli_detect_agents_finds_kilocode);
|
|
RUN_TEST(cli_detect_agents_finds_kiro);
|
|
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_existing);
|
|
RUN_TEST(cli_upsert_codex_mcp_replace);
|
|
|
|
/* Zed MCP format fix (1 test — group B) */
|
|
RUN_TEST(cli_zed_mcp_uses_args_format);
|
|
|
|
/* OpenCode MCP config upsert (2 tests — group B) */
|
|
RUN_TEST(cli_upsert_opencode_mcp_fresh);
|
|
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 (7 tests — group C) */
|
|
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);
|
|
|
|
/* Claude Code hooks (7 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_upsert_claude_hook_fresh);
|
|
RUN_TEST(cli_upsert_claude_hook_existing);
|
|
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 (6 tests — 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_delete);
|
|
RUN_TEST(cli_config_persists);
|
|
|
|
/* Replace binary (update command helper — group H) */
|
|
#ifndef _WIN32
|
|
RUN_TEST(replace_binary_overwrites_readonly);
|
|
RUN_TEST(replace_binary_creates_new_file);
|
|
#endif
|
|
}
|