config-writer.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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. */
  46. function readJsonFile(filePath: string): Record<string, any> {
  47. try {
  48. if (fs.existsSync(filePath)) {
  49. const content = fs.readFileSync(filePath, 'utf-8');
  50. return JSON.parse(content);
  51. }
  52. } catch {
  53. // Ignore parse errors, return empty object
  54. }
  55. return {};
  56. }
  57. /**
  58. * Write a JSON file, creating parent directories if needed
  59. */
  60. function writeJsonFile(filePath: string, data: Record<string, any>): void {
  61. const dir = path.dirname(filePath);
  62. if (!fs.existsSync(dir)) {
  63. fs.mkdirSync(dir, { recursive: true });
  64. }
  65. fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
  66. }
  67. /**
  68. * Get the MCP server configuration for the given location
  69. */
  70. function getMcpServerConfig(location: InstallLocation): Record<string, any> {
  71. if (location === 'global') {
  72. // Global: use 'codegraph' command directly (assumes globally installed)
  73. return {
  74. type: 'stdio',
  75. command: 'codegraph',
  76. args: ['serve', '--mcp'],
  77. };
  78. }
  79. // Local: use npx to run the package
  80. return {
  81. type: 'stdio',
  82. command: 'npx',
  83. args: ['@colbymchenry/codegraph', 'serve', '--mcp'],
  84. };
  85. }
  86. /**
  87. * Write the MCP server configuration to claude.json
  88. */
  89. export function writeMcpConfig(location: InstallLocation): void {
  90. const claudeJsonPath = getClaudeJsonPath(location);
  91. const config = readJsonFile(claudeJsonPath);
  92. // Ensure mcpServers object exists
  93. if (!config.mcpServers) {
  94. config.mcpServers = {};
  95. }
  96. // Add or update codegraph server
  97. config.mcpServers.codegraph = getMcpServerConfig(location);
  98. writeJsonFile(claudeJsonPath, config);
  99. }
  100. /**
  101. * Get the list of permissions for CodeGraph tools
  102. */
  103. function getCodeGraphPermissions(): string[] {
  104. return [
  105. 'mcp__codegraph__codegraph_search',
  106. 'mcp__codegraph__codegraph_context',
  107. 'mcp__codegraph__codegraph_callers',
  108. 'mcp__codegraph__codegraph_callees',
  109. 'mcp__codegraph__codegraph_impact',
  110. 'mcp__codegraph__codegraph_node',
  111. 'mcp__codegraph__codegraph_status',
  112. ];
  113. }
  114. /**
  115. * Write permissions to settings.json
  116. */
  117. export function writePermissions(location: InstallLocation): void {
  118. const settingsPath = getSettingsJsonPath(location);
  119. const settings = readJsonFile(settingsPath);
  120. // Ensure permissions object exists
  121. if (!settings.permissions) {
  122. settings.permissions = {};
  123. }
  124. // Ensure allow array exists
  125. if (!Array.isArray(settings.permissions.allow)) {
  126. settings.permissions.allow = [];
  127. }
  128. // Add CodeGraph permissions (avoiding duplicates)
  129. const codegraphPermissions = getCodeGraphPermissions();
  130. for (const permission of codegraphPermissions) {
  131. if (!settings.permissions.allow.includes(permission)) {
  132. settings.permissions.allow.push(permission);
  133. }
  134. }
  135. writeJsonFile(settingsPath, settings);
  136. }
  137. /**
  138. * Check if MCP config already exists for CodeGraph
  139. */
  140. export function hasMcpConfig(location: InstallLocation): boolean {
  141. const claudeJsonPath = getClaudeJsonPath(location);
  142. const config = readJsonFile(claudeJsonPath);
  143. return !!config.mcpServers?.codegraph;
  144. }
  145. /**
  146. * Check if permissions already exist for CodeGraph
  147. */
  148. export function hasPermissions(location: InstallLocation): boolean {
  149. const settingsPath = getSettingsJsonPath(location);
  150. const settings = readJsonFile(settingsPath);
  151. const permissions = settings.permissions?.allow;
  152. if (!Array.isArray(permissions)) {
  153. return false;
  154. }
  155. // Check if at least one CodeGraph permission exists
  156. return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
  157. }
  158. // =============================================================================
  159. // Hooks Configuration
  160. // =============================================================================
  161. /**
  162. * Get the hooks configuration for Claude Code auto-sync.
  163. *
  164. * PostToolUse(Edit|Write) → mark-dirty (async, non-blocking)
  165. * Stop → sync-if-dirty (sync, ensures fresh index before next user turn)
  166. */
  167. function getHooksConfig(location: InstallLocation): Record<string, any> {
  168. const command = location === 'global' ? 'codegraph' : 'npx @colbymchenry/codegraph';
  169. return {
  170. PostToolUse: [
  171. {
  172. matcher: 'Edit|Write',
  173. hooks: [
  174. {
  175. type: 'command',
  176. command: `${command} mark-dirty`,
  177. async: true,
  178. },
  179. ],
  180. },
  181. ],
  182. Stop: [
  183. {
  184. hooks: [
  185. {
  186. type: 'command',
  187. command: `${command} sync-if-dirty`,
  188. },
  189. ],
  190. },
  191. ],
  192. };
  193. }
  194. /**
  195. * Check if Claude Code hooks already exist for CodeGraph
  196. */
  197. export function hasHooks(location: InstallLocation): boolean {
  198. const settingsPath = getSettingsJsonPath(location);
  199. const settings = readJsonFile(settingsPath);
  200. const hooks = settings.hooks;
  201. if (!hooks) return false;
  202. // Check if any hook command references codegraph
  203. const json = JSON.stringify(hooks);
  204. return json.includes('codegraph mark-dirty') || json.includes('codegraph sync-if-dirty');
  205. }
  206. /**
  207. * Write Claude Code hooks to settings.json for auto-sync.
  208. * Merges with existing hooks, deduplicating any previous codegraph entries.
  209. */
  210. export function writeHooks(location: InstallLocation): void {
  211. const settingsPath = getSettingsJsonPath(location);
  212. const settings = readJsonFile(settingsPath);
  213. if (!settings.hooks) {
  214. settings.hooks = {};
  215. }
  216. const newHooks = getHooksConfig(location);
  217. // For each hook event (PostToolUse, Stop), merge with existing entries
  218. for (const [event, newEntries] of Object.entries(newHooks)) {
  219. if (!Array.isArray(settings.hooks[event])) {
  220. settings.hooks[event] = [];
  221. }
  222. // Remove any existing codegraph entries for this event
  223. settings.hooks[event] = (settings.hooks[event] as any[]).filter((entry: any) => {
  224. // Keep entries that don't reference codegraph
  225. const entryJson = JSON.stringify(entry);
  226. return !entryJson.includes('codegraph mark-dirty') && !entryJson.includes('codegraph sync-if-dirty');
  227. });
  228. // Add new codegraph entries
  229. settings.hooks[event].push(...(newEntries as any[]));
  230. }
  231. writeJsonFile(settingsPath, settings);
  232. }
  233. /**
  234. * Get the path to CLAUDE.md
  235. * - Global: ~/.claude/CLAUDE.md
  236. * - Local: ./.claude/CLAUDE.md
  237. */
  238. function getClaudeMdPath(location: InstallLocation): string {
  239. const configDir = getClaudeConfigDir(location);
  240. return path.join(configDir, 'CLAUDE.md');
  241. }
  242. /**
  243. * Check if CLAUDE.md has CodeGraph section
  244. */
  245. export function hasClaudeMdSection(location: InstallLocation): boolean {
  246. const claudeMdPath = getClaudeMdPath(location);
  247. try {
  248. if (fs.existsSync(claudeMdPath)) {
  249. const content = fs.readFileSync(claudeMdPath, 'utf-8');
  250. return content.includes(CODEGRAPH_SECTION_START) || content.includes('## CodeGraph');
  251. }
  252. } catch {
  253. // Ignore errors
  254. }
  255. return false;
  256. }
  257. /**
  258. * Write or update CLAUDE.md with CodeGraph instructions
  259. *
  260. * If the file exists and has a CodeGraph section (marked or unmarked),
  261. * it will be replaced. Otherwise, the template is appended.
  262. */
  263. export function writeClaudeMd(location: InstallLocation): { created: boolean; updated: boolean } {
  264. const claudeMdPath = getClaudeMdPath(location);
  265. const configDir = getClaudeConfigDir(location);
  266. // Ensure directory exists
  267. if (!fs.existsSync(configDir)) {
  268. fs.mkdirSync(configDir, { recursive: true });
  269. }
  270. // Check if file exists
  271. if (!fs.existsSync(claudeMdPath)) {
  272. // Create new file with just the CodeGraph section
  273. fs.writeFileSync(claudeMdPath, CLAUDE_MD_TEMPLATE + '\n');
  274. return { created: true, updated: false };
  275. }
  276. // Read existing content
  277. let content = fs.readFileSync(claudeMdPath, 'utf-8');
  278. // Check for marked section (from previous installer)
  279. if (content.includes(CODEGRAPH_SECTION_START)) {
  280. // Replace the marked section
  281. const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
  282. const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
  283. if (endIdx > startIdx) {
  284. // Replace existing marked section
  285. const before = content.substring(0, startIdx);
  286. const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length);
  287. content = before + CLAUDE_MD_TEMPLATE + after;
  288. fs.writeFileSync(claudeMdPath, content);
  289. return { created: false, updated: true };
  290. }
  291. }
  292. // Check for unmarked "## CodeGraph" section (from manual setup)
  293. const codegraphHeaderRegex = /\n## CodeGraph\n/;
  294. const match = content.match(codegraphHeaderRegex);
  295. if (match && match.index !== undefined) {
  296. // Find the end of the CodeGraph section (next ## header or end of file)
  297. const sectionStart = match.index;
  298. const afterSection = content.substring(sectionStart + 1);
  299. const nextHeaderMatch = afterSection.match(/\n## [^#]/);
  300. let sectionEnd: number;
  301. if (nextHeaderMatch && nextHeaderMatch.index !== undefined) {
  302. sectionEnd = sectionStart + 1 + nextHeaderMatch.index;
  303. } else {
  304. sectionEnd = content.length;
  305. }
  306. // Replace the section
  307. const before = content.substring(0, sectionStart);
  308. const after = content.substring(sectionEnd);
  309. content = before + '\n' + CLAUDE_MD_TEMPLATE + after;
  310. fs.writeFileSync(claudeMdPath, content);
  311. return { created: false, updated: true };
  312. }
  313. // No existing section, append to end
  314. content = content.trimEnd() + '\n\n' + CLAUDE_MD_TEMPLATE + '\n';
  315. fs.writeFileSync(claudeMdPath, content);
  316. return { created: false, updated: false };
  317. }