config-writer.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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. * When true, all configs use `npx @colbymchenry/codegraph` instead of the
  94. * bare `codegraph` command. Set by the installer when global install fails.
  95. */
  96. let useNpxFallback = false;
  97. export function setUseNpxFallback(value: boolean): void {
  98. useNpxFallback = value;
  99. }
  100. /**
  101. * Get the MCP server configuration for the given location
  102. */
  103. function getMcpServerConfig(location: InstallLocation): Record<string, any> {
  104. if (location === 'global' && !useNpxFallback) {
  105. // Global: use 'codegraph' command directly (globally installed and in PATH)
  106. return {
  107. type: 'stdio',
  108. command: 'codegraph',
  109. args: ['serve', '--mcp'],
  110. };
  111. }
  112. // Local or npx fallback: use npx to run the package
  113. return {
  114. type: 'stdio',
  115. command: 'npx',
  116. args: ['@colbymchenry/codegraph', 'serve', '--mcp'],
  117. };
  118. }
  119. /**
  120. * Write the MCP server configuration to claude.json
  121. */
  122. export function writeMcpConfig(location: InstallLocation): void {
  123. const claudeJsonPath = getClaudeJsonPath(location);
  124. const config = readJsonFile(claudeJsonPath);
  125. // Ensure mcpServers object exists
  126. if (!config.mcpServers) {
  127. config.mcpServers = {};
  128. }
  129. // Add or update codegraph server
  130. config.mcpServers.codegraph = getMcpServerConfig(location);
  131. writeJsonFile(claudeJsonPath, config);
  132. }
  133. /**
  134. * Get the list of permissions for CodeGraph tools
  135. */
  136. function getCodeGraphPermissions(): string[] {
  137. return [
  138. 'mcp__codegraph__codegraph_search',
  139. 'mcp__codegraph__codegraph_context',
  140. 'mcp__codegraph__codegraph_callers',
  141. 'mcp__codegraph__codegraph_callees',
  142. 'mcp__codegraph__codegraph_impact',
  143. 'mcp__codegraph__codegraph_node',
  144. 'mcp__codegraph__codegraph_status',
  145. ];
  146. }
  147. /**
  148. * Write permissions to settings.json
  149. */
  150. export function writePermissions(location: InstallLocation): void {
  151. const settingsPath = getSettingsJsonPath(location);
  152. const settings = readJsonFile(settingsPath);
  153. // Ensure permissions object exists
  154. if (!settings.permissions) {
  155. settings.permissions = {};
  156. }
  157. // Ensure allow array exists
  158. if (!Array.isArray(settings.permissions.allow)) {
  159. settings.permissions.allow = [];
  160. }
  161. // Add CodeGraph permissions (avoiding duplicates)
  162. const codegraphPermissions = getCodeGraphPermissions();
  163. for (const permission of codegraphPermissions) {
  164. if (!settings.permissions.allow.includes(permission)) {
  165. settings.permissions.allow.push(permission);
  166. }
  167. }
  168. writeJsonFile(settingsPath, settings);
  169. }
  170. /**
  171. * Check if MCP config already exists for CodeGraph
  172. */
  173. export function hasMcpConfig(location: InstallLocation): boolean {
  174. const claudeJsonPath = getClaudeJsonPath(location);
  175. const config = readJsonFile(claudeJsonPath);
  176. return !!config.mcpServers?.codegraph;
  177. }
  178. /**
  179. * Check if permissions already exist for CodeGraph
  180. */
  181. export function hasPermissions(location: InstallLocation): boolean {
  182. const settingsPath = getSettingsJsonPath(location);
  183. const settings = readJsonFile(settingsPath);
  184. const permissions = settings.permissions?.allow;
  185. if (!Array.isArray(permissions)) {
  186. return false;
  187. }
  188. // Check if at least one CodeGraph permission exists
  189. return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
  190. }
  191. // =============================================================================
  192. // Hooks Configuration
  193. // =============================================================================
  194. /**
  195. * Get the hooks configuration for Claude Code auto-sync.
  196. *
  197. * PostToolUse(Edit|Write) → mark-dirty (async, non-blocking)
  198. * Stop → sync-if-dirty (sync, ensures fresh index before next user turn)
  199. */
  200. function getHooksConfig(location: InstallLocation): Record<string, any> {
  201. const command = (location === 'global' && !useNpxFallback) ? 'codegraph' : 'npx @colbymchenry/codegraph';
  202. return {
  203. PostToolUse: [
  204. {
  205. matcher: 'Edit|Write',
  206. hooks: [
  207. {
  208. type: 'command',
  209. command: `${command} mark-dirty`,
  210. async: true,
  211. },
  212. ],
  213. },
  214. ],
  215. Stop: [
  216. {
  217. matcher: '.*',
  218. hooks: [
  219. {
  220. type: 'command',
  221. command: `${command} sync-if-dirty`,
  222. },
  223. ],
  224. },
  225. ],
  226. };
  227. }
  228. /**
  229. * Check if Claude Code hooks already exist for CodeGraph
  230. */
  231. export function hasHooks(location: InstallLocation): boolean {
  232. const settingsPath = getSettingsJsonPath(location);
  233. const settings = readJsonFile(settingsPath);
  234. const hooks = settings.hooks;
  235. if (!hooks) return false;
  236. // Check if any hook command references codegraph
  237. const json = JSON.stringify(hooks);
  238. return json.includes('codegraph mark-dirty') || json.includes('codegraph sync-if-dirty');
  239. }
  240. /**
  241. * Write Claude Code hooks to settings.json for auto-sync.
  242. * Merges with existing hooks, deduplicating any previous codegraph entries.
  243. */
  244. export function writeHooks(location: InstallLocation): void {
  245. const settingsPath = getSettingsJsonPath(location);
  246. const settings = readJsonFile(settingsPath);
  247. if (!settings.hooks) {
  248. settings.hooks = {};
  249. }
  250. const newHooks = getHooksConfig(location);
  251. // For each hook event (PostToolUse, Stop), merge with existing entries
  252. for (const [event, newEntries] of Object.entries(newHooks)) {
  253. if (!Array.isArray(settings.hooks[event])) {
  254. settings.hooks[event] = [];
  255. }
  256. // Remove any existing codegraph entries for this event
  257. settings.hooks[event] = (settings.hooks[event] as any[]).filter((entry: any) => {
  258. // Keep entries that don't reference codegraph
  259. const entryJson = JSON.stringify(entry);
  260. return !entryJson.includes('codegraph mark-dirty') && !entryJson.includes('codegraph sync-if-dirty');
  261. });
  262. // Add new codegraph entries
  263. settings.hooks[event].push(...(newEntries as any[]));
  264. }
  265. writeJsonFile(settingsPath, settings);
  266. }
  267. /**
  268. * Get the path to CLAUDE.md
  269. * - Global: ~/.claude/CLAUDE.md
  270. * - Local: ./.claude/CLAUDE.md
  271. */
  272. function getClaudeMdPath(location: InstallLocation): string {
  273. const configDir = getClaudeConfigDir(location);
  274. return path.join(configDir, 'CLAUDE.md');
  275. }
  276. /**
  277. * Check if CLAUDE.md has CodeGraph section
  278. */
  279. export function hasClaudeMdSection(location: InstallLocation): boolean {
  280. const claudeMdPath = getClaudeMdPath(location);
  281. try {
  282. if (fs.existsSync(claudeMdPath)) {
  283. const content = fs.readFileSync(claudeMdPath, 'utf-8');
  284. return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
  285. }
  286. } catch {
  287. // Ignore errors
  288. }
  289. return false;
  290. }
  291. /**
  292. * Write or update CLAUDE.md with CodeGraph instructions
  293. *
  294. * If the file exists and has a CodeGraph section (marked or unmarked),
  295. * it will be replaced. Otherwise, the template is appended.
  296. */
  297. export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
  298. const claudeMdPath = getClaudeMdPath(location);
  299. const configDir = getClaudeConfigDir(location);
  300. // Ensure directory exists
  301. if (!fs.existsSync(configDir)) {
  302. fs.mkdirSync(configDir, { recursive: true });
  303. }
  304. // Check if file exists
  305. if (!fs.existsSync(claudeMdPath)) {
  306. // Create new file with just the CodeGraph section
  307. atomicWriteFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
  308. return { created: true, updated: false };
  309. }
  310. // Read existing content
  311. let content = fs.readFileSync(claudeMdPath, 'utf-8');
  312. // Check for marked section (from previous installer)
  313. if (content.includes(CODEGRAPH_SECTION_START)) {
  314. // Replace the marked section
  315. const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
  316. const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
  317. if (endIdx > startIdx) {
  318. // Replace existing marked section
  319. const before = content.substring(0, startIdx);
  320. const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
  321. content = before + CLAUDE_MD_TEMPLATE + after;
  322. atomicWriteFileSync(claudeMdPath, content);
  323. return { created: false, updated: true };
  324. }
  325. }
  326. // Check for unmarked "## CodeGraph" section (from manual setup)
  327. const codegraphHeaderRegex = /\n## CodeGraph\n/;
  328. const match = content.match(codegraphHeaderRegex);
  329. if (match && match.index !== undefined) {
  330. // Find the end of the CodeGraph section (next h2 header or end of file)
  331. // Use negative lookahead (?!#) to match "## X" but not "### X"
  332. const sectionStart = match.index;
  333. const afterSection = content.substring(sectionStart + 1);
  334. const nextHeaderMatch = afterSection.match(/\n## (?!#)/);
  335. let sectionEnd: number;
  336. if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
  337. sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
  338. } else {
  339. sectionEnd = content.length;
  340. }
  341. // Replace the section
  342. const before = content.substring(0, sectionStart);
  343. const after = content.substring(sectionEnd);
  344. content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
  345. atomicWriteFileSync(claudeMdPath, content);
  346. return { created: false, updated: true };
  347. }
  348. // No existing section, append to end
  349. content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
  350. atomicWriteFileSync(claudeMdPath, content);
  351. return { created: false, updated: false };
  352. }