fix(cc-config): .claude.json sits BESIDE ~/.claude by default, not inside it

The previous commit routed the read through getClaudeHome(), which appends
/.claude when CLAUDE_HOME is unset — pointing the dashboard at
~/.claude/.claude.json, a file the CLI never writes by default. Verified
against the real binary: with CLAUDE_CONFIG_DIR unset the CLI writes
$HOME/.claude.json; with it set, .claude.json sits flat inside that dir.
Branch on CLAUDE_HOME accordingly.

The test now drives a fake claude that writes where the real one does and
asserts getClaudeJsonPath() names that exact file, under both branches —
the previous test only restated the implementation.
This commit is contained in:
2026-09-09 10:00:05 +07:00
parent 17e4321761
commit c34931c3f5
5 changed files with 68 additions and 26 deletions
+41 -17
View File
@@ -225,26 +225,50 @@ describe("cc-plugin-cli CLAUDE_CONFIG_DIR pinning", () => {
}
});
// The two env tests above pin one half each. This pins the pair: the file
// the dashboard READS must be the file the CLI WRITES, under both settings.
// `.claude.json` was the only read path in cc-discovery not derived from
// getClaudeHome(), which is exactly how the two halves drifted apart.
it("reads the same .claude.json the CLI writes, with CLAUDE_HOME set or unset", () => {
// The two env tests above pin the env var each half of the pair depends
// on. This pins the pair itself end to end: the file getClaudeJsonPath()
// returns must be the file the CLI actually wrote — under BOTH an unset
// and a set CLAUDE_HOME. A fake `claude` that writes `.claude.json`
// exactly where the real binary does (verified against it: flat inside
// CLAUDE_CONFIG_DIR when set, beside — NOT inside — $HOME when unset)
// stands in for the real CLI; no real CLI or real $HOME/CLAUDE_HOME touched.
const WRITE_CLAUDE_JSON = 'echo "{}" > "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"';
it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME unset", async () => {
const cc = require("../lib/cc-discovery");
const originalHome = process.env.CLAUDE_HOME;
// CLI's write target = <CLAUDE_CONFIG_DIR>/.claude.json, and
// CLAUDE_CONFIG_DIR is either CLAUDE_HOME or the CLI's own default,
// which is the same directory getClaudeHome() returns.
const originalHome = process.env.HOME;
const originalClaudeHome = process.env.CLAUDE_HOME;
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakehome-"));
delete process.env.CLAUDE_HOME;
process.env.HOME = fakeHome;
const restore = stubClaude(WRITE_CLAUDE_JSON);
try {
delete process.env.CLAUDE_HOME;
assert.equal(cc.getClaudeJsonPath(), path.join(os.homedir(), ".claude", ".claude.json"));
process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
assert.equal(cc.getClaudeJsonPath(), "/tmp/some-other-claude-home/.claude.json");
await cli.marketplaceUpdate({ name: "m" });
assert.equal(cc.getClaudeJsonPath(), path.join(fakeHome, ".claude.json"));
assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look");
} finally {
if (originalHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = originalHome;
restore();
process.env.HOME = originalHome;
if (originalClaudeHome !== undefined) process.env.CLAUDE_HOME = originalClaudeHome;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it("writes .claude.json exactly where getClaudeJsonPath() reads it, with CLAUDE_HOME set", async () => {
const cc = require("../lib/cc-discovery");
const originalClaudeHome = process.env.CLAUDE_HOME;
const fakeClaudeHome = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-fakeclaudehome-"));
process.env.CLAUDE_HOME = fakeClaudeHome;
const restore = stubClaude(WRITE_CLAUDE_JSON);
try {
await cli.marketplaceUpdate({ name: "m" });
assert.equal(cc.getClaudeJsonPath(), path.join(fakeClaudeHome, ".claude.json"));
assert.ok(fs.existsSync(cc.getClaudeJsonPath()), "the CLI's write landed where reads look");
} finally {
restore();
if (originalClaudeHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = originalClaudeHome;
fs.rmSync(fakeClaudeHome, { recursive: true, force: true });
}
});
});
+15 -7
View File
@@ -29,13 +29,21 @@ function getProjectClaudeDir(cwd) {
}
function getClaudeJsonPath() {
// One root for everything: this file used to resolve from $HOME while the
// other 19 read paths derived from getClaudeHome(), and cc-plugin-cli.js's
// runClaude() pinned CLAUDE_CONFIG_DIR to getClaudeHome() — which is where
// the CLI puts .claude.json. The dashboard read one file and made the CLI
// write another, so MCP add/remove was inert. Keep this and runClaude's
// env in step; they are the same decision seen from two sides.
return path.join(getClaudeHome(), ".claude.json");
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
// - CLAUDE_HOME set: the CLI's CLAUDE_CONFIG_DIR (pinned to it by
// cc-plugin-cli.js's runClaude()) becomes a $HOME-substitute for both
// `.claude.json` AND the rest of the tree (projects/, settings.json,
// plugins/) — flat inside it, no nested ".claude". Same value
// getClaudeHome() returns in this branch, so the two formulas agree.
// - CLAUDE_HOME unset: runClaude() deliberately leaves CLAUDE_CONFIG_DIR
// unset too, and the CLI's OWN default writes `.claude.json` BESIDE
// `~/.claude`, not inside it — while getClaudeHome()'s unset branch
// appends "/.claude". Delegating to getClaudeHome() here would read
// `~/.claude/.claude.json`, a file the CLI never writes by default.
return process.env.CLAUDE_HOME
? path.join(process.env.CLAUDE_HOME, ".claude.json")
: path.join(os.homedir(), ".claude.json");
}
/**
+6
View File
@@ -327,6 +327,12 @@ const schemas = {
"Where the definition was found, e.g. `~/.claude.json (top-level)`, `~/.claude.json (projects[<root>])`, or `~/.claude/settings.json`.",
example: "~/.claude.json (top-level)",
},
removable: {
type: "boolean",
description:
"False when no `claude mcp` scope can address this entry (currently only ~/.claude/settings.json-sourced servers), so POST /mcp/remove has no scope that would find it. Absent/true means removable.",
example: true,
},
kind: {
type: "string",
enum: ["http", "stdio", "unknown"],