diff --git a/src/cli/cli.c b/src/cli/cli.c index 124341c..45ecc8b 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1953,18 +1953,54 @@ static const char *detect_arch(void) { #endif } +/* ── Claude config dir helper ─────────────────────────────────── */ + +/* Return the Claude Code config directory. + * Checks CLAUDE_CONFIG_DIR env var first; falls back to {home}/.claude. + * Result is written into buf (size n). Returns buf, or NULL on error. */ +char *cbm_claude_config_dir(const char *home, char *buf, size_t n) { + const char *env = getenv("CLAUDE_CONFIG_DIR"); + if (env && env[0]) { + snprintf(buf, n, "%s", env); + } else { + snprintf(buf, n, "%s/.claude", home); + } + return buf; +} + +/* Internal alias used within this file */ +static char *claude_config_dir(const char *home, char *buf, size_t n) { + return cbm_claude_config_dir(home, buf, n); +} + /* ── Subcommand: install ──────────────────────────────────────── */ int cbm_cmd_install(int argc, char **argv) { parse_auto_answer(argc, argv); bool dry_run = false; bool force = false; + bool has_project = false; + char project_path[1024]; + project_path[0] = '\0'; + for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; - } - if (strcmp(argv[i], "--force") == 0) { + } else if (strcmp(argv[i], "--force") == 0) { force = true; + } else if (strcmp(argv[i], "--project") == 0) { + has_project = true; + /* Consume the next arg as the project path if it doesn't look like a flag */ + if (i + 1 < argc && argv[i + 1][0] != '-') { + snprintf(project_path, sizeof(project_path), "%s", argv[i + 1]); + i++; + } else { + /* No path provided — use cwd */ + if (!getcwd(project_path, sizeof(project_path))) { + fprintf(stderr, "error: getcwd failed\n"); + return 1; + } + } } } @@ -1974,6 +2010,16 @@ int cbm_cmd_install(int argc, char **argv) { return 1; } + /* Determine the Claude Code config base directory: + * --project overrides to {project_path}/.claude; + * otherwise CLAUDE_CONFIG_DIR env var overrides ~/.claude. */ + char claude_dir[1024]; + if (has_project) { + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", project_path); + } else { + claude_config_dir(home, claude_dir, sizeof(claude_dir)); + } + printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); /* Step 1: Check for existing indexes */ @@ -2011,8 +2057,14 @@ int cbm_cmd_install(int argc, char **argv) { char self_path[1024]; snprintf(self_path, sizeof(self_path), "%s/.local/bin/codebase-memory-mcp", home); - /* Step 3: Detect agents */ + /* Step 3: Detect agents. + * When --project is set, we force Claude Code to true (we always install + * into the project's .claude/ dir) and still detect all other agents from + * the global home directory. */ cbm_detected_agents_t agents = cbm_detect_agents(home); + if (has_project) { + agents.claude_code = true; + } printf("Detected agents:"); if (agents.claude_code) { printf(" Claude-Code"); @@ -2047,7 +2099,7 @@ int cbm_cmd_install(int argc, char **argv) { /* Step 4: Install Claude Code skills + hooks */ if (agents.claude_code) { char skills_dir[1024]; - snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", claude_dir); printf("Claude Code:\n"); int skill_count = cbm_install_skills(skills_dir, force, dry_run); @@ -2059,7 +2111,7 @@ int cbm_cmd_install(int argc, char **argv) { /* MCP config (.mcp.json) */ char mcp_path[1024]; - snprintf(mcp_path, sizeof(mcp_path), "%s/.claude/.mcp.json", home); + snprintf(mcp_path, sizeof(mcp_path), "%s/.mcp.json", claude_dir); if (!dry_run) { cbm_install_editor_mcp(self_path, mcp_path); } @@ -2067,13 +2119,19 @@ int cbm_cmd_install(int argc, char **argv) { /* PreToolUse hook */ char settings_path[1024]; - snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", claude_dir); if (!dry_run) { cbm_upsert_claude_hooks(settings_path); } printf(" hooks: PreToolUse (Grep|Glob reminder)\n"); } + /* Steps 5-12: Global agent installs — skipped when --project is used + * because those tools use their own global config directories, not the + * project-local .claude/ tree. */ + const char *shell_rc = ""; + if (!has_project) { + /* Step 5: Install Codex CLI */ if (agents.codex) { printf("Codex CLI:\n"); @@ -2203,18 +2261,23 @@ int cbm_cmd_install(int argc, char **argv) { /* Step 12: Ensure PATH */ char bin_dir[1024]; snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); - const char *rc = cbm_detect_shell_rc(home); - if (rc[0]) { - int path_rc = cbm_ensure_path(bin_dir, rc, dry_run); + shell_rc = cbm_detect_shell_rc(home); + if (shell_rc[0]) { + int path_rc = cbm_ensure_path(bin_dir, shell_rc, dry_run); if (path_rc == 0) { - printf("\nAdded %s to PATH in %s\n", bin_dir, rc); + printf("\nAdded %s to PATH in %s\n", bin_dir, shell_rc); } else if (path_rc == 1) { printf("\nPATH already includes %s\n", bin_dir); } } - printf("\nInstall complete. Restart your shell or run:\n"); - printf(" source %s\n", rc); + } /* end if (!has_project) */ + + printf("\nInstall complete."); + if (!has_project && shell_rc[0]) { + printf(" Restart your shell or run:\n source %s", shell_rc); + } + printf("\n"); if (dry_run) { printf("\n(dry-run — no files were modified)\n"); } @@ -2226,9 +2289,24 @@ int cbm_cmd_install(int argc, char **argv) { int cbm_cmd_uninstall(int argc, char **argv) { parse_auto_answer(argc, argv); bool dry_run = false; + bool has_project = false; + char project_path[1024]; + project_path[0] = '\0'; + for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; + } else if (strcmp(argv[i], "--project") == 0) { + has_project = true; + if (i + 1 < argc && argv[i + 1][0] != '-') { + snprintf(project_path, sizeof(project_path), "%s", argv[i + 1]); + i++; + } else { + if (!getcwd(project_path, sizeof(project_path))) { + fprintf(stderr, "error: getcwd failed\n"); + return 1; + } + } } } @@ -2238,32 +2316,47 @@ int cbm_cmd_uninstall(int argc, char **argv) { return 1; } + /* Determine the Claude Code config base directory */ + char claude_dir[1024]; + if (has_project) { + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", project_path); + } else { + claude_config_dir(home, claude_dir, sizeof(claude_dir)); + } + printf("codebase-memory-mcp uninstall\n\n"); /* Step 1: Detect agents and remove per-agent configs */ cbm_detected_agents_t agents = cbm_detect_agents(home); + if (has_project) { + agents.claude_code = true; + } if (agents.claude_code) { char skills_dir[1024]; - snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", claude_dir); int removed = cbm_remove_skills(skills_dir, dry_run); printf("Claude Code: removed %d skill(s)\n", removed); char mcp_path[1024]; - snprintf(mcp_path, sizeof(mcp_path), "%s/.claude/.mcp.json", home); + snprintf(mcp_path, sizeof(mcp_path), "%s/.mcp.json", claude_dir); if (!dry_run) { cbm_remove_editor_mcp(mcp_path); } printf(" removed MCP config entry\n"); char settings_path[1024]; - snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home); + snprintf(settings_path, sizeof(settings_path), "%s/settings.json", claude_dir); if (!dry_run) { cbm_remove_claude_hooks(settings_path); } printf(" removed PreToolUse hook\n"); } + /* Steps below (other agents, indexes, binary) are global-only — skip when + * --project is used so only the project-local .claude/ tree is touched. */ + if (!has_project) { + if (agents.codex) { char config_path[1024]; snprintf(config_path, sizeof(config_path), "%s/.codex/config.toml", home); @@ -2413,6 +2506,8 @@ int cbm_cmd_uninstall(int argc, char **argv) { printf("Removed %s\n", bin_path); } + } /* end if (!has_project) */ + printf("\nUninstall complete.\n"); if (dry_run) { printf("(dry-run — no files were modified)\n"); @@ -2425,6 +2520,25 @@ int cbm_cmd_uninstall(int argc, char **argv) { int cbm_cmd_update(int argc, char **argv) { parse_auto_answer(argc, argv); + bool has_project = false; + char project_path[1024]; + project_path[0] = '\0'; + + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "--project") == 0) { + has_project = true; + if (i + 1 < argc && argv[i + 1][0] != '-') { + snprintf(project_path, sizeof(project_path), "%s", argv[i + 1]); + i++; + } else { + if (!getcwd(project_path, sizeof(project_path))) { + fprintf(stderr, "error: getcwd failed\n"); + return 1; + } + } + } + } + const char *home = getenv("HOME"); if (!home) { fprintf(stderr, "error: HOME not set\n"); @@ -2590,10 +2704,21 @@ int cbm_cmd_update(int argc, char **argv) { } /* Step 6: Reinstall skills (force to pick up new content) */ + char claude_dir_upd[1024]; + claude_config_dir(home, claude_dir_upd, sizeof(claude_dir_upd)); char skills_dir[1024]; - snprintf(skills_dir, sizeof(skills_dir), "%s/.claude/skills", home); + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", claude_dir_upd); int skill_count = cbm_install_skills(skills_dir, true, false); - printf("Updated %d skill(s).\n", skill_count); + printf("Updated %d global skill(s).\n", skill_count); + + /* Also update project-local skills if --project was specified. */ + if (has_project) { + char proj_skills_dir[1024]; + snprintf(proj_skills_dir, sizeof(proj_skills_dir), "%s/.claude/skills", project_path); + int proj_skill_count = cbm_install_skills(proj_skills_dir, true, false); + printf("Updated %d project skill(s) in %s/.claude/skills.\n", proj_skill_count, + project_path); + } /* Step 7: Verify new version */ printf("\nUpdate complete. Verifying:\n"); diff --git a/src/cli/cli.h b/src/cli/cli.h index 733db73..0ecfaca 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -10,6 +10,7 @@ #define CBM_CLI_H #include +#include /* ── Version ──────────────────────────────────────────────────── */ @@ -231,6 +232,13 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" +/* ── Claude config dir helper ─────────────────────────────────── */ + +/* Return the Claude Code config directory into buf (size n). + * Checks CLAUDE_CONFIG_DIR env var first; falls back to {home}/.claude. + * Returns buf on success, NULL on error. */ +char *cbm_claude_config_dir(const char *home, char *buf, size_t n); + /* ── Subcommands (wired from main.c) ─────────────────────────── */ /* install: copy binary, install skills, install editor MCP configs, ensure PATH. diff --git a/tests/test_cli.c b/tests/test_cli.c index 19ccdec..21f3e21 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2012,6 +2012,91 @@ TEST(cli_config_persists) { PASS(); } +/* ═══════════════════════════════════════════════════════════════════ + * Group G: --project flag / CLAUDE_CONFIG_DIR + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(cli_claude_config_dir_default) { + /* Without CLAUDE_CONFIG_DIR set, returns {home}/.claude */ + const char *raw = getenv("CLAUDE_CONFIG_DIR"); + char *old_val = raw ? strdup(raw) : NULL; + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + + char buf[1024]; + char *result = cbm_claude_config_dir("/home/user", buf, sizeof(buf)); + ASSERT_NOT_NULL(result); + ASSERT_STR_EQ(result, "/home/user/.claude"); + + if (old_val) { + cbm_setenv("CLAUDE_CONFIG_DIR", old_val, 1); + free(old_val); + } + PASS(); +} + +TEST(cli_claude_config_dir_env_override) { + /* CLAUDE_CONFIG_DIR overrides the default ~/.claude path */ + const char *raw = getenv("CLAUDE_CONFIG_DIR"); + char *old_val = raw ? strdup(raw) : NULL; + cbm_setenv("CLAUDE_CONFIG_DIR", "/custom/claude/dir", 1); + + char buf[1024]; + char *result = cbm_claude_config_dir("/home/user", buf, sizeof(buf)); + ASSERT_NOT_NULL(result); + ASSERT_STR_EQ(result, "/custom/claude/dir"); + + if (old_val) { + cbm_setenv("CLAUDE_CONFIG_DIR", old_val, 1); + free(old_val); + } else { + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + } + PASS(); +} + +TEST(cli_project_install_targets_project_claude_dir) { + /* --project /some/path causes skills to install into {path}/.claude/skills/ + * We test this by directly calling cbm_install_skills with the expected path + * to verify the path construction logic matches the expected pattern. */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-proj-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + /* Simulate what cbm_cmd_install does when --project {tmpdir} is provided: + * claude_dir = {tmpdir}/.claude + * skills_dir = {tmpdir}/.claude/skills */ + char claude_dir[512]; + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", tmpdir); + + char skills_dir[512]; + snprintf(skills_dir, sizeof(skills_dir), "%s/skills", claude_dir); + + int count = cbm_install_skills(skills_dir, false, false); + ASSERT_EQ(count, CBM_SKILL_COUNT); + + /* Verify skills landed under project dir, not global home */ + 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); + } + + /* Also verify MCP config path pattern */ + char mcp_path[512]; + snprintf(mcp_path, sizeof(mcp_path), "%s/.mcp.json", claude_dir); + int rc = cbm_install_editor_mcp("/usr/local/bin/codebase-memory-mcp", mcp_path); + ASSERT_EQ(rc, 0); + + const char *data = read_test_file(mcp_path); + ASSERT_NOT_NULL(data); + ASSERT(strstr(data, "codebase-memory-mcp") != NULL); + + test_rmdir_r(tmpdir); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Suite definition * ═══════════════════════════════════════════════════════════════════ */ @@ -2148,4 +2233,9 @@ SUITE(cli) { RUN_TEST(cli_config_get_int); RUN_TEST(cli_config_delete); RUN_TEST(cli_config_persists); + + /* --project flag / CLAUDE_CONFIG_DIR (3 tests — group G) */ + RUN_TEST(cli_claude_config_dir_default); + RUN_TEST(cli_claude_config_dir_env_override); + RUN_TEST(cli_project_install_targets_project_claude_dir); }