diff --git a/gitnexus/README.md b/gitnexus/README.md index eff30e026..6e0f6e05d 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -36,6 +36,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up `gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`. +For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install). + ### Editor Support | Editor | MCP | Skills | Hooks (auto-augment) | Support | @@ -398,7 +400,7 @@ An operation must name a protocol, either through its own `bindings` or through Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`. -> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. +> **`gitnexus uninstall`** removes the named GitNexus MCP entries, hooks, and skill directories from detected editors. This includes a URL-based MCP entry you configured yourself, even if setup preserved it. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. ## Remote Embeddings diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index 212a4c400..f3267aa4b 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -56,7 +56,7 @@ export interface McpJsoncTarget { export interface CodexMcpTarget { id: 'codex'; label: string; - /** Absolute path to ~/.codex/config.toml. */ + /** Absolute path to CODEX_HOME/config.toml (default ~/.codex/config.toml). */ configFile: string; /** The TOML table header (without brackets) setup writes / uninstall strips. */ tomlSection: string; @@ -100,6 +100,9 @@ export interface EditorTargets { * `process.env.HOME` before invoking sees the right locations. */ export function getEditorTargets(home: string = os.homedir()): EditorTargets { + const codexHome = process.env.CODEX_HOME + ? path.resolve(process.env.CODEX_HOME) + : path.join(home, '.codex'); const mcpJsonc: McpJsoncTarget[] = [ { id: 'cursor', @@ -166,7 +169,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { const codex: CodexMcpTarget = { id: 'codex', label: 'Codex', - configFile: path.join(home, '.codex', 'config.toml'), + configFile: path.join(codexHome, 'config.toml'), tomlSection: 'mcp_servers.gitnexus', }; @@ -205,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { // Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape // and hookSpecificOutput response contract, in a dedicated hooks.json // (https://developers.openai.com/codex/hooks). - settingsFile: path.join(home, '.codex', 'hooks.json'), + settingsFile: path.join(codexHome, 'hooks.json'), events: ['PreToolUse', 'PostToolUse'], needle: 'gitnexus-hook', - scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'), + scriptDir: path.join(codexHome, 'hooks', 'gitnexus'), }, { id: 'antigravity', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index f03c58aa3..33de0ad6b 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -12,7 +12,16 @@ import os from 'os'; import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; -import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser'; +import { + parseTree, + findNodeAtLocation, + getNodeValue, + modify, + applyEdits, + ParseError, + parse as parseJsonc, +} from 'jsonc-parser'; +import { parse as parseToml, TomlError } from 'smol-toml'; import { packageVersion } from '../core/package-version.js'; import { getGlobalDir } from '../storage/repo-manager.js'; import { @@ -214,7 +223,8 @@ async function mergeJsoncFile( filePath: string, keyPath: string[], value: unknown, -): Promise { + options?: { preserveHttpEntry?: boolean }, +): Promise { let raw: string; try { raw = await fs.readFile(filePath, 'utf-8'); @@ -240,6 +250,15 @@ async function mergeJsoncFile( const tree = parseTree(raw, parseErrors); if (tree && tree.type === 'object' && parseErrors.length === 0) { + const existingNode = options?.preserveHttpEntry ? findNodeAtLocation(tree, keyPath) : undefined; + const existingEntry = existingNode === undefined ? undefined : getNodeValue(existingNode); + if ( + existingEntry !== null && + typeof existingEntry === 'object' && + typeof (existingEntry as Record).url === 'string' + ) { + return 'preserved-http'; + } const formattingOptions = detectIndentation(raw); const edits = modify(raw, keyPath, value, { formattingOptions }); const result = applyEdits(raw, edits); @@ -306,8 +325,12 @@ async function setupCursor(result: SetupResult): Promise { const { file: mcpPath, keyPath } = mcpTarget('cursor'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Cursor (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Cursor'); } else { result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content'); @@ -327,8 +350,12 @@ async function setupClaudeCode(result: SetupResult): Promise { // Claude Code stores MCP config in ~/.claude.json const { file: mcpPath, keyPath } = mcpTarget('claude'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Claude Code (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Claude Code'); } else { result.errors.push( @@ -1044,13 +1071,47 @@ async function upsertCodexConfigToml(configPath: string): Promise { await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); } +async function codexHasHttpMcpEntry(configPath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(configPath, 'utf-8'); + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } + const config = parseToml(raw); + const servers = config.mcp_servers; + if (servers === null || typeof servers !== 'object') return false; + const entry = (servers as Record).gitnexus; + return ( + entry !== null && + typeof entry === 'object' && + typeof (entry as Record).url === 'string' + ); +} + async function setupCodex(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); - if (!(await dirExists(codexDir))) { + const configPath = getEditorTargets().codex.configFile; + const codexDir = path.dirname(configPath); + if (!process.env.CODEX_HOME && !(await dirExists(codexDir))) { result.skipped.push('Codex (not installed)'); return; } + try { + if (await codexHasHttpMcpEntry(configPath)) { + result.configured.push('Codex (existing HTTP MCP entry kept)'); + return; + } + } catch (err) { + result.errors.push( + err instanceof TomlError + ? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged` + : `Codex: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + try { const entry = getMcpEntry(); await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], { @@ -1064,9 +1125,8 @@ async function setupCodex(result: SetupResult): Promise { } try { - const configPath = getEditorTargets().codex.configFile; await upsertCodexConfigToml(configPath); - result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + result.configured.push('Codex (MCP added to active config.toml)'); } catch (err: any) { result.errors.push(`Codex: ${err.message}`); } @@ -1265,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise { * Install global Codex skills to ~/.agents/skills/gitnexus/ */ async function installCodexSkills(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); + const codexDir = path.dirname(getEditorTargets().codex.configFile); if (!(await dirExists(codexDir))) return; const skillsDir = skillTarget('codex').dir; diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index a30badf50..6b19738dd 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -27,6 +27,7 @@ describe('setupCommand codex execution', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let platformDescriptor: PropertyDescriptor | undefined; const setPlatform = (value: NodeJS.Platform) => { @@ -42,6 +43,8 @@ describe('setupCommand codex execution', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -62,6 +65,8 @@ describe('setupCommand codex execution', () => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -110,6 +115,93 @@ describe('setupCommand codex execution', () => { await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); }); + it('keeps an existing HTTP entry and its bearer-token setting without invoking codex mcp add', async () => { + const configPath = path.join(tempHome, '.codex', 'config.toml'); + const raw = + '[mcp_servers.gitnexus]\nurl = "http://127.0.0.1:4748/mcp"\nbearer_token_env_var = "GITNEXUS_TOKEN"\n'; + await fs.writeFile(configPath, raw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toBe(raw); + expect( + await fs.stat(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + + it('does not mistake an HTTP entry in the default home for the active CODEX_HOME entry', async () => { + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHome = path.join(tempHome, 'active-codex'); + await fs.writeFile(defaultConfig, '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true, windowsHide: true }, + expect.any(Function), + ); + }); + + it('preserves an HTTP entry in the active CODEX_HOME even when the default home is empty', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const raw = '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'; + await fs.mkdir(activeHome); + await fs.writeFile(activeConfig, raw); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(raw); + }); + + it('writes the fallback entry to the active CODEX_HOME when the Codex CLI is unavailable', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + process.env.CODEX_HOME = activeHome; + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') callback(new Error('codex unavailable')); + }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(await fs.readFile(path.join(activeHome, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.gitnexus]', + ); + await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); + }); + + it('does not print TOML source or nearby credentials when the active config is malformed', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const configPath = path.join(activeHome, 'config.toml'); + const token = 'Bearer FAKE_PR3460_REVIEW_TOKEN'; + await fs.mkdir(activeHome); + await fs.writeFile( + configPath, + `[mcp_servers.gitnexus]\nhttp_headers = { Authorization = "${token}" }\ninvalid =\n`, + ); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).toMatch(/invalid config\.toml \(line 3, column \d+\)/); + expect(output).not.toContain(token); + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toContain(token); + }); + it('skips Codex setup entirely when ~/.codex is missing', async () => { await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index e9d0a5873..e61859ab4 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -134,6 +134,40 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toBeDefined(); }); + it('keeps existing Claude and Cursor HTTP MCP entries while installing skills', async () => { + const claudePath = path.join(tempHome, '.claude.json'); + const cursorDir = path.join(tempHome, '.cursor'); + const cursorPath = path.join(cursorDir, 'mcp.json'); + await fs.mkdir(cursorDir, { recursive: true }); + const claudeRaw = `{ + // Shared GitNexus server + "mcpServers": { + "gitnexus": { "type": "http", "url": "http://127.0.0.1:4748/mcp", "headers": { "Authorization": "Bearer $GITNEXUS_TOKEN" } } + } +}`; + const cursorRaw = JSON.stringify({ + mcpServers: { + gitnexus: { + url: 'http://127.0.0.1:4748/mcp', + headers: { Authorization: 'Bearer $GITNEXUS_TOKEN' }, + }, + }, + }); + await fs.writeFile(claudePath, claudeRaw, 'utf-8'); + await fs.writeFile(cursorPath, cursorRaw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: ['claude', 'cursor'] }); + + expect(await fs.readFile(claudePath, 'utf-8')).toBe(claudeRaw); + expect(await fs.readFile(cursorPath, 'utf-8')).toBe(cursorRaw); + expect(logLines()).toContain('Claude Code (existing HTTP MCP entry kept)'); + expect(logLines()).toContain('Cursor (existing HTTP MCP entry kept)'); + expect( + await fs.stat(path.join(tempHome, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + it('handles missing ~/.claude.json (creates fresh)', async () => { setPlatform('linux'); @@ -800,6 +834,7 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json'); @@ -809,6 +844,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -824,6 +861,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -844,6 +883,28 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { ).resolves.toBeUndefined(); }); + it('installs hooks and skills for active CODEX_HOME without touching the default home', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const defaultHooks = hooksJsonPath(); + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const hooks = JSON.parse(await fs.readFile(path.join(activeHome, 'hooks.json'), 'utf-8')).hooks; + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PostToolUse).toHaveLength(1); + await expect( + fs.access(path.join(activeHome, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).resolves.toBeUndefined(); + await expect(fs.access(defaultHooks)).rejects.toThrow(); + }); + it('is idempotent — a second setup run adds no duplicate entries', async () => { const { setupCommand } = await import('../../src/cli/setup.js'); await setupCommand(); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index ad20eb5ca..5c3a2e285 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -23,6 +23,7 @@ describe('uninstallCommand', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let originalSkillsRoot: string | undefined; let originalExitCode: typeof process.exitCode; let skillsRoot: string; @@ -35,6 +36,8 @@ describe('uninstallCommand', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; originalExitCode = process.exitCode; @@ -57,6 +60,8 @@ describe('uninstallCommand', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT; else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot; // The command sets process.exitCode=1 on partial failure; restore it so a @@ -203,6 +208,52 @@ describe('uninstallCommand', () => { expect(result).toContain('command = "other"'); }); + it('dry-runs and removes only the active CODEX_HOME MCP entry', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHooks = path.join(activeHome, 'hooks.json'); + const defaultHooks = path.join(tempHome, '.codex', 'hooks.json'); + const activeScriptDir = path.join(activeHome, 'hooks', 'gitnexus'); + const defaultScriptDir = path.join(tempHome, '.codex', 'hooks', 'gitnexus'); + const activeRaw = '[mcp_servers.gitnexus]\ncommand = "active"\n'; + const defaultRaw = '[mcp_servers.gitnexus]\ncommand = "default"\n'; + const hooksRaw = JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'node gitnexus-hook.cjs' }] }, + { matcher: 'Read', hooks: [{ type: 'command', command: 'user-hook' }] }, + ], + }, + }); + await fs.mkdir(activeHome); + await fs.mkdir(path.dirname(defaultConfig)); + await fs.mkdir(activeScriptDir, { recursive: true }); + await fs.mkdir(defaultScriptDir, { recursive: true }); + await fs.writeFile(activeConfig, activeRaw); + await fs.writeFile(defaultConfig, defaultRaw); + await fs.writeFile(activeHooks, hooksRaw); + await fs.writeFile(defaultHooks, hooksRaw); + process.env.CODEX_HOME = activeHome; + + const uninstallCommand = await importUninstall(); + await uninstallCommand(); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeConfig); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeHooks); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(activeRaw); + expect(await fs.readFile(activeHooks, 'utf-8')).toBe(hooksRaw); + + await uninstallCommand({ force: true }); + expect(await fs.readFile(activeConfig, 'utf-8')).not.toContain('[mcp_servers.gitnexus]'); + expect(await fs.readFile(defaultConfig, 'utf-8')).toBe(defaultRaw); + const remaining = JSON.parse(await fs.readFile(activeHooks, 'utf-8')); + expect(remaining.hooks.PreToolUse).toHaveLength(1); + expect(remaining.hooks.PreToolUse[0].hooks[0].command).toBe('user-hook'); + expect(await fs.readFile(defaultHooks, 'utf-8')).toBe(hooksRaw); + await expect(fs.access(activeScriptDir)).rejects.toThrow(); + await expect(fs.access(defaultScriptDir)).resolves.toBeUndefined(); + }); + it('leaves a corrupt JSON config untouched', async () => { const claudeJson = path.join(tempHome, '.claude.json'); const corrupt = '{ not valid json !!!';