config-writer.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. /**
  2. * Config file writing for the CodeGraph installer
  3. * Writes to claude.json, settings.json, and CLAUDE.md
  4. */
  5. import * as fs from 'fs';
  6. import * as path from 'path';
  7. import * as os from 'os';
  8. import { InstallLocation } from './prompts';
  9. import {
  10. CLAUDE_MD_TEMPLATE,
  11. CODEGRAPH_SECTION_START,
  12. CODEGRAPH_SECTION_END,
  13. } from './claude-md-template';
  14. /**
  15. * Get the path to the Claude config directory
  16. */
  17. function getClaudeConfigDir(location: InstallLocation): string {
  18. if (location === 'global') {
  19. return path.join(os.homedir(), '.claude');
  20. }
  21. return path.join(process.cwd(), '.claude');
  22. }
  23. /**
  24. * Get the path to the claude.json file
  25. * - Global: ~/.claude.json (root level)
  26. * - Local: ./.claude.json (project root)
  27. */
  28. function getClaudeJsonPath(location: InstallLocation): string {
  29. if (location === 'global') {
  30. return path.join(os.homedir(), '.claude.json');
  31. }
  32. return path.join(process.cwd(), '.claude.json');
  33. }
  34. /**
  35. * Get the path to the settings.json file
  36. * - Global: ~/.claude/settings.json
  37. * - Local: ./.claude/settings.json
  38. */
  39. function getSettingsJsonPath(location: InstallLocation): string {
  40. const configDir = getClaudeConfigDir(location);
  41. return path.join(configDir, 'settings.json');
  42. }
  43. /**
  44. * Read a JSON file, returning an empty object if it doesn't exist.
  45. * Distinguishes between missing files (returns {}) and corrupted
  46. * files (logs warning, returns {}).
  47. */
  48. function readJsonFile(filePath: string): Record<string, any> {
  49. if (!fs.existsSync(filePath)) {
  50. return {};
  51. }
  52. try {
  53. const content = fs.readFileSync(filePath, 'utf-8');
  54. return JSON.parse(content);
  55. } catch (err) {
  56. const msg = err instanceof Error ? err.message : String(err);
  57. console.warn(` Warning: Could not parse ${path.basename(filePath)}: ${msg}`);
  58. console.warn(` A backup will be created before overwriting.`);
  59. // Create a backup of the corrupted file
  60. try {
  61. const backupPath = filePath + '.backup';
  62. fs.copyFileSync(filePath, backupPath);
  63. } catch { /* ignore backup failure */ }
  64. return {};
  65. }
  66. }
  67. /**
  68. * Write a file atomically by writing to a temp file then renaming.
  69. * Prevents corruption if the process crashes mid-write.
  70. */
  71. function atomicWriteFileSync(filePath: string, content: string): void {
  72. const dir = path.dirname(filePath);
  73. if (!fs.existsSync(dir)) {
  74. fs.mkdirSync(dir, { recursive: true });
  75. }
  76. const tmpPath = filePath + '.tmp.' + process.pid;
  77. try {
  78. fs.writeFileSync(tmpPath, content);
  79. fs.renameSync(tmpPath, filePath);
  80. } catch (err) {
  81. // Clean up temp file on failure
  82. try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
  83. throw err;
  84. }
  85. }
  86. /**
  87. * Write a JSON file, creating parent directories if needed
  88. */
  89. function writeJsonFile(filePath: string, data: Record<string, any>): void {
  90. atomicWriteFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
  91. }
  92. /**
  93. * Get the MCP server configuration for the given location
  94. */
  95. function getMcpServerConfig(location: InstallLocation): Record<string, any> {
  96. if (location === 'global') {
  97. // Global: use 'codegraph' command directly (assumes globally installed)
  98. return {
  99. type: 'stdio',
  100. command: 'codegraph',
  101. args: ['serve', '--mcp'],
  102. };
  103. }
  104. // Local: use npx to run the package
  105. return {
  106. type: 'stdio',
  107. command: 'npx',
  108. args: ['@colbymchenry/codegraph', 'serve', '--mcp'],
  109. };
  110. }
  111. /**
  112. * Write the MCP server configuration to claude.json
  113. */
  114. export function writeMcpConfig(location: InstallLocation): void {
  115. const claudeJsonPath = getClaudeJsonPath(location);
  116. const config = readJsonFile(claudeJsonPath);
  117. // Ensure mcpServers object exists
  118. if (!config.mcpServers) {
  119. config.mcpServers = {};
  120. }
  121. // Add or update codegraph server
  122. config.mcpServers.codegraph = getMcpServerConfig(location);
  123. writeJsonFile(claudeJsonPath, config);
  124. }
  125. /**
  126. * Get the list of permissions for CodeGraph tools
  127. */
  128. function getCodeGraphPermissions(): string[] {
  129. return [
  130. 'mcp__codegraph__codegraph_search',
  131. 'mcp__codegraph__codegraph_context',
  132. 'mcp__codegraph__codegraph_callers',
  133. 'mcp__codegraph__codegraph_callees',
  134. 'mcp__codegraph__codegraph_impact',
  135. 'mcp__codegraph__codegraph_node',
  136. 'mcp__codegraph__codegraph_status',
  137. ];
  138. }
  139. /**
  140. * Write permissions to settings.json
  141. */
  142. export function writePermissions(location: InstallLocation): void {
  143. const settingsPath = getSettingsJsonPath(location);
  144. const settings = readJsonFile(settingsPath);
  145. // Ensure permissions object exists
  146. if (!settings.permissions) {
  147. settings.permissions = {};
  148. }
  149. // Ensure allow array exists
  150. if (!Array.isArray(settings.permissions.allow)) {
  151. settings.permissions.allow = [];
  152. }
  153. // Add CodeGraph permissions (avoiding duplicates)
  154. const codegraphPermissions = getCodeGraphPermissions();
  155. for (const permission of codegraphPermissions) {
  156. if (!settings.permissions.allow.includes(permission)) {
  157. settings.permissions.allow.push(permission);
  158. }
  159. }
  160. writeJsonFile(settingsPath, settings);
  161. }
  162. /**
  163. * Check if MCP config already exists for CodeGraph
  164. */
  165. export function hasMcpConfig(location: InstallLocation): boolean {
  166. const claudeJsonPath = getClaudeJsonPath(location);
  167. const config = readJsonFile(claudeJsonPath);
  168. return !!config.mcpServers?.codegraph;
  169. }
  170. /**
  171. * Check if permissions already exist for CodeGraph
  172. */
  173. export function hasPermissions(location: InstallLocation): boolean {
  174. const settingsPath = getSettingsJsonPath(location);
  175. const settings = readJsonFile(settingsPath);
  176. const permissions = settings.permissions?.allow;
  177. if (!Array.isArray(permissions)) {
  178. return false;
  179. }
  180. // Check if at least one CodeGraph permission exists
  181. return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
  182. }
  183. // =============================================================================
  184. // Hooks Configuration
  185. // =============================================================================
  186. /**
  187. * Get the hooks configuration for Claude Code auto-sync.
  188. *
  189. * PostToolUse(Edit|Write) → mark-dirty (async, non-blocking)
  190. * Stop → sync-if-dirty (sync, ensures fresh index before next user turn)
  191. */
  192. function getHooksConfig(location: InstallLocation): Record<string, any> {
  193. const command = location === 'global' ? 'codegraph' : 'npx @colbymchenry/codegraph';
  194. return {
  195. PostToolUse: [
  196. {
  197. matcher: 'Edit|Write',
  198. hooks: [
  199. {
  200. type: 'command',
  201. command: `${command} mark-dirty`,
  202. async: true,
  203. },
  204. ],
  205. },
  206. ],
  207. Stop: [
  208. {
  209. hooks: [
  210. {
  211. type: 'command',
  212. command: `${command} sync-if-dirty`,
  213. },
  214. ],
  215. },
  216. ],
  217. };
  218. }
  219. /**
  220. * Check if Claude Code hooks already exist for CodeGraph
  221. */
  222. export function hasHooks(location: InstallLocation): boolean {
  223. const settingsPath = getSettingsJsonPath(location);
  224. const settings = readJsonFile(settingsPath);
  225. const hooks = settings.hooks;
  226. if (!hooks) return false;
  227. // Check if any hook command references codegraph
  228. const json = JSON.stringify(hooks);
  229. return json.includes('codegraph mark-dirty') || json.includes('codegraph sync-if-dirty');
  230. }
  231. /**
  232. * Write Claude Code hooks to settings.json for auto-sync.
  233. * Merges with existing hooks, deduplicating any previous codegraph entries.
  234. */
  235. export function writeHooks(location: InstallLocation): void {
  236. const settingsPath = getSettingsJsonPath(location);
  237. const settings = readJsonFile(settingsPath);
  238. if (!settings.hooks) {
  239. settings.hooks = {};
  240. }
  241. const newHooks = getHooksConfig(location);
  242. // For each hook event (PostToolUse, Stop), merge with existing entries
  243. for (const [event, newEntries] of Object.entries(newHooks)) {
  244. if (!Array.isArray(settings.hooks[event])) {
  245. settings.hooks[event] = [];
  246. }
  247. // Remove any existing codegraph entries for this event
  248. settings.hooks[event] = (settings.hooks[event] as any[]).filter((entry: any) => {
  249. // Keep entries that don't reference codegraph
  250. const entryJson = JSON.stringify(entry);
  251. return !entryJson.includes('codegraph mark-dirty') && !entryJson.includes('codegraph sync-if-dirty');
  252. });
  253. // Add new codegraph entries
  254. settings.hooks[event].push(...(newEntries as any[]));
  255. }
  256. writeJsonFile(settingsPath, settings);
  257. }
  258. /**
  259. * Get the path to CLAUDE.md
  260. * - Global: ~/.claude/CLAUDE.md
  261. * - Local: ./.claude/CLAUDE.md
  262. */
  263. function getClaudeMdPath(location: InstallLocation): string {
  264. const configDir = getClaudeConfigDir(location);
  265. return path.join(configDir, 'CLAUDE.md');
  266. }
  267. /**
  268. * Check if CLAUDE.md has CodeGraph section
  269. */
  270. export function hasClaudeMdSection(location: InstallLocation): boolean {
  271. const claudeMdPath = getClaudeMdPath(location);
  272. try {
  273. if (fs.existsSync(claudeMdPath)) {
  274. const content = fs.readFileSync(claudeMdPath, 'utf-8');
  275. return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
  276. }
  277. } catch {
  278. // Ignore errors
  279. }
  280. return false;
  281. }
  282. /**
  283. * Write or update CLAUDE.md with CodeGraph instructions
  284. *
  285. * If the file exists and has a CodeGraph section (marked or unmarked),
  286. * it will be replaced. Otherwise, the template is appended.
  287. */
  288. export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
  289. const claudeMdPath = getClaudeMdPath(location);
  290. const configDir = getClaudeConfigDir(location);
  291. // Ensure directory exists
  292. if (!fs.existsSync(configDir)) {
  293. fs.mkdirSync(configDir, { recursive: true });
  294. }
  295. // Check if file exists
  296. if (!fs.existsSync(claudeMdPath)) {
  297. // Create new file with just the CodeGraph section
  298. atomicWriteFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
  299. return { created: true, updated: false };
  300. }
  301. // Read existing content
  302. let content = fs.readFileSync(claudeMdPath, 'utf-8');
  303. // Check for marked section (from previous installer)
  304. if (content.includes(CODEGRAPH_SECTION_START)) {
  305. // Replace the marked section
  306. const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
  307. const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
  308. if (endIdx > startIdx) {
  309. // Replace existing marked section
  310. const before = content.substring(0, startIdx);
  311. const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
  312. content = before + CLAUDE_MD_TEMPLATE + after;
  313. atomicWriteFileSync(claudeMdPath, content);
  314. return { created: false, updated: true };
  315. }
  316. }
  317. // Check for unmarked "## CodeGraph" section (from manual setup)
  318. const codegraphHeaderRegex = /\n## CodeGraph\n/;
  319. const match = content.match(codegraphHeaderRegex);
  320. if (match && match.index !== undefined) {
  321. // Find the end of the CodeGraph section (next h2 header or end of file)
  322. // Use negative lookahead (?!#) to match "## X" but not "### X"
  323. const sectionStart = match.index;
  324. const afterSection = content.substring(sectionStart + 1);
  325. const nextHeaderMatch = afterSection.match(/\n## (?!#)/);
  326. let sectionEnd: number;
  327. if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
  328. sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
  329. } else {
  330. sectionEnd = content.length;
  331. }
  332. // Replace the section
  333. const before = content.substring(0, sectionStart);
  334. const after = content.substring(sectionEnd);
  335. content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
  336. atomicWriteFileSync(claudeMdPath, content);
  337. return { created: false, updated: true };
  338. }
  339. // No existing section, append to end
  340. content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
  341. atomicWriteFileSync(claudeMdPath, content);
  342. return { created: false, updated: false };
  343. }