carto-md 2.0.7 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/init.js CHANGED
@@ -50,8 +50,8 @@ async function run(projectRoot) {
50
50
  'utf-8'
51
51
  );
52
52
 
53
- // Install pre-commit hook
54
- installGitHook(projectRoot);
53
+ // Install git hooks (silent freshness on git events)
54
+ installGitHooks(projectRoot);
55
55
 
56
56
  // Run first sync — V2 SQLite-backed indexer.
57
57
  await runSyncV2({
@@ -62,102 +62,334 @@ async function run(projectRoot) {
62
62
  // Auto-wire MCP config into installed AI tools
63
63
  wireIDEs(projectRoot);
64
64
 
65
- console.log('[CARTO] AGENTS.md generated. Carto will sync on every git commit.');
65
+ console.log('[CARTO] AGENTS.md generated. Index stays fresh via git hooks + lazy MCP re-parse.');
66
66
  }
67
67
 
68
- function installGitHook(projectRoot) {
68
+ /**
69
+ * installGitHooks(projectRoot)
70
+ *
71
+ * Installs four git hooks that call `carto sync` quietly:
72
+ * - pre-commit fires on `git commit` before the commit lands
73
+ * - post-checkout fires after `git checkout` (branch switch, file checkout)
74
+ * - post-merge fires after `git merge` and `git pull`
75
+ * - post-rewrite fires after `git rebase` / `git commit --amend`
76
+ *
77
+ * Together these cover the 90% case of "user did a normal git operation,
78
+ * index should re-sync." The remaining 10% (uncommitted edits) is handled
79
+ * by the lazy mtime check in the MCP server.
80
+ *
81
+ * Hooks are idempotent: re-running `carto init` does not duplicate the
82
+ * `carto sync` line. If a user already has a hook for other reasons, we
83
+ * append non-destructively.
84
+ */
85
+ function installGitHooks(projectRoot) {
69
86
  const gitDir = path.join(projectRoot, '.git');
70
87
  if (!fs.existsSync(gitDir)) return;
71
88
 
72
89
  const hooksDir = path.join(gitDir, 'hooks');
73
90
  if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
74
91
 
75
- const hookPath = path.join(hooksDir, 'pre-commit');
76
- const hookLine = 'carto sync\n';
92
+ // Hook bodies. `>/dev/null 2>&1 || true` keeps git fast and never blocks
93
+ // the user's git command if carto exits non-zero (e.g. transient lock).
94
+ const HOOK_NAMES = ['pre-commit', 'post-checkout', 'post-merge', 'post-rewrite'];
95
+ const HOOK_LINE = 'carto sync >/dev/null 2>&1 || true\n';
96
+ const MARKER = '# carto-md: keep index fresh on git events';
77
97
 
78
- if (fs.existsSync(hookPath)) {
79
- const existing = fs.readFileSync(hookPath, 'utf-8');
80
- if (existing.includes('carto sync')) {
81
- console.log('[CARTO] Git hook already installed.');
82
- return;
98
+ const installed = [];
99
+ const skipped = [];
100
+
101
+ for (const name of HOOK_NAMES) {
102
+ const hookPath = path.join(hooksDir, name);
103
+
104
+ if (fs.existsSync(hookPath)) {
105
+ const existing = fs.readFileSync(hookPath, 'utf-8');
106
+ if (existing.includes('carto sync')) {
107
+ skipped.push(name);
108
+ continue;
109
+ }
110
+ // Append to user's existing hook without clobbering it
111
+ fs.appendFileSync(hookPath, `\n${MARKER}\n${HOOK_LINE}`);
112
+ } else {
113
+ fs.writeFileSync(hookPath, `#!/bin/sh\n${MARKER}\n${HOOK_LINE}`);
83
114
  }
84
- fs.appendFileSync(hookPath, '\n' + hookLine);
85
- } else {
86
- fs.writeFileSync(hookPath, '#!/bin/sh\n' + hookLine);
115
+
116
+ try { fs.chmodSync(hookPath, 0o755); } catch {}
117
+ installed.push(name);
118
+ }
119
+
120
+ if (installed.length > 0) {
121
+ console.log(`[CARTO] Git hooks installed: ${installed.join(', ')}`);
122
+ }
123
+ if (skipped.length > 0 && installed.length === 0) {
124
+ console.log(`[CARTO] Git hooks already installed (${skipped.join(', ')}).`);
125
+ }
126
+ }
127
+
128
+ // Back-compat alias — older code/tests import installGitHook.
129
+ const installGitHook = installGitHooks;
130
+
131
+ /**
132
+ * binaryExists(name) → boolean
133
+ *
134
+ * Best-effort cross-platform check for whether a CLI is on the user's PATH.
135
+ * Uses `which` on macOS/Linux and `where` on Windows. Returns false on any
136
+ * error so the caller treats "couldn't tell" as "not present" — we'd rather
137
+ * fall back to dir-based detection than write a config the user didn't want.
138
+ *
139
+ * Test escape hatch: `CARTO_TEST_BINARY_OVERRIDES=claude=1,codex=0` lets the
140
+ * Init-flow suite drive each tool's detection deterministically without
141
+ * depending on what's installed on the dev box. Comma-separated list of
142
+ * `name=0|1` pairs; any binary not listed falls through to the real check.
143
+ */
144
+ function binaryExists(name) {
145
+ const override = process.env.CARTO_TEST_BINARY_OVERRIDES;
146
+ if (override) {
147
+ for (const pair of override.split(',')) {
148
+ const [n, v] = pair.split('=');
149
+ if (n && n.trim() === name) return v && v.trim() === '1';
150
+ }
151
+ }
152
+ try {
153
+ const { spawnSync } = require('child_process');
154
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
155
+ const r = spawnSync(cmd, [name], { stdio: 'ignore' });
156
+ return r.status === 0;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * claudeDesktopConfigPath() → string
164
+ *
165
+ * Platform-specific Claude Desktop MCP config location.
166
+ * macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
167
+ * Windows: %APPDATA%\Claude\claude_desktop_config.json
168
+ * Linux: ~/.config/Claude/claude_desktop_config.json (unofficial — Anthropic
169
+ * doesn't ship a Linux build, but community wrappers/Wine installs
170
+ * put it here, so we honor the path if the dir exists.)
171
+ */
172
+ function claudeDesktopConfigPath() {
173
+ const os = require('os');
174
+ const home = os.homedir();
175
+ if (process.platform === 'darwin') {
176
+ return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
177
+ }
178
+ if (process.platform === 'win32') {
179
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
180
+ return path.join(appData, 'Claude', 'claude_desktop_config.json');
181
+ }
182
+ return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
183
+ }
184
+
185
+ /**
186
+ * mergeMcpJson(filePath, key, entry) — JSON-style MCP config helper.
187
+ *
188
+ * Reads `filePath` (creates {} if missing), merges `entry` under `[key].carto`
189
+ * (where `key` is `mcpServers` or `servers` depending on the host), writes it
190
+ * back. Used by every JSON-based MCP host: Cursor, Kiro, Claude Code, Claude
191
+ * Desktop, Windsurf, VS Code Copilot.
192
+ *
193
+ * Defensive against malformed existing JSON — a single bad config from another
194
+ * tool can't block the rest of the wiring.
195
+ */
196
+ function mergeMcpJson(filePath, key, entry) {
197
+ let config = {};
198
+ if (fs.existsSync(filePath)) {
199
+ try {
200
+ config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
201
+ if (!config || typeof config !== 'object') config = {};
202
+ } catch {
203
+ // Existing file is malformed — start fresh rather than clobbering blindly.
204
+ // Print a warning so the user knows we replaced it.
205
+ console.warn(`[CARTO] ${filePath} was not valid JSON — rewriting.`);
206
+ config = {};
207
+ }
208
+ }
209
+ if (!config[key] || typeof config[key] !== 'object') config[key] = {};
210
+ config[key].carto = entry;
211
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
212
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
213
+ }
214
+
215
+ /**
216
+ * upsertCodexToml(filePath, projectRoot) — Append/replace a `[mcp_servers.carto]`
217
+ * block in Codex's `config.toml`. We don't pull in a TOML parser dep — Codex's
218
+ * MCP block has a stable, simple shape, so a small regex+rewrite is enough.
219
+ *
220
+ * Behavior:
221
+ * - File missing → write a fresh file with just the carto block.
222
+ * - File present, no carto block → append the block at the end (preserves
223
+ * all existing content + comments).
224
+ * - File present, carto block present → replace it in-place via regex.
225
+ *
226
+ * The block we write is exactly:
227
+ *
228
+ * [mcp_servers.carto]
229
+ * command = "carto"
230
+ * args = ["serve"]
231
+ * cwd = "<project>"
232
+ * enabled = true
233
+ */
234
+ function upsertCodexToml(filePath, projectRoot) {
235
+ const tomlEscape = (s) => '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
236
+ const block = [
237
+ '[mcp_servers.carto]',
238
+ `command = ${tomlEscape('carto')}`,
239
+ `args = [${tomlEscape('serve')}]`,
240
+ `cwd = ${tomlEscape(projectRoot)}`,
241
+ 'enabled = true',
242
+ '',
243
+ ].join('\n');
244
+
245
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
246
+
247
+ if (!fs.existsSync(filePath)) {
248
+ fs.writeFileSync(filePath, block, 'utf-8');
249
+ return;
87
250
  }
88
251
 
89
- fs.chmodSync(hookPath, '755');
90
- console.log('[CARTO] Git pre-commit hook installed.');
252
+ const existing = fs.readFileSync(filePath, 'utf-8');
253
+ // Match the carto section header AND its body up to the next [section]
254
+ // header or end of file. Note: NO `m` flag — we want `$` to mean
255
+ // end-of-input, not end-of-line. The header `[mcp_servers.carto]` is
256
+ // unique per TOML file, so we don't need a line-start anchor.
257
+ const sectionRe = /\[mcp_servers\.carto\][\s\S]*?(?=\n\[|$)/;
258
+ let next;
259
+ if (sectionRe.test(existing)) {
260
+ next = existing.replace(sectionRe, block.trimEnd());
261
+ // Ensure trailing newline.
262
+ if (!next.endsWith('\n')) next += '\n';
263
+ } else {
264
+ // Append. Make sure existing content ends with a newline before the new block.
265
+ next = existing.endsWith('\n') ? existing + '\n' + block : existing + '\n\n' + block;
266
+ }
267
+ fs.writeFileSync(filePath, next, 'utf-8');
91
268
  }
92
269
 
93
270
  function wireIDEs(projectRoot) {
94
271
  const os = require('os');
95
272
  const home = os.homedir();
96
273
  const wired = [];
274
+ const errors = [];
97
275
 
98
- // Claude Code (project-scoped). Spec 7: write .mcp.json at the project root.
99
- // Claude Code reads this file automatically when opened in the project — no
100
- // host-level config needed, and no detection required (it's a project-local
101
- // file that only takes effect when the user actually uses Claude Code here).
102
- try {
103
- const mcpJsonPath = path.join(projectRoot, '.mcp.json');
104
- const config = fs.existsSync(mcpJsonPath)
105
- ? JSON.parse(fs.readFileSync(mcpJsonPath, 'utf-8'))
106
- : { mcpServers: {} };
107
- config.mcpServers = config.mcpServers || {};
108
- config.mcpServers.carto = { command: 'carto', args: ['serve'] };
109
- fs.writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
110
- wired.push('Claude Code');
111
- } catch {}
112
-
113
- // Kiro
114
- const kiroDir = path.join(home, '.kiro', 'settings');
276
+ // ─── Cursor ────────────────────────────────────────────────────────
277
+ // Detection: `~/.cursor/` exists. Path stable across macOS/Linux/Windows.
278
+ if (fs.existsSync(path.join(home, '.cursor'))) {
279
+ try {
280
+ mergeMcpJson(
281
+ path.join(home, '.cursor', 'mcp.json'),
282
+ 'mcpServers',
283
+ { command: 'carto', args: ['serve'], cwd: projectRoot }
284
+ );
285
+ wired.push('Cursor');
286
+ } catch (err) { errors.push(`Cursor: ${err.message}`); }
287
+ }
288
+
289
+ // ─── Claude Code ───────────────────────────────────────────────────
290
+ // Detection: `claude` binary OR `~/.claude/` directory exists. Without
291
+ // this gating we'd write `.mcp.json` into every project regardless of
292
+ // whether the user has Claude Code installed.
293
+ if (binaryExists('claude') || fs.existsSync(path.join(home, '.claude'))) {
294
+ try {
295
+ mergeMcpJson(
296
+ path.join(projectRoot, '.mcp.json'),
297
+ 'mcpServers',
298
+ { command: 'carto', args: ['serve'] }
299
+ );
300
+ wired.push('Claude Code');
301
+ } catch (err) { errors.push(`Claude Code: ${err.message}`); }
302
+ }
303
+
304
+ // ─── Kiro ──────────────────────────────────────────────────────────
115
305
  if (fs.existsSync(path.join(home, '.kiro'))) {
116
306
  try {
117
- fs.mkdirSync(kiroDir, { recursive: true });
118
- const mcpPath = path.join(kiroDir, 'mcp.json');
119
- const config = fs.existsSync(mcpPath)
120
- ? JSON.parse(fs.readFileSync(mcpPath, 'utf-8'))
121
- : { mcpServers: {} };
122
- config.mcpServers = config.mcpServers || {};
123
- config.mcpServers.carto = { command: 'carto', args: ['serve'], cwd: projectRoot };
124
- fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
307
+ mergeMcpJson(
308
+ path.join(home, '.kiro', 'settings', 'mcp.json'),
309
+ 'mcpServers',
310
+ { command: 'carto', args: ['serve'], cwd: projectRoot }
311
+ );
125
312
  wired.push('Kiro');
126
- } catch {}
313
+ } catch (err) { errors.push(`Kiro: ${err.message}`); }
127
314
  }
128
315
 
129
- // Claude Desktop
130
- const claudeConfig = path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
131
- if (fs.existsSync(path.dirname(claudeConfig))) {
316
+ // ─── Claude Desktop (cross-platform) ───────────────────────────────
317
+ // macOS: ~/Library/Application Support/Claude/
318
+ // Windows: %APPDATA%\Claude\
319
+ // Linux: ~/.config/Claude/ (unofficial)
320
+ const claudeCfgPath = claudeDesktopConfigPath();
321
+ if (fs.existsSync(path.dirname(claudeCfgPath))) {
132
322
  try {
133
- const config = fs.existsSync(claudeConfig)
134
- ? JSON.parse(fs.readFileSync(claudeConfig, 'utf-8'))
135
- : { mcpServers: {} };
136
- config.mcpServers = config.mcpServers || {};
137
- config.mcpServers.carto = { command: 'carto', args: ['serve'], cwd: projectRoot };
138
- fs.writeFileSync(claudeConfig, JSON.stringify(config, null, 2) + '\n', 'utf-8');
323
+ mergeMcpJson(
324
+ claudeCfgPath,
325
+ 'mcpServers',
326
+ { command: 'carto', args: ['serve'], cwd: projectRoot }
327
+ );
139
328
  wired.push('Claude Desktop');
140
- } catch {}
329
+ } catch (err) { errors.push(`Claude Desktop: ${err.message}`); }
141
330
  }
142
331
 
143
- // Cursor
144
- const cursorConfig = path.join(home, '.cursor', 'mcp.json');
145
- if (fs.existsSync(path.join(home, '.cursor'))) {
332
+ // ─── Codex ─────────────────────────────────────────────────────────
333
+ // Detection: `codex` binary OR `~/.codex/` directory exists.
334
+ // Format: TOML with `[mcp_servers.carto]` block.
335
+ if (binaryExists('codex') || fs.existsSync(path.join(home, '.codex'))) {
146
336
  try {
147
- const config = fs.existsSync(cursorConfig)
148
- ? JSON.parse(fs.readFileSync(cursorConfig, 'utf-8'))
149
- : { mcpServers: {} };
150
- config.mcpServers = config.mcpServers || {};
151
- config.mcpServers.carto = { command: 'carto', args: ['serve'], cwd: projectRoot };
152
- fs.writeFileSync(cursorConfig, JSON.stringify(config, null, 2) + '\n', 'utf-8');
153
- wired.push('Cursor');
154
- } catch {}
337
+ upsertCodexToml(path.join(home, '.codex', 'config.toml'), projectRoot);
338
+ wired.push('Codex');
339
+ } catch (err) { errors.push(`Codex: ${err.message}`); }
340
+ }
341
+
342
+ // ─── Windsurf ──────────────────────────────────────────────────────
343
+ // Path: ~/.codeium/windsurf/mcp_config.json (NOT ~/.windsurf/, which
344
+ // older docs sometimes show — the real path lives under .codeium).
345
+ if (fs.existsSync(path.join(home, '.codeium', 'windsurf'))
346
+ || fs.existsSync(path.join(home, '.codeium'))) {
347
+ try {
348
+ mergeMcpJson(
349
+ path.join(home, '.codeium', 'windsurf', 'mcp_config.json'),
350
+ 'mcpServers',
351
+ { command: 'carto', args: ['serve'], cwd: projectRoot }
352
+ );
353
+ wired.push('Windsurf');
354
+ } catch (err) { errors.push(`Windsurf: ${err.message}`); }
155
355
  }
156
356
 
357
+ // ─── VS Code Copilot ───────────────────────────────────────────────
358
+ // Detection: `code` binary on PATH (most reliable; user-profile path
359
+ // varies too much across macOS/Linux/Windows + Insiders/stable to be
360
+ // a good detection signal). Schema is different from MCP-spec default:
361
+ // VS Code uses `servers` (not `mcpServers`) and requires `type: stdio`.
362
+ if (binaryExists('code')) {
363
+ try {
364
+ mergeMcpJson(
365
+ path.join(projectRoot, '.vscode', 'mcp.json'),
366
+ 'servers',
367
+ { type: 'stdio', command: 'carto', args: ['serve'] }
368
+ );
369
+ wired.push('VS Code Copilot');
370
+ } catch (err) { errors.push(`VS Code Copilot: ${err.message}`); }
371
+ }
372
+
373
+ // ─── Reporting ─────────────────────────────────────────────────────
157
374
  if (wired.length > 0) {
158
- console.log(`[CARTO] MCP wired into: ${wired.join(', ')}`);
159
- console.log('[CARTO] Run `carto serve` to start the MCP server.');
375
+ console.log(`[CARTO] MCP auto-wired into: ${wired.join(', ')}`);
376
+ } else {
377
+ console.log('[CARTO] No supported AI tools detected for auto-wiring.');
378
+ console.log('[CARTO] See https://github.com/theanshsonkar/carto#use-it-with-your-ai-tool for manual config.');
379
+ }
380
+ if (errors.length > 0) {
381
+ for (const e of errors) console.warn(`[CARTO] Could not wire: ${e}`);
160
382
  }
161
383
  }
162
384
 
163
385
  module.exports = { run };
386
+
387
+ // Test-only exports — surfaced for the Init flow suite to assert detection
388
+ // + format behavior in isolation. Not part of the public API.
389
+ module.exports._internal = {
390
+ binaryExists,
391
+ claudeDesktopConfigPath,
392
+ mergeMcpJson,
393
+ upsertCodexToml,
394
+ wireIDEs,
395
+ };