mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
Extensionless bash shims under %USERPROFILE%\.claude\hooks triggered the Windows 'How do you want to open this file?' dialog whenever an editor (Cursor) scanned the hooks dir, and could not execute without bash anyway. All three Claude hook scripts (discovery gate, session reminder, subagent reminder) now install as .cmd files with cmd syntax on Windows - @echo off bodies, caret-escaped pipes in the reminder text, 2>/dev/null redirects - and the install removes the pre-existing extensionless twin so upgrades stop triggering the dialog. The settings.json command paths follow automatically since they are built from the same script-name macros. POSIX behavior is byte-identical and guarded (no .cmd twin). Closes #929 Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
3317 lines
116 KiB
C
3317 lines
116 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 "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include <cli/cli.h>
|
|
#include <foundation/yaml.h>
|
|
#include <stdint.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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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, "14 MCP Tools") != NULL);
|
|
|
|
/* Gotchas section */
|
|
ASSERT(strstr(sk[0].content, "Gotchas") != NULL);
|
|
|
|
PASS();
|
|
}
|
|
|
|
TEST(cli_codex_instructions) {
|
|
/* Port of TestCodexInstructionsCreation */
|
|
const char *instr = cbm_get_codex_instructions();
|
|
ASSERT_NOT_NULL(instr);
|
|
ASSERT(strstr(instr, "Codebase Knowledge Graph") != NULL);
|
|
ASSERT(strstr(instr, "trace_path") != NULL);
|
|
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_junie_mcp_install_issue651) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-mcp-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
char configpath[512];
|
|
snprintf(configpath, sizeof(configpath), "%s/.junie/mcp/mcp.json", tmpdir);
|
|
|
|
int rc = cbm_upsert_junie_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
const char *data = read_test_file(configpath);
|
|
ASSERT_NOT_NULL(data);
|
|
ASSERT(strstr(data, "mcpServers") != NULL);
|
|
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
|
|
ASSERT(strstr(data, "/usr/local/bin/codebase-memory-mcp") != NULL);
|
|
|
|
rc = cbm_upsert_junie_mcp("/usr/local/bin/codebase-memory-mcp", configpath);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
data = read_test_file(configpath);
|
|
ASSERT_NOT_NULL(data);
|
|
int count = 0;
|
|
const char *p = data;
|
|
while ((p = strstr(p, "\"codebase-memory-mcp\"")) != NULL) {
|
|
count++;
|
|
p += 20;
|
|
}
|
|
ASSERT_EQ(count, 1);
|
|
|
|
rc = cbm_remove_junie_mcp(configpath);
|
|
ASSERT_EQ(rc, 0);
|
|
|
|
data = read_test_file(configpath);
|
|
ASSERT_NOT_NULL(data);
|
|
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();
|
|
}
|
|
|
|
TEST(cli_extract_binary_from_zip_rejects_truncated_deflate_size_over_int_max) {
|
|
const char *filename = "codebase-memory-mcp";
|
|
const unsigned char deflated[] = {0xAB, 0x00, 0x00}; /* raw DEFLATE for "x" */
|
|
size_t name_len = strlen(filename);
|
|
size_t zip_len = 30 + name_len + sizeof(deflated);
|
|
unsigned char *zip = calloc(1, zip_len);
|
|
ASSERT_NOT_NULL(zip);
|
|
|
|
uint32_t comp_size = 0xFFFF0000U;
|
|
uint32_t uncomp_size = 1U;
|
|
zip[0] = 0x50;
|
|
zip[1] = 0x4B;
|
|
zip[2] = 0x03;
|
|
zip[3] = 0x04;
|
|
zip[8] = 8;
|
|
zip[9] = 0;
|
|
zip[18] = (unsigned char)(comp_size & 0xFF);
|
|
zip[19] = (unsigned char)((comp_size >> 8) & 0xFF);
|
|
zip[20] = (unsigned char)((comp_size >> 16) & 0xFF);
|
|
zip[21] = (unsigned char)((comp_size >> 24) & 0xFF);
|
|
zip[22] = (unsigned char)(uncomp_size & 0xFF);
|
|
zip[23] = (unsigned char)((uncomp_size >> 8) & 0xFF);
|
|
zip[24] = (unsigned char)((uncomp_size >> 16) & 0xFF);
|
|
zip[25] = (unsigned char)((uncomp_size >> 24) & 0xFF);
|
|
zip[26] = (unsigned char)(name_len & 0xFF);
|
|
zip[27] = (unsigned char)((name_len >> 8) & 0xFF);
|
|
memcpy(zip + 30, filename, name_len);
|
|
memcpy(zip + 30 + name_len, deflated, sizeof(deflated));
|
|
|
|
int out_len = 0;
|
|
unsigned char *extracted = cbm_extract_binary_from_zip(zip, (int)zip_len, &out_len);
|
|
if (extracted) {
|
|
free(extracted);
|
|
free(zip);
|
|
FAIL("accepted a truncated deflated zip entry with a wrapped compressed size");
|
|
}
|
|
ASSERT_EQ(out_len, 0);
|
|
free(zip);
|
|
PASS();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* Skill dry-run tests
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(cli_install_dry_run) {
|
|
/* Port of TestCLI_InstallDryRun */
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-dry-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
char skills_dir[512];
|
|
snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", tmpdir);
|
|
|
|
int count = cbm_install_skills(skills_dir, false, true);
|
|
ASSERT_EQ(count, CBM_SKILL_COUNT);
|
|
|
|
/* Skills should NOT be created */
|
|
const cbm_skill_t *sk = cbm_get_skills();
|
|
for (int i = 0; i < CBM_SKILL_COUNT; i++) {
|
|
char path[1024];
|
|
snprintf(path, sizeof(path), "%s/%s/SKILL.md", skills_dir, sk[i].name);
|
|
struct stat st;
|
|
ASSERT(stat(path, &st) != 0);
|
|
}
|
|
|
|
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 Cursor + Codex "detected". */
|
|
char dir[512];
|
|
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, ".cursor/mcp.json") != NULL);
|
|
ASSERT(strstr(json, ".codex/config.toml") != NULL);
|
|
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();
|
|
}
|
|
|
|
/* 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();
|
|
}
|
|
|
|
/* Claude SubagentStart reminder: subagents spawned via the Agent tool do not
|
|
* fire SessionStart, so this hook is their code-discovery channel. Verify the
|
|
* install shape, idempotent re-install, and clean removal. */
|
|
TEST(cli_claude_subagent_hook) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-subhook-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
char cfg[512];
|
|
snprintf(cfg, sizeof(cfg), "%s/settings.json", tmpdir);
|
|
|
|
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
|
|
const char *d = read_test_file(cfg);
|
|
ASSERT_NOT_NULL(d);
|
|
ASSERT(strstr(d, "SubagentStart") != NULL);
|
|
ASSERT(strstr(d, "\"*\"") != NULL); /* match-all matcher */
|
|
ASSERT(strstr(d, "cbm-subagent-reminder") != NULL); /* points at the hook script */
|
|
|
|
/* Idempotent: a second upsert must not duplicate our entry. */
|
|
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
|
|
d = read_test_file(cfg);
|
|
ASSERT_NOT_NULL(d);
|
|
int count = 0;
|
|
for (const char *p = d; (p = strstr(p, "cbm-subagent-reminder")) != NULL; p++)
|
|
count++;
|
|
ASSERT_EQ(count, 1);
|
|
|
|
ASSERT_EQ(cbm_remove_claude_subagent_hooks(cfg), 0);
|
|
d = read_test_file(cfg);
|
|
ASSERT_NULL(strstr(d, "SubagentStart"));
|
|
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* A user's own catch-all ("*") SubagentStart hook must survive CMM install and
|
|
* uninstall: ownership is keyed on the command, not just the "*" matcher. */
|
|
TEST(cli_claude_subagent_hook_preserves_user_entry) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-subuser-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
char cfg[512];
|
|
snprintf(cfg, sizeof(cfg), "%s/settings.json", tmpdir);
|
|
/* Pre-existing user SubagentStart hook, also matcher "*", different command. */
|
|
write_test_file(
|
|
cfg, "{\"hooks\":{\"SubagentStart\":[{\"matcher\":\"*\","
|
|
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo user-subagent-hook\"}]}]}}");
|
|
|
|
/* Install CMM's hook: the user's "*" entry must remain, ours added alongside. */
|
|
ASSERT_EQ(cbm_upsert_claude_subagent_hooks(cfg), 0);
|
|
const char *d = read_test_file(cfg);
|
|
ASSERT_NOT_NULL(d);
|
|
ASSERT(strstr(d, "echo user-subagent-hook") != NULL); /* user's hook untouched */
|
|
ASSERT(strstr(d, "cbm-subagent-reminder") != NULL); /* ours added */
|
|
|
|
/* Remove CMM's hook: the user's entry must still be intact, ours gone. */
|
|
ASSERT_EQ(cbm_remove_claude_subagent_hooks(cfg), 0);
|
|
d = read_test_file(cfg);
|
|
ASSERT_NOT_NULL(d);
|
|
ASSERT(strstr(d, "echo user-subagent-hook") != NULL); /* user's hook preserved */
|
|
ASSERT_NULL(strstr(d, "cbm-subagent-reminder")); /* only ours removed */
|
|
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/* issue #651: Junie (~/.junie/) must be detected so install registers the
|
|
* MCP server in ~/.junie/mcp/mcp.json. */
|
|
TEST(cli_detect_agents_finds_junie_issue651) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
char dir[512];
|
|
snprintf(dir, sizeof(dir), "%s/.junie", tmpdir);
|
|
test_mkdirp(dir);
|
|
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
|
|
ASSERT_TRUE(agents.junie);
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
TEST(cli_detect_agents_none_found) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
/* Empty home 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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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_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_snippet") != 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 includes Read for the coverage note (#963). Safe against the
|
|
* issue-#362 gate hazard: the augmenter is structurally non-blocking
|
|
* (always exit 0, additionalContext only). */
|
|
ASSERT(strstr(data, "\"Grep|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];
|
|
#ifdef _WIN32
|
|
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate.cmd",
|
|
tmpdir);
|
|
#else
|
|
snprintf(script_path, sizeof(script_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir);
|
|
#endif
|
|
const char *data = read_test_file(script_path);
|
|
ASSERT_NOT_NULL(data);
|
|
/* No predictable temp/state file and no PPID-derived path. */
|
|
ASSERT(strstr(data, "/tmp") == NULL);
|
|
ASSERT(strstr(data, "PPID") == NULL);
|
|
/* It delegates to the stateless compiled augmenter (stdout only). */
|
|
ASSERT(strstr(data, "hook-augment") != NULL);
|
|
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* #929: on Windows, extensionless bash shims under .claude/hooks trigger the
|
|
* "How do you want to open this file?" dialog when editors (Cursor) scan the
|
|
* dir, and cannot execute without bash. Windows must install .cmd scripts
|
|
* (and remove the extensionless legacy twin on upgrade); POSIX keeps the
|
|
* extensionless bash form with no .cmd twin. */
|
|
TEST(cli_hook_scripts_platform_shape_issue929) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook929-XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
|
|
char hooks_dir[512];
|
|
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
|
|
|
|
#ifdef _WIN32
|
|
/* Upgrade path: pre-create the pre-#929 extensionless file; the install
|
|
* must remove it so the Open-With trigger disappears. */
|
|
cbm_mkdir_p(hooks_dir, 0755);
|
|
char legacy_path[512];
|
|
snprintf(legacy_path, sizeof(legacy_path), "%s/cbm-code-discovery-gate", hooks_dir);
|
|
write_test_file(legacy_path, "#!/bin/bash\nexit 0\n");
|
|
#endif
|
|
|
|
cbm_install_hook_gate_script(tmpdir, "/usr/local/bin/codebase-memory-mcp");
|
|
|
|
char script_path[512];
|
|
#ifdef _WIN32
|
|
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
|
|
const char *data = read_test_file(script_path);
|
|
ASSERT_NOT_NULL(data);
|
|
ASSERT(strncmp(data, "@echo off", 9) == 0); /* cmd, not bash */
|
|
ASSERT(strstr(data, "hook-augment") != NULL);
|
|
/* Legacy extensionless twin removed on upgrade. */
|
|
FILE *lf = fopen(legacy_path, "r");
|
|
if (lf) {
|
|
fclose(lf);
|
|
FAIL("legacy extensionless hook file still present after install");
|
|
}
|
|
#else
|
|
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir);
|
|
const char *data = read_test_file(script_path);
|
|
ASSERT_NOT_NULL(data);
|
|
ASSERT(strncmp(data, "#!/usr/bin/env bash", 19) == 0);
|
|
/* No .cmd twin on POSIX. */
|
|
char cmd_path[512];
|
|
snprintf(cmd_path, sizeof(cmd_path), "%s/cbm-code-discovery-gate.cmd", hooks_dir);
|
|
FILE *cf = fopen(cmd_path, "r");
|
|
if (cf) {
|
|
fclose(cf);
|
|
FAIL(".cmd twin must not exist on POSIX");
|
|
}
|
|
#endif
|
|
|
|
test_rmdir_r(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* issue #618: hook-augment was a structural no-op on Windows because its path
|
|
* guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter
|
|
* cwd (C:/repo) was rejected before any search_graph query. The predicate must
|
|
* accept POSIX and Windows drive roots alike (callers normalize '\\' to '/'). */
|
|
TEST(cli_hook_augment_path_is_abs) {
|
|
/* POSIX absolute (unchanged behavior) */
|
|
ASSERT(cbm_hook_path_is_abs("/home/u/proj"));
|
|
/* Windows drive roots — the #618 regression */
|
|
ASSERT(cbm_hook_path_is_abs("C:/Users/me/proj"));
|
|
ASSERT(cbm_hook_path_is_abs("C:/"));
|
|
ASSERT(cbm_hook_path_is_abs("C:"));
|
|
ASSERT(cbm_hook_path_is_abs("d:/lowercase/drive"));
|
|
/* Not absolute → augmenter no-ops cleanly */
|
|
ASSERT(!cbm_hook_path_is_abs("relative/path"));
|
|
ASSERT(!cbm_hook_path_is_abs("proj"));
|
|
ASSERT(!cbm_hook_path_is_abs(""));
|
|
ASSERT(!cbm_hook_path_is_abs(NULL));
|
|
PASS();
|
|
}
|
|
|
|
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 current matcher (Read included for #963). */
|
|
ASSERT(strstr(data, "\"Grep|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 an OLD matcher (pre-#963) + old message */
|
|
write_test_file(settingspath,
|
|
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob\","
|
|
"\"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();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* 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_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 */
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* CLI tool-argument flags / per-tool --help (#680)
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
/* A plain `--flag value` pair maps to a string property by schema type. */
|
|
TEST(cli_build_args_json_string_flag_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--repo-path", "/x"};
|
|
char *json = cbm_cli_build_args_json("index_repository", 2, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT_NULL(err);
|
|
ASSERT(strstr(json, "\"repo_path\":\"/x\"") != NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* An integer-typed property serializes as a JSON NUMBER, not a quoted string. */
|
|
TEST(cli_build_args_json_integer_flag_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--limit", "100"};
|
|
char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT(strstr(json, "\"limit\":100") != NULL);
|
|
ASSERT(strstr(json, "\"limit\":\"100\"") == NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* A bare boolean flag (no value) becomes true. */
|
|
TEST(cli_build_args_json_bare_boolean_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--exclude-entry-points"};
|
|
char *json = cbm_cli_build_args_json("search_graph", 1, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT(strstr(json, "\"exclude_entry_points\":true") != NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* An unknown flag for a KNOWN tool must be rejected loudly, not silently
|
|
* typed as a string and dropped server-side (#997). GF1 eval: `trace_path
|
|
* --max-depth 1` was accepted, the real --depth stayed at default 3, and
|
|
* the trace silently returned hop-2/3 results — silent-wrong output. The
|
|
* error names the closest valid flag as a suggestion. */
|
|
TEST(cli_build_args_json_unknown_flag_rejected) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--max-depth", "1"};
|
|
char *json = cbm_cli_build_args_json("trace_path", 2, argv, &err);
|
|
ASSERT_NULL(json);
|
|
ASSERT_NOT_NULL(err);
|
|
ASSERT(strstr(err, "unknown flag") != NULL);
|
|
ASSERT(strstr(err, "max-depth") != NULL);
|
|
ASSERT(strstr(err, "--depth") != NULL); /* nearest-flag suggestion */
|
|
free(err);
|
|
PASS();
|
|
}
|
|
|
|
/* A repeated array-typed flag accumulates into a JSON array. */
|
|
TEST(cli_build_args_json_repeated_array_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--semantic-query", "send", "--semantic-query", "publish"};
|
|
char *json = cbm_cli_build_args_json("search_graph", 4, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT(strstr(json, "\"semantic_query\":[\"send\",\"publish\"]") != NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* kebab-case flag names map to snake_case JSON keys. */
|
|
TEST(cli_build_args_json_kebab_to_snake_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--name-pattern", "Foo.*"};
|
|
char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT(strstr(json, "\"name_pattern\":\"Foo.*\"") != NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* `--key=value` form splits on the FIRST `=`; value may contain spaces/dashes. */
|
|
TEST(cli_build_args_json_key_equals_value_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"--repo-path=/a b"};
|
|
char *json = cbm_cli_build_args_json("index_repository", 1, argv, &err);
|
|
ASSERT_NOT_NULL(json);
|
|
ASSERT(strstr(json, "\"repo_path\":\"/a b\"") != NULL);
|
|
free(json);
|
|
PASS();
|
|
}
|
|
|
|
/* A non-`--` positional is an error: returns NULL and sets *err_out. */
|
|
TEST(cli_build_args_json_bad_positional_errors_issue680) {
|
|
char *err = NULL;
|
|
char *argv[] = {"foo"};
|
|
char *json = cbm_cli_build_args_json("search_graph", 1, argv, &err);
|
|
ASSERT_NULL(json);
|
|
ASSERT_NOT_NULL(err);
|
|
free(err);
|
|
PASS();
|
|
}
|
|
|
|
/* Per-tool --help returns 0 for a known tool, -1 for an unknown one. */
|
|
TEST(cli_print_tool_help_issue680) {
|
|
ASSERT_EQ(cbm_cli_print_tool_help("index_repository"), 0);
|
|
ASSERT_EQ(cbm_cli_print_tool_help("nope_not_a_tool"), -1);
|
|
PASS();
|
|
}
|
|
|
|
/* The self-update path verifies a downloaded archive against a published
|
|
* checksum. That check is only meaningful if the digest is actually computed —
|
|
* a broken hash command (it once invoked `shasum -a CBM_SZ_256`, an invalid
|
|
* algorithm, from a bad macro rename inside the shell string) makes every
|
|
* digest fail, and the caller then falls through and installs unverified.
|
|
* Guard the digest itself against a known vector. */
|
|
extern int cbm_cli_sha256_file(const char *path, char *out, size_t out_size);
|
|
|
|
/* Hash `content` (len bytes) via a temp file and compare to expected hex.
|
|
* Returns 1 on match, 0 otherwise. */
|
|
static int sha256_vector_ok(const void *content, size_t len, const char *expected) {
|
|
char path[512];
|
|
snprintf(path, sizeof(path), "%s/cbm_sha_XXXXXX", cbm_tmpdir());
|
|
int fd = cbm_mkstemp(path);
|
|
if (fd < 0) {
|
|
return 0;
|
|
}
|
|
FILE *fp = fdopen(fd, "wb");
|
|
if (!fp) {
|
|
return 0;
|
|
}
|
|
if (len > 0) {
|
|
fwrite(content, 1, len, fp);
|
|
}
|
|
fclose(fp);
|
|
|
|
char digest[128] = {0};
|
|
int rc = cbm_cli_sha256_file(path, digest, sizeof(digest));
|
|
remove(path);
|
|
return rc == 0 && strcmp(digest, expected) == 0;
|
|
}
|
|
|
|
/* NIST FIPS 180-4 SHA-256 test vectors: empty input, a single block ("abc"),
|
|
* and a 56-byte input that forces the length padding into a second block. */
|
|
TEST(cli_sha256_file_matches_known_vector) {
|
|
ASSERT_TRUE(sha256_vector_ok(
|
|
"", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
|
|
ASSERT_TRUE(sha256_vector_ok(
|
|
"abc", 3, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
|
|
ASSERT_TRUE(
|
|
sha256_vector_ok("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56,
|
|
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"));
|
|
PASS();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════
|
|
* Suite definition
|
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
|
|
SUITE(cli) {
|
|
RUN_TEST(cli_sha256_file_matches_known_vector);
|
|
/* Version (2 tests — selfupdate_test.go) */
|
|
RUN_TEST(cli_compare_versions);
|
|
RUN_TEST(cli_version_get_set);
|
|
|
|
/* Shell RC detection (5 tests — install_test.go) */
|
|
RUN_TEST(cli_detect_shell_rc_zsh);
|
|
RUN_TEST(cli_detect_shell_rc_bash);
|
|
RUN_TEST(cli_detect_shell_rc_bash_with_bashrc);
|
|
RUN_TEST(cli_detect_shell_rc_fish);
|
|
RUN_TEST(cli_detect_shell_rc_default);
|
|
|
|
/* CLI binary detection (3 tests — install_test.go) */
|
|
RUN_TEST(cli_find_cli_not_found);
|
|
RUN_TEST(cli_find_cli_on_path);
|
|
RUN_TEST(cli_find_cli_fallback_paths);
|
|
|
|
/* 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_junie_mcp_install_issue651);
|
|
RUN_TEST(cli_gemini_mcp_install);
|
|
RUN_TEST(cli_openclaw_mcp_install_uses_nested_servers);
|
|
RUN_TEST(cli_openclaw_mcp_preserves_existing_config);
|
|
RUN_TEST(cli_openclaw_mcp_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);
|
|
RUN_TEST(cli_extract_binary_from_zip_rejects_truncated_deflate_size_over_int_max);
|
|
|
|
/* Dry-run lifecycle (2 tests) */
|
|
RUN_TEST(cli_install_dry_run);
|
|
RUN_TEST(cli_uninstall_dry_run);
|
|
|
|
/* 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);
|
|
|
|
/* 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_gemini_session_hook_parity);
|
|
RUN_TEST(cli_claude_subagent_hook);
|
|
RUN_TEST(cli_claude_subagent_hook_preserves_user_entry);
|
|
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_finds_junie_issue651);
|
|
RUN_TEST(cli_detect_agents_none_found);
|
|
|
|
/* Codex MCP config upsert (3 tests — group B) */
|
|
RUN_TEST(cli_upsert_codex_mcp_fresh);
|
|
RUN_TEST(cli_upsert_codex_mcp_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 (2 tests — group B) */
|
|
RUN_TEST(cli_upsert_antigravity_mcp_fresh);
|
|
RUN_TEST(cli_upsert_antigravity_mcp_replace);
|
|
|
|
/* Instructions file upsert (6 tests — group C) */
|
|
RUN_TEST(cli_upsert_instructions_fresh);
|
|
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 (5 tests — group D) */
|
|
RUN_TEST(cli_hook_gate_script_no_predictable_tmp_issue384);
|
|
RUN_TEST(cli_hook_scripts_platform_shape_issue929);
|
|
RUN_TEST(cli_hook_augment_path_is_abs);
|
|
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);
|
|
|
|
/* 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_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
|
|
|
|
/* CLI tool-argument flags / per-tool --help (#680) */
|
|
RUN_TEST(cli_build_args_json_string_flag_issue680);
|
|
RUN_TEST(cli_build_args_json_integer_flag_issue680);
|
|
RUN_TEST(cli_build_args_json_bare_boolean_issue680);
|
|
RUN_TEST(cli_build_args_json_unknown_flag_rejected);
|
|
RUN_TEST(cli_build_args_json_repeated_array_issue680);
|
|
RUN_TEST(cli_build_args_json_kebab_to_snake_issue680);
|
|
RUN_TEST(cli_build_args_json_key_equals_value_issue680);
|
|
RUN_TEST(cli_build_args_json_bad_positional_errors_issue680);
|
|
RUN_TEST(cli_print_tool_help_issue680);
|
|
}
|