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]}`,