diff --git a/package-lock.json b/package-lock.json index 4693c4f..f0702e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.5.5", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.5.5", + "version": "0.6.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0e50f78..a28a284 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@colbymchenry/codegraph", - "version": "0.5.5", + "version": "0.6.2", "description": "Supercharge Claude Code with semantic code intelligence. 30% fewer tokens, 25% fewer tool calls, 100% local.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -15,6 +15,7 @@ "scripts": { "build": "tsc && npm run copy-assets", "postinstall": "node scripts/postinstall.js", + "preuninstall": "node dist/bin/uninstall.js", "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", "cli": "npm run build && node dist/bin/codegraph.js", diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 75a78dd..810144d 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1015,17 +1015,17 @@ program process.exit(0); } - // Spawn `codegraph sync` as a detached background process - // so this hook exits immediately and doesn't block Claude Code - const isWindows = process.platform === 'win32'; + // Spawn sync as a detached background process + // so this hook exits immediately and doesn't block Claude Code. + // Uses process.argv[0]/[1] (e.g. node /path/to/codegraph.js) so it + // works whether invoked via global install, npx, or directly. const child = spawn( - isWindows ? 'codegraph' : process.argv[0]!, - isWindows ? ['sync', '--quiet', projectRoot!] : [process.argv[1]!, 'sync', '--quiet', projectRoot!], + process.argv[0]!, + [process.argv[1]!, 'sync', '--quiet', projectRoot!], { detached: true, stdio: 'ignore', windowsHide: true, - shell: isWindows, } ); child.unref(); diff --git a/src/bin/uninstall.ts b/src/bin/uninstall.ts new file mode 100644 index 0000000..8ae3d13 --- /dev/null +++ b/src/bin/uninstall.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * CodeGraph preuninstall cleanup script + * + * Runs automatically when `npm uninstall -g @colbymchenry/codegraph` is called. + * Removes all CodeGraph configuration from Claude Code: + * - MCP server entry from ~/.claude.json + * - Permissions from ~/.claude/settings.json + * - Hooks from ~/.claude/settings.json + * - CodeGraph section from ~/.claude/CLAUDE.md + * + * This script must never throw — a failed cleanup must not block uninstall. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const CODEGRAPH_SECTION_START = ''; +const CODEGRAPH_SECTION_END = ''; + +function readJson(filePath: string): Record | null { + try { + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function writeJson(filePath: string, data: Record): void { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n'); +} + +/** + * Remove CodeGraph MCP server from ~/.claude.json + */ +function removeMcpConfig(): void { + const filePath = path.join(os.homedir(), '.claude.json'); + const config = readJson(filePath); + if (!config?.mcpServers?.codegraph) return; + + delete config.mcpServers.codegraph; + + // Clean up empty mcpServers object + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + + writeJson(filePath, config); +} + +/** + * Remove CodeGraph permissions and hooks from ~/.claude/settings.json + */ +function removeSettings(): void { + const filePath = path.join(os.homedir(), '.claude', 'settings.json'); + const settings = readJson(filePath); + if (!settings) return; + + let changed = false; + + // Remove codegraph permissions + if (Array.isArray(settings.permissions?.allow)) { + const before = settings.permissions.allow.length; + settings.permissions.allow = settings.permissions.allow.filter( + (p: string) => !p.startsWith('mcp__codegraph__') + ); + if (settings.permissions.allow.length !== before) changed = true; + + // Clean up empty allow array + if (settings.permissions.allow.length === 0) { + delete settings.permissions.allow; + } + // Clean up empty permissions object + if (Object.keys(settings.permissions).length === 0) { + delete settings.permissions; + } + } + + // Remove codegraph hooks + if (settings.hooks) { + for (const event of Object.keys(settings.hooks)) { + if (!Array.isArray(settings.hooks[event])) continue; + + const before = settings.hooks[event].length; + settings.hooks[event] = settings.hooks[event].filter((entry: any) => { + const json = JSON.stringify(entry); + return !json.includes('codegraph mark-dirty') && !json.includes('codegraph sync-if-dirty'); + }); + if (settings.hooks[event].length !== before) changed = true; + + // Clean up empty event arrays + if (settings.hooks[event].length === 0) { + delete settings.hooks[event]; + } + } + + // Clean up empty hooks object + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + } + + if (changed) { + writeJson(filePath, settings); + } +} + +/** + * Remove CodeGraph section from ~/.claude/CLAUDE.md + */ +function removeClaudeMd(): void { + const filePath = path.join(os.homedir(), '.claude', 'CLAUDE.md'); + try { + if (!fs.existsSync(filePath)) return; + let content = fs.readFileSync(filePath, 'utf-8'); + + // Remove marked section + const startIdx = content.indexOf(CODEGRAPH_SECTION_START); + const endIdx = content.indexOf(CODEGRAPH_SECTION_END); + + if (startIdx !== -1 && endIdx > startIdx) { + const before = content.substring(0, startIdx).trimEnd(); + const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length).trimStart(); + content = before + (before && after ? '\n\n' : '') + after; + + if (content.trim() === '') { + // File is empty after removing section — delete it + fs.unlinkSync(filePath); + } else { + fs.writeFileSync(filePath, content.trim() + '\n'); + } + } + } catch { + // Never fail + } +} + +// Run cleanup — never throw +try { + removeMcpConfig(); +} catch { /* ignore */ } + +try { + removeSettings(); +} catch { /* ignore */ } + +try { + removeClaudeMd(); +} catch { /* ignore */ } diff --git a/src/installer/banner.ts b/src/installer/banner.ts index 1f3f811..fb70bf3 100644 --- a/src/installer/banner.ts +++ b/src/installer/banner.ts @@ -113,16 +113,18 @@ export function warn(message: string): void { /** * Show the "next steps" section after installation */ -export function showNextSteps(location: 'global' | 'local', useNpx?: boolean): void { +export function showNextSteps(location: 'global' | 'local'): void { console.log(); console.log(chalk.bold(' Done!') + ' Restart Claude Code to use CodeGraph.'); console.log(); if (location === 'global') { - const cmd = useNpx ? 'npx @colbymchenry/codegraph' : 'codegraph'; console.log(chalk.dim(' Quick start:')); console.log(chalk.dim(' cd your-project')); - console.log(chalk.cyan(` ${cmd} init -i`)); + console.log(chalk.cyan(' codegraph init -i')); + console.log(); + console.log(chalk.dim(' To uninstall:')); + console.log(chalk.dim(' npm uninstall -g @colbymchenry/codegraph')); } else { console.log(chalk.dim(' CodeGraph is ready to use in this project!')); } diff --git a/src/installer/config-writer.ts b/src/installer/config-writer.ts index d37edd1..2d9e8ab 100644 --- a/src/installer/config-writer.ts +++ b/src/installer/config-writer.ts @@ -98,32 +98,13 @@ function writeJsonFile(filePath: string, data: Record): void { } /** - * When true, all configs use `npx @colbymchenry/codegraph` instead of the - * bare `codegraph` command. Set by the installer when global install fails. + * Get the MCP server configuration */ -let useNpxFallback = false; - -export function setUseNpxFallback(value: boolean): void { - useNpxFallback = value; -} - -/** - * Get the MCP server configuration for the given location - */ -function getMcpServerConfig(location: InstallLocation): Record { - if (location === 'global' && !useNpxFallback) { - // Global: use 'codegraph' command directly (globally installed and in PATH) - return { - type: 'stdio', - command: 'codegraph', - args: ['serve', '--mcp'], - }; - } - // Local or npx fallback: use npx to run the package +function getMcpServerConfig(): Record { return { type: 'stdio', - command: 'npx', - args: ['@colbymchenry/codegraph', 'serve', '--mcp'], + command: 'codegraph', + args: ['serve', '--mcp'], }; } @@ -140,7 +121,7 @@ export function writeMcpConfig(location: InstallLocation): void { } // Add or update codegraph server - config.mcpServers.codegraph = getMcpServerConfig(location); + config.mcpServers.codegraph = getMcpServerConfig(); writeJsonFile(claudeJsonPath, config); } @@ -221,8 +202,8 @@ export function hasPermissions(location: InstallLocation): boolean { * PostToolUse(Edit|Write) → mark-dirty (async, non-blocking) * Stop → sync-if-dirty (sync, ensures fresh index before next user turn) */ -function getHooksConfig(location: InstallLocation): Record { - const command = (location === 'global' && !useNpxFallback) ? 'codegraph' : 'npx @colbymchenry/codegraph'; +function getHooksConfig(): Record { + const command = 'codegraph'; return { PostToolUse: [ @@ -277,7 +258,7 @@ export function writeHooks(location: InstallLocation): void { settings.hooks = {}; } - const newHooks = getHooksConfig(location); + const newHooks = getHooksConfig(); // For each hook event (PostToolUse, Stop), merge with existing entries for (const [event, newEntries] of Object.entries(newHooks)) { diff --git a/src/installer/index.ts b/src/installer/index.ts index 1cc6f9d..c381489 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -8,7 +8,7 @@ import { execSync } from 'child_process'; import { showBanner, showNextSteps, success, error, info, chalk } from './banner'; import { promptInstallLocation, promptAutoAllow, InstallLocation } from './prompts'; -import { writeMcpConfig, writePermissions, writeClaudeMd, writeHooks, hasMcpConfig, hasPermissions, hasHooks, setUseNpxFallback } from './config-writer'; +import { writeMcpConfig, writePermissions, writeClaudeMd, writeHooks, hasMcpConfig, hasPermissions, hasHooks } from './config-writer'; /** * Format a number with commas @@ -25,40 +25,24 @@ export async function runInstaller(): Promise { showBanner(); try { - // Step 1: Check if codegraph is available (skip install if already there) - let codegraphAvailable = false; + // Step 1: Install codegraph globally. + // Always run npm install -g — we can't use `command -v codegraph` to check + // because npx puts a temporary binary in PATH that vanishes when npx exits. + console.log(chalk.dim(' Installing codegraph globally...')); try { - const checkCmd = process.platform === 'win32' ? 'where codegraph' : 'command -v codegraph'; - execSync(checkCmd, { stdio: 'pipe' }); - codegraphAvailable = true; + execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); + success('Installed codegraph command globally'); } catch { - // Not installed globally yet - } - - if (!codegraphAvailable) { - console.log(chalk.dim(' Installing codegraph globally...')); - try { - execSync('npm install -g @colbymchenry/codegraph', { stdio: 'pipe' }); - success('Installed codegraph command globally'); - codegraphAvailable = true; - } catch { - // May fail if no permissions, but that's ok - npx still works - info('Could not install globally — will use npx instead'); - info('(MCP server and hooks will use npx @colbymchenry/codegraph)'); - } - console.log(); - } - - // If codegraph binary isn't in PATH, tell config-writer to use npx for everything - if (!codegraphAvailable) { - setUseNpxFallback(true); + info('Could not install globally (permission denied)'); + info('Try: sudo npm install -g @colbymchenry/codegraph'); } + console.log(); // Step 2: Ask for installation location const location = await promptInstallLocation(); console.log(); - // Step 3: Write MCP configuration + // Step 3: Write MCP configuration (always uses npx for reliability) const alreadyHasMcp = hasMcpConfig(location); writeMcpConfig(location); @@ -111,7 +95,7 @@ export async function runInstaller(): Promise { } // Show next steps - showNextSteps(location, !codegraphAvailable); + showNextSteps(location); } catch (err) { console.log(); if (err instanceof Error && err.message.includes('readline was closed')) {