PostToolUse(Edit|Write) marks the project dirty via .codegraph/.dirty, and Stop syncs only if dirty — batching all edits into one sync per Claude response. The installer now writes these hooks to settings.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
/**
|
|
* Config file writing for the CodeGraph installer
|
|
* Writes to claude.json, settings.json, and CLAUDE.md
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { InstallLocation } from './prompts';
|
|
import {
|
|
CLAUDE_MD_TEMPLATE,
|
|
CODEGRAPH_SECTION_START,
|
|
CODEGRAPH_SECTION_END,
|
|
} from './claude-md-template';
|
|
|
|
/**
|
|
* Get the path to the Claude config directory
|
|
*/
|
|
function getClaudeConfigDir(location: InstallLocation): string {
|
|
if (location === 'global') {
|
|
return path.join(os.homedir(), '.claude');
|
|
}
|
|
return path.join(process.cwd(), '.claude');
|
|
}
|
|
|
|
/**
|
|
* Get the path to the claude.json file
|
|
* - Global: ~/.claude.json (root level)
|
|
* - Local: ./.claude.json (project root)
|
|
*/
|
|
function getClaudeJsonPath(location: InstallLocation): string {
|
|
if (location === 'global') {
|
|
return path.join(os.homedir(), '.claude.json');
|
|
}
|
|
return path.join(process.cwd(), '.claude.json');
|
|
}
|
|
|
|
/**
|
|
* Get the path to the settings.json file
|
|
* - Global: ~/.claude/settings.json
|
|
* - Local: ./.claude/settings.json
|
|
*/
|
|
function getSettingsJsonPath(location: InstallLocation): string {
|
|
const configDir = getClaudeConfigDir(location);
|
|
return path.join(configDir, 'settings.json');
|
|
}
|
|
|
|
/**
|
|
* Read a JSON file, returning an empty object if it doesn't exist
|
|
*/
|
|
function readJsonFile(filePath: string): Record<string, any> {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
return JSON.parse(content);
|
|
}
|
|
} catch {
|
|
// Ignore parse errors, return empty object
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Write a JSON file, creating parent directories if needed
|
|
*/
|
|
function writeJsonFile(filePath: string, data: Record<string, any>): void {
|
|
const dir = path.dirname(filePath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
}
|
|
|
|
/**
|
|
* Get the MCP server configuration for the given location
|
|
*/
|
|
function getMcpServerConfig(location: InstallLocation): Record<string, any> {
|
|
if (location === 'global') {
|
|
// Global: use 'codegraph' command directly (assumes globally installed)
|
|
return {
|
|
type: 'stdio',
|
|
command: 'codegraph',
|
|
args: ['serve', '--mcp'],
|
|
};
|
|
}
|
|
// Local: use npx to run the package
|
|
return {
|
|
type: 'stdio',
|
|
command: 'npx',
|
|
args: ['@colbymchenry/codegraph', 'serve', '--mcp'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Write the MCP server configuration to claude.json
|
|
*/
|
|
export function writeMcpConfig(location: InstallLocation): void {
|
|
const claudeJsonPath = getClaudeJsonPath(location);
|
|
const config = readJsonFile(claudeJsonPath);
|
|
|
|
// Ensure mcpServers object exists
|
|
if (!config.mcpServers) {
|
|
config.mcpServers = {};
|
|
}
|
|
|
|
// Add or update codegraph server
|
|
config.mcpServers.codegraph = getMcpServerConfig(location);
|
|
|
|
writeJsonFile(claudeJsonPath, config);
|
|
}
|
|
|
|
/**
|
|
* Get the list of permissions for CodeGraph tools
|
|
*/
|
|
function getCodeGraphPermissions(): string[] {
|
|
return [
|
|
'mcp__codegraph__codegraph_search',
|
|
'mcp__codegraph__codegraph_context',
|
|
'mcp__codegraph__codegraph_callers',
|
|
'mcp__codegraph__codegraph_callees',
|
|
'mcp__codegraph__codegraph_impact',
|
|
'mcp__codegraph__codegraph_node',
|
|
'mcp__codegraph__codegraph_status',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Write permissions to settings.json
|
|
*/
|
|
export function writePermissions(location: InstallLocation): void {
|
|
const settingsPath = getSettingsJsonPath(location);
|
|
const settings = readJsonFile(settingsPath);
|
|
|
|
// Ensure permissions object exists
|
|
if (!settings.permissions) {
|
|
settings.permissions = {};
|
|
}
|
|
|
|
// Ensure allow array exists
|
|
if (!Array.isArray(settings.permissions.allow)) {
|
|
settings.permissions.allow = [];
|
|
}
|
|
|
|
// Add CodeGraph permissions (avoiding duplicates)
|
|
const codegraphPermissions = getCodeGraphPermissions();
|
|
for (const permission of codegraphPermissions) {
|
|
if (!settings.permissions.allow.includes(permission)) {
|
|
settings.permissions.allow.push(permission);
|
|
}
|
|
}
|
|
|
|
writeJsonFile(settingsPath, settings);
|
|
}
|
|
|
|
/**
|
|
* Check if MCP config already exists for CodeGraph
|
|
*/
|
|
export function hasMcpConfig(location: InstallLocation): boolean {
|
|
const claudeJsonPath = getClaudeJsonPath(location);
|
|
const config = readJsonFile(claudeJsonPath);
|
|
return !!config.mcpServers?.codegraph;
|
|
}
|
|
|
|
/**
|
|
* Check if permissions already exist for CodeGraph
|
|
*/
|
|
export function hasPermissions(location: InstallLocation): boolean {
|
|
const settingsPath = getSettingsJsonPath(location);
|
|
const settings = readJsonFile(settingsPath);
|
|
const permissions = settings.permissions?.allow;
|
|
if (!Array.isArray(permissions)) {
|
|
return false;
|
|
}
|
|
// Check if at least one CodeGraph permission exists
|
|
return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hooks Configuration
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get the hooks configuration for Claude Code auto-sync.
|
|
*
|
|
* 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<string, any> {
|
|
const command = location === 'global' ? 'codegraph' : 'npx @colbymchenry/codegraph';
|
|
|
|
return {
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|Write',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `${command} mark-dirty`,
|
|
async: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `${command} sync-if-dirty`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if Claude Code hooks already exist for CodeGraph
|
|
*/
|
|
export function hasHooks(location: InstallLocation): boolean {
|
|
const settingsPath = getSettingsJsonPath(location);
|
|
const settings = readJsonFile(settingsPath);
|
|
const hooks = settings.hooks;
|
|
if (!hooks) return false;
|
|
|
|
// Check if any hook command references codegraph
|
|
const json = JSON.stringify(hooks);
|
|
return json.includes('codegraph mark-dirty') || json.includes('codegraph sync-if-dirty');
|
|
}
|
|
|
|
/**
|
|
* Write Claude Code hooks to settings.json for auto-sync.
|
|
* Merges with existing hooks, deduplicating any previous codegraph entries.
|
|
*/
|
|
export function writeHooks(location: InstallLocation): void {
|
|
const settingsPath = getSettingsJsonPath(location);
|
|
const settings = readJsonFile(settingsPath);
|
|
|
|
if (!settings.hooks) {
|
|
settings.hooks = {};
|
|
}
|
|
|
|
const newHooks = getHooksConfig(location);
|
|
|
|
// For each hook event (PostToolUse, Stop), merge with existing entries
|
|
for (const [event, newEntries] of Object.entries(newHooks)) {
|
|
if (!Array.isArray(settings.hooks[event])) {
|
|
settings.hooks[event] = [];
|
|
}
|
|
|
|
// Remove any existing codegraph entries for this event
|
|
settings.hooks[event] = (settings.hooks[event] as any[]).filter((entry: any) => {
|
|
// Keep entries that don't reference codegraph
|
|
const entryJson = JSON.stringify(entry);
|
|
return !entryJson.includes('codegraph mark-dirty') && !entryJson.includes('codegraph sync-if-dirty');
|
|
});
|
|
|
|
// Add new codegraph entries
|
|
settings.hooks[event].push(...(newEntries as any[]));
|
|
}
|
|
|
|
writeJsonFile(settingsPath, settings);
|
|
}
|
|
|
|
/**
|
|
* Get the path to CLAUDE.md
|
|
* - Global: ~/.claude/CLAUDE.md
|
|
* - Local: ./.claude/CLAUDE.md
|
|
*/
|
|
function getClaudeMdPath(location: InstallLocation): string {
|
|
const configDir = getClaudeConfigDir(location);
|
|
return path.join(configDir, 'CLAUDE.md');
|
|
}
|
|
|
|
/**
|
|
* Check if CLAUDE.md has CodeGraph section
|
|
*/
|
|
export function hasClaudeMdSection(location: InstallLocation): boolean {
|
|
const claudeMdPath = getClaudeMdPath(location);
|
|
try {
|
|
if (fs.existsSync(claudeMdPath)) {
|
|
const content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Write or update CLAUDE.md with CodeGraph instructions
|
|
*
|
|
* If the file exists and has a CodeGraph section (marked or unmarked),
|
|
* it will be replaced. Otherwise, the template is appended.
|
|
*/
|
|
export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
|
|
const claudeMdPath = getClaudeMdPath(location);
|
|
const configDir = getClaudeConfigDir(location);
|
|
|
|
// Ensure directory exists
|
|
if (!fs.existsSync(configDir)) {
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
}
|
|
|
|
// Check if file exists
|
|
if (!fs.existsSync(claudeMdPath)) {
|
|
// Create new file with just the CodeGraph section
|
|
fs.writeFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
|
|
return { created: true, updated: false };
|
|
}
|
|
|
|
// Read existing content
|
|
let content = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
|
|
// Check for marked section (from previous installer)
|
|
if (content.includes(CODEGRAPH_SECTION_START)) {
|
|
// Replace the marked section
|
|
const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
|
|
const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
|
|
|
|
if (endIdx > startIdx) {
|
|
// Replace existing marked section
|
|
const before = content.substring(0, startIdx);
|
|
const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
|
|
content = before + CLAUDE_MD_TEMPLATE + after;
|
|
fs.writeFileSync(claudeMdPath, content);
|
|
return { created: false, updated: true };
|
|
}
|
|
}
|
|
|
|
// Check for unmarked "## CodeGraph" section (from manual setup)
|
|
const codegraphHeaderRegex = /\n## CodeGraph\n/;
|
|
const match = content.match(codegraphHeaderRegex);
|
|
|
|
if (match && match.index !== undefined) {
|
|
// Find the end of the CodeGraph section (next ## header or end of file)
|
|
const sectionStart = match.index;
|
|
const afterSection = content.substring(sectionStart + 1);
|
|
const nextHeaderMatch = afterSection.match(/\n## [^#]/);
|
|
|
|
let sectionEnd: number;
|
|
if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
|
|
sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
|
|
} else {
|
|
sectionEnd = content.length;
|
|
}
|
|
|
|
// Replace the section
|
|
const before = content.substring(0, sectionStart);
|
|
const after = content.substring(sectionEnd);
|
|
content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
|
|
fs.writeFileSync(claudeMdPath, content);
|
|
return { created: false, updated: true };
|
|
}
|
|
|
|
// No existing section, append to end
|
|
content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
|
|
fs.writeFileSync(claudeMdPath, content);
|
|
return { created: false, updated: false };
|
|
}
|