diff --git a/docs/MIGRATION_FROM_CLAUDE_CODE.md b/docs/MIGRATION_FROM_CLAUDE_CODE.md index ba096e6..4b6ecf7 100644 --- a/docs/MIGRATION_FROM_CLAUDE_CODE.md +++ b/docs/MIGRATION_FROM_CLAUDE_CODE.md @@ -7,6 +7,22 @@ API key. It is no longer chasing 1:1 parity — see ## TL;DR — the 5-minute switch +DeepCode reads your existing Claude Code assets **in place** — you do not have to +move anything to try it: + +| Read in place | Notes | +| ----------------------------- | --------------------------------------------------- | +| `~/.claude/settings.json` | Used when `~/.deepcode/settings.json` doesn't exist | +| `~/.claude/CLAUDE.md` | Loaded as user memory | +| `CLAUDE.md` (any project dir) | Loaded alongside `DEEPCODE.md` / `AGENTS.md` | +| `~/.claude/skills/` | A same-named DeepCode skill wins | +| `~/.claude/agents/` | A same-named DeepCode agent wins | + +A project's `.claude/settings.json` is **not** read: project settings pass +through the directory-trust gate, and that boundary is not widened implicitly. + +The copy below is only needed if you want DeepCode to own its own copies. + ```bash # 1. Install DeepCode CLI npm install -g deepcode-cli diff --git a/packages/core/src/config/claude-compat.test.ts b/packages/core/src/config/claude-compat.test.ts new file mode 100644 index 0000000..6473d2a --- /dev/null +++ b/packages/core/src/config/claude-compat.test.ts @@ -0,0 +1,137 @@ +// Reading a Claude Code user's existing assets in place. +// +// The migration guide asked people to `mv ~/.claude/... ~/.deepcode/...` before +// DeepCode would see anything they had. That is five steps of grit in front of +// `npm i -g deepcode-cli && deepcode`, and it is the first thing a migrating +// user hits. + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { loadSettings, resolveUserSettingsPath } from './loader.js'; +import { loadMemory } from '../memory/loader.js'; +import { loadSkills } from '../skills/loader.js'; +import { loadSubAgents } from '../sub-agents/loader.js'; + +let home: string; +let cwd: string; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-claude-compat-')); + cwd = join(home, 'project'); + await mkdir(cwd, { recursive: true }); +}); + +const writeAt = async (path: string, body: string): Promise => { + await mkdir(join(path, '..'), { recursive: true }); + await writeFile(path, body, 'utf8'); +}; + +describe('settings', () => { + it('reads ~/.claude/settings.json when DeepCode has none', async () => { + await writeAt(join(home, '.claude', 'settings.json'), JSON.stringify({ model: 'from-claude' })); + expect(await resolveUserSettingsPath({ cwd, home })).toBe(join(home, '.claude/settings.json')); + const loaded = await loadSettings({ cwd, home }); + expect(loaded.merged.model).toBe('from-claude'); + }); + + it('prefers DeepCode settings outright when both exist', async () => { + await writeAt(join(home, '.claude', 'settings.json'), JSON.stringify({ model: 'from-claude' })); + await writeAt( + join(home, '.deepcode', 'settings.json'), + JSON.stringify({ effortLevel: 'high' }), + ); + const loaded = await loadSettings({ cwd, home }); + expect(loaded.merged.effortLevel).toBe('high'); + // A fallback, not an extra layer — no silent merge from the other file. + expect(loaded.merged.model).toBeUndefined(); + expect(loaded.sources.userPath).toBe(join(home, '.deepcode/settings.json')); + }); + + it('names the file it actually read, so provenance stays honest', async () => { + await writeAt(join(home, '.claude', 'settings.json'), JSON.stringify({ model: 'from-claude' })); + const loaded = await loadSettings({ cwd, home }); + expect(loaded.sources.userPath).toContain('.claude'); + expect(Object.values(loaded.provenance)).toContainEqual( + expect.objectContaining({ path: join(home, '.claude/settings.json') }), + ); + }); + + it('does not fall back when an explicit data directory is given', async () => { + await writeAt(join(home, '.claude', 'settings.json'), JSON.stringify({ model: 'from-claude' })); + const directory = join(home, 'explicit'); + expect(await resolveUserSettingsPath({ cwd, home, directory })).toBe( + join(directory, 'settings.json'), + ); + }); + + it('does not read a project .claude/settings.json — that path is trust-gated', async () => { + await writeAt(join(cwd, '.claude', 'settings.json'), JSON.stringify({ model: 'untrusted' })); + const loaded = await loadSettings({ cwd, home }); + expect(loaded.merged.model).toBeUndefined(); + }); +}); + +describe('memory', () => { + it('reads ~/.claude/CLAUDE.md', async () => { + await writeAt(join(home, '.claude', 'CLAUDE.md'), 'global claude instructions'); + const memory = await loadMemory({ cwd, home }); + expect(memory.text).toContain('global claude instructions'); + }); + + it('reads a project CLAUDE.md', async () => { + await writeAt(join(cwd, 'CLAUDE.md'), 'project claude instructions'); + const memory = await loadMemory({ cwd, home }); + expect(memory.text).toContain('project claude instructions'); + }); + + it('places DEEPCODE.md after CLAUDE.md at the same level, so it wins', async () => { + await writeAt(join(cwd, 'CLAUDE.md'), 'claude says'); + await writeAt(join(cwd, 'DEEPCODE.md'), 'deepcode says'); + const memory = await loadMemory({ cwd, home }); + expect(memory.text.indexOf('claude says')).toBeLessThan(memory.text.indexOf('deepcode says')); + }); +}); + +describe('skills and agents', () => { + it('loads skills from ~/.claude/skills', async () => { + await writeAt( + join(home, '.claude', 'skills', 'greet', 'SKILL.md'), + '---\nname: greet\ndescription: say hi\n---\nbody', + ); + const skills = await loadSkills({ cwd, home }); + expect(skills.map((s) => s.qualifiedName)).toContain('greet'); + }); + + it('lets a DeepCode skill shadow a same-named Claude Code one', async () => { + await writeAt( + join(home, '.claude', 'skills', 'greet', 'SKILL.md'), + '---\nname: greet\ndescription: from claude\n---\nbody', + ); + await writeAt( + join(home, '.deepcode', 'skills', 'greet', 'SKILL.md'), + '---\nname: greet\ndescription: from deepcode\n---\nbody', + ); + const skills = (await loadSkills({ cwd, home })).filter((s) => s.qualifiedName === 'greet'); + expect(skills).toHaveLength(1); + expect(skills[0]!.frontmatter.description).toBe('from deepcode'); + }); + + it('loads sub-agents from ~/.claude/agents, with DeepCode winning collisions', async () => { + await writeAt( + join(home, '.claude', 'agents', 'scout.md'), + '---\nname: scout\ndescription: from claude\n---\nbody', + ); + const fromClaude = await loadSubAgents({ cwd, home }); + expect(fromClaude.map((a) => a.qualifiedName)).toContain('scout'); + + await writeAt( + join(home, '.deepcode', 'agents', 'scout.md'), + '---\nname: scout\ndescription: from deepcode\n---\nbody', + ); + const both = (await loadSubAgents({ cwd, home })).filter((a) => a.qualifiedName === 'scout'); + expect(both).toHaveLength(1); + expect(both[0]!.frontmatter.description).toBe('from deepcode'); + }); +}); diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 4e4ddc5..d06a14d 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -56,6 +56,36 @@ export function settingsPaths(opts: LoadSettingsOpts): LoadedSettings['sources'] }; } +/** + * Where the user layer comes from when DeepCode has no settings of its own. + * + * A Claude Code user's `~/.claude/settings.json` is read in place rather than + * requiring `mv ~/.claude/settings.json ~/.deepcode/settings.json`. It is a + * *fallback*, not an extra layer: the moment `~/.deepcode/settings.json` + * exists it wins outright, so provenance keeps naming one real file and the + * trust gate keeps seeing exactly the layers it already knows about. + * + * User-level only. A project's `.claude/settings.json` is not read: project + * settings pass through the directory-trust gate, and quietly widening what + * that gate covers is not a change to make in passing. + */ +export async function resolveUserSettingsPath(opts: LoadSettingsOpts): Promise { + const preferred = settingsPaths(opts).userPath; + if (opts.directory) return preferred; // explicit data dir — no fallback + try { + await fs.access(preferred); + return preferred; + } catch { + const claudePath = join(opts.home ?? homedir(), '.claude', 'settings.json'); + try { + await fs.access(claudePath); + return claudePath; + } catch { + return preferred; + } + } +} + async function readJson(path: string): Promise { try { const raw = await fs.readFile(path, 'utf8'); @@ -79,7 +109,7 @@ async function readJsonRequired(path: string): Promise { } export async function loadSettings(opts: LoadSettingsOpts): Promise { - const sources = settingsPaths(opts); + const sources = { ...settingsPaths(opts), userPath: await resolveUserSettingsPath(opts) }; const [user, project, local, override] = await Promise.all([ readJson(sources.userPath), readJson(sources.projectPath), diff --git a/packages/core/src/memory/loader.ts b/packages/core/src/memory/loader.ts index 0db879a..c45efca 100644 --- a/packages/core/src/memory/loader.ts +++ b/packages/core/src/memory/loader.ts @@ -113,6 +113,12 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { await addRaw(abs, label, raw, depth); }; + // 0. ~/.claude/CLAUDE.md — a Claude Code user's existing global instructions. + // Read first so DeepCode's own files can override it, and read in place + // rather than requiring `mv ~/.claude ~/.deepcode`: the migration guide's + // five-step copy was the largest piece of grit in the way of trying this. + await addFile(join(home, '.claude', 'CLAUDE.md'), 'CLAUDE.md (Claude Code)', 0); + // 1. ~/.deepcode/DEEPCODE.md (user-level) await addFile(join(directory, 'DEEPCODE.md'), 'user memory', 0); @@ -123,10 +129,13 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { 0, ); - // 2. DEEPCODE.md walking from cwd → root, deepest first - const upwards = walkUpwards(opts.cwd, home); + // 2. CLAUDE.md / DEEPCODE.md walking from cwd → root, deepest first. // Reverse so root-most first, deepest last (later overrides via concat — Claude Code semantics) + const upwards = walkUpwards(opts.cwd, home); for (const dir of upwards.reverse()) { + // CLAUDE.md before DEEPCODE.md at each level: same-directory DeepCode + // instructions win over the Claude Code ones they were derived from. + await addFile(join(dir, 'CLAUDE.md'), `${dir}/CLAUDE.md`, 0); await addFile(join(dir, 'DEEPCODE.md'), `${dir}/DEEPCODE.md`, 0); } diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index 24e0f33..524bb9c 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -62,8 +62,12 @@ export async function loadSkills(opts: LoadSkillsOpts): Promise { await loadFromDir(opts.builtinDir, 'builtin', out); } - // 2. User-level + // 2. User-level — DeepCode's own first, so it wins a name collision, then a + // Claude Code user's ~/.claude/skills read in place rather than moved. await loadFromDir(join(directory, 'skills'), 'user', out); + if (!opts.directory) { + await loadFromDir(join(home, '.claude', 'skills'), 'user', out); + } // 3. Project-level await loadFromDir(join(opts.cwd, '.deepcode', 'skills'), 'project', out); @@ -116,6 +120,10 @@ async function loadFromDir( } if (front.disabled === true) continue; const qualifiedName = pluginName ? `${pluginName}:${front.name}` : front.name; + // First definition of a name wins. Callers load in precedence order, so a + // DeepCode skill shadows the Claude Code skill it was derived from instead + // of both being listed to the model. + if (out.some((skill) => skill.qualifiedName === qualifiedName)) continue; out.push({ qualifiedName, frontmatter: front as SkillFrontmatter, diff --git a/packages/core/src/sub-agents/loader.ts b/packages/core/src/sub-agents/loader.ts index 130810c..0ae40a0 100644 --- a/packages/core/src/sub-agents/loader.ts +++ b/packages/core/src/sub-agents/loader.ts @@ -41,6 +41,8 @@ export async function loadSubAgents(opts: LoadSubAgentsOpts): Promise; if (!front.name || !front.description) continue; const qualifiedName = pluginName ? `${pluginName}:${front.name}` : front.name; + // First definition wins, matching findAgent()'s .find(). Callers load in + // precedence order, so a DeepCode agent shadows a same-named Claude Code + // one rather than the list carrying both. + if (out.some((agent) => agent.qualifiedName === qualifiedName)) continue; out.push({ qualifiedName, frontmatter: front as SubAgentFrontmatter,