From 79e83cbd67ca9fd78b5469c3d44101e5e93f8a2b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:25:21 +0800 Subject: [PATCH] fix(setup): resolve empty Claude profile paths consistently Use the default home registration and personal skill roots for an empty profile override. Share marker and skill path resolution while preserving nonempty caller paths, backups and idempotence. Refs #4262, #4270 Co-authored-by: Polly Labs --- docs/installation.md | 2 + src/application/SupportedClients.ts | 8 +- tests/boundary/cli/clientProfiles.test.ts | 122 ++++++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index a8a13ac82c..7f12b7ea63 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -179,6 +179,8 @@ as well, so it is broader than this Claude-only migration. history under `CLAUDE_CONFIG_DIR`. Setup follows the same variable: it writes the registration to `$CLAUDE_CONFIG_DIR/.claude.json` and the skill to `$CLAUDE_CONFIG_DIR/skills`. +For REA setup, an empty `CLAUDE_CONFIG_DIR` is treated as unset: registration +goes to `~/.claude.json` and the skill goes to `~/.claude/skills`. ```bash CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents setup --client claude_code diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index c4da675e70..a45599578a 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -159,10 +159,10 @@ const claudeDesktopLocation = ( }; const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string => - env.CLAUDE_CONFIG_DIR ?? home; + env.CLAUDE_CONFIG_DIR || home; const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => - env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"); + env.CLAUDE_CONFIG_DIR || join(home, ".claude"); const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME || join(home, ".codex"); @@ -486,8 +486,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "claude_code", displayName: "Claude Code", - skillPath: ({ home, env }: ClientPathContext) => - join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"), + skillPath: (context: ClientPathContext) => + join(claudeCodeMarkerDirectory(context), "skills"), configPath: (context: ClientPathContext) => join(claudeCodeConfigDirectory(context), ".claude.json"), markerPath: claudeCodeMarkerDirectory, diff --git a/tests/boundary/cli/clientProfiles.test.ts b/tests/boundary/cli/clientProfiles.test.ts index 092ce65d0c..700001753f 100644 --- a/tests/boundary/cli/clientProfiles.test.ts +++ b/tests/boundary/cli/clientProfiles.test.ts @@ -6,6 +6,128 @@ import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest( + "Claude Code routes an empty config directory to its home registration", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-claude-empty-config-dir-"); + const home = join(root, "home"); + const cwd = join(root, "work"); + const config = join(home, ".claude.json"); + const backup = `${config}.rea.backup`; + const skill = join(home, ".claude", "skills", PRODUCT_IDENTITY.skillName); + const cwdSkill = join(cwd, "skills", PRODUCT_IDENTITY.skillName); + const original = JSON.stringify({ + theme: "dark", + mcpServers: { other: { command: "other" } }, + }); + await mkdir(home, { recursive: true }); + await mkdir(cwd, { recursive: true }); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd, + environment: { + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: "", + }, + timeoutMs: 20_000, + }); + const setup = (flags: readonly string[] = []) => + run(["setup", "--client", "claude_code", ...flags, "--json"]); + + const planned = await setup(["--dry-run"]); + expect(planned.exitCode, planned.stderr).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ + id: "configure_client:claude_code", + target: config, + backupPath: backup, + }), + expect.objectContaining({ + id: "install_skill:claude_code", + target: skill, + }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(access(join(cwd, ".claude.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(skill, "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(cwdSkill, "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(backup)).rejects.toMatchObject({ code: "ENOENT" }); + + const applied = await setup(["--yes"]); + expect(applied.exitCode, applied.stderr).toBe(0); + expect(applied.json).toMatchObject({ status: "ready" }); + expect(await readFile(backup, "utf8")).toBe(original); + expect(parse(await readFile(config, "utf8"))).toMatchObject({ + theme: "dark", + mcpServers: { + other: { command: "other" }, + rea: expect.any(Object), + }, + }); + await expect(access(join(skill, "SKILL.md"))).resolves.toBeUndefined(); + await expect(access(join(cwd, ".claude.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(cwdSkill, "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + + const repeat = await setup(["--yes"]); + expect(repeat.exitCode, repeat.stderr).toBe(0); + expect(repeat.json).toMatchObject({ + status: "ready", + appliedActions: [], + }); + expect(await readFile(backup, "utf8")).toBe(original); + const doctor = await run([ + "doctor", + "--client", + "claude_code", + "--skill", + "--json", + ]); + expect(doctor.exitCode, doctor.stderr).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + skill: { state: "aligned" }, + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "claude_code", state: "aligned" }), + ]), + }, + }); + + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode, uninstall.stderr).toBe(0); + expect(uninstall.json).toMatchObject({ status: "complete" }); + expect(parse(await readFile(config, "utf8"))).toEqual({ + theme: "dark", + mcpServers: { other: { command: "other" } }, + }); + await expect(access(join(skill, "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(cwd, ".claude.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(access(join(cwdSkill, "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + for (const policy of [{ excluded: ["rea"] }, { allowed: ["other"] }]) { cliTest( `Gemini refuses a registration blocked by ${Object.keys(policy)[0]}`,