@patchen0518/agentbrew 1.0.2 → 1.2.0

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/dist/sync.js ADDED
@@ -0,0 +1,713 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.INSTRUCTIONS_FILE = exports.MARKER_END = exports.MARKER_START = void 0;
7
+ exports.extractSkillEntries = extractSkillEntries;
8
+ exports.syncSkillsToClaudeCode = syncSkillsToClaudeCode;
9
+ exports.unsyncSkillsFromClaudeCode = unsyncSkillsFromClaudeCode;
10
+ exports.syncSkillsToGeminiCLI = syncSkillsToGeminiCLI;
11
+ exports.unsyncSkillsFromGeminiCLI = unsyncSkillsFromGeminiCLI;
12
+ exports.syncSkillsToWindsurf = syncSkillsToWindsurf;
13
+ exports.unsyncSkillsFromWindsurf = unsyncSkillsFromWindsurf;
14
+ exports.syncSkillsToAntigravityCLI = syncSkillsToAntigravityCLI;
15
+ exports.unsyncSkillsFromAntigravityCLI = unsyncSkillsFromAntigravityCLI;
16
+ exports.cleanOrphanSkills = cleanOrphanSkills;
17
+ exports.syncMcpServerToCursor = syncMcpServerToCursor;
18
+ exports.unsyncMcpServerFromCursor = unsyncMcpServerFromCursor;
19
+ exports.syncSkillsToCursor = syncSkillsToCursor;
20
+ exports.unsyncSkillsFromCursor = unsyncSkillsFromCursor;
21
+ exports.getDefaultTargets = getDefaultTargets;
22
+ exports.getInstructionsPath = getInstructionsPath;
23
+ exports.buildInjectedSection = buildInjectedSection;
24
+ exports.injectIntoFile = injectIntoFile;
25
+ exports.removeFromFile = removeFromFile;
26
+ exports.syncInstructions = syncInstructions;
27
+ exports.unsyncInstructions = unsyncInstructions;
28
+ const fs_1 = __importDefault(require("fs"));
29
+ const os_1 = __importDefault(require("os"));
30
+ const path_1 = __importDefault(require("path"));
31
+ const config_1 = require("./config");
32
+ const package_json_1 = __importDefault(require("../package.json"));
33
+ /**
34
+ * Extracts SkillEntry objects from discovered packages by scanning for SKILL.md prompts.
35
+ */
36
+ function extractSkillEntries(packages) {
37
+ const skills = [];
38
+ for (const pkg of packages) {
39
+ if (!pkg.manifest.prompts)
40
+ continue;
41
+ for (const prompt of pkg.manifest.prompts) {
42
+ if (path_1.default.basename(prompt.file).toUpperCase() !== 'SKILL.MD')
43
+ continue;
44
+ skills.push({
45
+ packageName: pkg.packageName,
46
+ skillName: prompt.name,
47
+ skillDir: path_1.default.dirname(path_1.default.resolve(pkg.path, prompt.file)),
48
+ description: prompt.description,
49
+ });
50
+ }
51
+ }
52
+ return skills;
53
+ }
54
+ const SYNCED_SKILLS_FILE = 'synced-skills.json';
55
+ const AGENTBREW_EXTENSION_NAME = 'agentbrew';
56
+ const CURSOR_SKILLS_INDEX_FILE = 'agentbrew-skills-index.md';
57
+ function getSyncedSkillsPath(brewRoot) {
58
+ return path_1.default.join(brewRoot ?? (0, config_1.getBrewRoot)(), SYNCED_SKILLS_FILE);
59
+ }
60
+ function loadSyncedState(brewRoot) {
61
+ try {
62
+ const raw = JSON.parse(fs_1.default.readFileSync(getSyncedSkillsPath(brewRoot), 'utf-8'));
63
+ // Migrate old flat format: { skills: [...] } → { claude: [...] }
64
+ if (raw.skills && !raw.claude) {
65
+ return { claude: raw.skills, gemini: [], windsurf: [], cursor: false, cursorMcp: false, antigravity: [] };
66
+ }
67
+ return {
68
+ claude: raw.claude ?? [],
69
+ gemini: raw.gemini ?? [],
70
+ windsurf: raw.windsurf ?? [],
71
+ cursor: raw.cursor ?? false,
72
+ cursorMcp: raw.cursorMcp ?? false,
73
+ antigravity: raw.antigravity ?? [],
74
+ };
75
+ }
76
+ catch {
77
+ return { claude: [], gemini: [], windsurf: [], cursor: false, cursorMcp: false, antigravity: [] };
78
+ }
79
+ }
80
+ function saveSyncedState(state, brewRoot) {
81
+ const p = getSyncedSkillsPath(brewRoot);
82
+ fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
83
+ fs_1.default.writeFileSync(p, JSON.stringify(state, null, 2), 'utf-8');
84
+ }
85
+ function symlinkSkills(skills, targetDir, state, agentKey, brewRoot) {
86
+ const results = [];
87
+ const newEntries = [];
88
+ for (const skill of skills) {
89
+ const entryName = `${skill.packageName}-${skill.skillName}`;
90
+ const entryPath = path_1.default.join(targetDir, entryName);
91
+ if (!fs_1.default.existsSync(skill.skillDir)) {
92
+ results.push({ entryName, status: 'skipped', note: 'Source directory not found' });
93
+ continue;
94
+ }
95
+ let exists = false;
96
+ try {
97
+ fs_1.default.lstatSync(entryPath);
98
+ exists = true;
99
+ }
100
+ catch { }
101
+ if (exists) {
102
+ let currentTarget = null;
103
+ try {
104
+ currentTarget = fs_1.default.readlinkSync(entryPath);
105
+ }
106
+ catch { }
107
+ if (currentTarget === null) {
108
+ // Not a symlink — not created by AgentBrew, leave it alone
109
+ results.push({ entryName, status: 'skipped', note: 'Path exists and is not a symlink' });
110
+ continue;
111
+ }
112
+ if (currentTarget === skill.skillDir) {
113
+ newEntries.push(entryName);
114
+ results.push({ entryName, status: 'already_exists', path: entryPath });
115
+ continue;
116
+ }
117
+ // Stale symlink pointing at a different target — remove and re-create
118
+ try {
119
+ fs_1.default.rmSync(entryPath, { force: true });
120
+ }
121
+ catch { }
122
+ }
123
+ try {
124
+ fs_1.default.symlinkSync(skill.skillDir, entryPath);
125
+ newEntries.push(entryName);
126
+ results.push({ entryName, status: 'linked', path: entryPath });
127
+ }
128
+ catch (e) {
129
+ results.push({ entryName, status: 'error', note: e.message });
130
+ }
131
+ }
132
+ state[agentKey] = [...new Set([...state[agentKey], ...newEntries])];
133
+ saveSyncedState(state, brewRoot);
134
+ return results;
135
+ }
136
+ function removeTrackedSymlinks(tracked, skillsDir) {
137
+ const results = [];
138
+ for (const entryName of tracked) {
139
+ const entryPath = path_1.default.join(skillsDir, entryName);
140
+ let exists = false;
141
+ try {
142
+ fs_1.default.lstatSync(entryPath);
143
+ exists = true;
144
+ }
145
+ catch { }
146
+ if (!exists) {
147
+ results.push({ entryName, status: 'skipped', note: 'Not found' });
148
+ continue;
149
+ }
150
+ try {
151
+ fs_1.default.rmSync(entryPath, { recursive: true, force: true });
152
+ results.push({ entryName, status: 'removed', path: entryPath });
153
+ }
154
+ catch (e) {
155
+ results.push({ entryName, status: 'error', note: e.message });
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+ // ─── Claude Code ────────────────────────────────────────────────────────────
161
+ /**
162
+ * Symlinks each skill directory into ~/.claude/skills/<pkgName>-<skillName>
163
+ * so Claude Code can discover them as invocable skills.
164
+ */
165
+ function syncSkillsToClaudeCode(skills, brewRoot) {
166
+ const claudeDir = path_1.default.join(os_1.default.homedir(), '.claude');
167
+ if (!fs_1.default.existsSync(claudeDir))
168
+ return [];
169
+ const skillsDir = path_1.default.join(claudeDir, 'skills');
170
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
171
+ const state = loadSyncedState(brewRoot);
172
+ return symlinkSkills(skills, skillsDir, state, 'claude', brewRoot);
173
+ }
174
+ /**
175
+ * Removes all skill symlinks previously created by syncSkillsToClaudeCode.
176
+ */
177
+ function unsyncSkillsFromClaudeCode(brewRoot) {
178
+ const skillsDir = path_1.default.join(os_1.default.homedir(), '.claude', 'skills');
179
+ const state = loadSyncedState(brewRoot);
180
+ const results = removeTrackedSymlinks(state.claude, skillsDir);
181
+ state.claude = [];
182
+ saveSyncedState(state, brewRoot);
183
+ return results;
184
+ }
185
+ // ─── Gemini CLI ──────────────────────────────────────────────────────────────
186
+ /**
187
+ * Registers skills with Gemini CLI by creating an agentbrew extension at
188
+ * ~/.gemini/extensions/agentbrew/ and symlinking each skill's directory into
189
+ * ~/.gemini/extensions/agentbrew/skills/<pkgName>-<skillName>.
190
+ */
191
+ function syncSkillsToGeminiCLI(skills, brewRoot) {
192
+ const geminiDir = path_1.default.join(os_1.default.homedir(), '.gemini');
193
+ if (!fs_1.default.existsSync(geminiDir))
194
+ return [];
195
+ const extensionDir = path_1.default.join(geminiDir, 'extensions', AGENTBREW_EXTENSION_NAME);
196
+ const skillsDir = path_1.default.join(extensionDir, 'skills');
197
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
198
+ // Write extension manifest (we own this file)
199
+ const manifestPath = path_1.default.join(extensionDir, 'gemini-extension.json');
200
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify({ name: AGENTBREW_EXTENSION_NAME, version: package_json_1.default.version }, null, 2), 'utf-8');
201
+ // Enable extension in extension-enablement.json
202
+ _enableGeminiExtension(geminiDir);
203
+ const state = loadSyncedState(brewRoot);
204
+ return symlinkSkills(skills, skillsDir, state, 'gemini', brewRoot);
205
+ }
206
+ /**
207
+ * Removes Gemini CLI skill symlinks, the extension manifest, and the
208
+ * agentbrew extension entry from extension-enablement.json.
209
+ */
210
+ function unsyncSkillsFromGeminiCLI(brewRoot) {
211
+ const geminiDir = path_1.default.join(os_1.default.homedir(), '.gemini');
212
+ const extensionDir = path_1.default.join(geminiDir, 'extensions', AGENTBREW_EXTENSION_NAME);
213
+ const skillsDir = path_1.default.join(extensionDir, 'skills');
214
+ const state = loadSyncedState(brewRoot);
215
+ const results = removeTrackedSymlinks(state.gemini, skillsDir);
216
+ // Clean up extension dir (best-effort; ignores non-empty)
217
+ try {
218
+ fs_1.default.rmSync(path_1.default.join(extensionDir, 'gemini-extension.json'), { force: true });
219
+ }
220
+ catch { }
221
+ try {
222
+ fs_1.default.rmdirSync(skillsDir);
223
+ }
224
+ catch { }
225
+ try {
226
+ fs_1.default.rmdirSync(extensionDir);
227
+ }
228
+ catch { }
229
+ _disableGeminiExtension(geminiDir);
230
+ state.gemini = [];
231
+ saveSyncedState(state, brewRoot);
232
+ return results;
233
+ }
234
+ function _enableGeminiExtension(geminiDir) {
235
+ const enablementPath = path_1.default.join(geminiDir, 'extensions', 'extension-enablement.json');
236
+ let data = {};
237
+ try {
238
+ data = JSON.parse(fs_1.default.readFileSync(enablementPath, 'utf-8'));
239
+ }
240
+ catch { }
241
+ if (!data[AGENTBREW_EXTENSION_NAME]) {
242
+ data[AGENTBREW_EXTENSION_NAME] = { overrides: [`${os_1.default.homedir()}/*`] };
243
+ fs_1.default.writeFileSync(enablementPath, JSON.stringify(data, null, 2), 'utf-8');
244
+ }
245
+ }
246
+ function _disableGeminiExtension(geminiDir) {
247
+ const enablementPath = path_1.default.join(geminiDir, 'extensions', 'extension-enablement.json');
248
+ try {
249
+ const data = JSON.parse(fs_1.default.readFileSync(enablementPath, 'utf-8'));
250
+ if (AGENTBREW_EXTENSION_NAME in data) {
251
+ delete data[AGENTBREW_EXTENSION_NAME];
252
+ if (Object.keys(data).length === 0) {
253
+ fs_1.default.rmSync(enablementPath, { force: true });
254
+ }
255
+ else {
256
+ fs_1.default.writeFileSync(enablementPath, JSON.stringify(data, null, 2), 'utf-8');
257
+ }
258
+ }
259
+ }
260
+ catch { }
261
+ }
262
+ // ─── Windsurf ────────────────────────────────────────────────────────────────
263
+ /**
264
+ * Symlinks each skill directory into ~/.codeium/windsurf/skills/<pkgName>-<skillName>
265
+ * so Windsurf can discover them.
266
+ */
267
+ function syncSkillsToWindsurf(skills, brewRoot) {
268
+ const windsurfDir = path_1.default.join(os_1.default.homedir(), '.codeium', 'windsurf');
269
+ if (!fs_1.default.existsSync(windsurfDir))
270
+ return [];
271
+ const skillsDir = path_1.default.join(windsurfDir, 'skills');
272
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
273
+ const state = loadSyncedState(brewRoot);
274
+ return symlinkSkills(skills, skillsDir, state, 'windsurf', brewRoot);
275
+ }
276
+ /**
277
+ * Removes all Windsurf skill symlinks previously created by syncSkillsToWindsurf.
278
+ */
279
+ function unsyncSkillsFromWindsurf(brewRoot) {
280
+ const skillsDir = path_1.default.join(os_1.default.homedir(), '.codeium', 'windsurf', 'skills');
281
+ const state = loadSyncedState(brewRoot);
282
+ const results = removeTrackedSymlinks(state.windsurf, skillsDir);
283
+ state.windsurf = [];
284
+ saveSyncedState(state, brewRoot);
285
+ return results;
286
+ }
287
+ // ─── Antigravity CLI ─────────────────────────────────────────────────────────
288
+ /**
289
+ * Symlinks each skill directory into ~/.gemini/antigravity-cli/skills/<pkgName>-<skillName>
290
+ * so Antigravity CLI can auto-discover them.
291
+ */
292
+ function syncSkillsToAntigravityCLI(skills, brewRoot) {
293
+ const antigravityDir = path_1.default.join(os_1.default.homedir(), '.gemini', 'antigravity-cli');
294
+ if (!fs_1.default.existsSync(antigravityDir))
295
+ return [];
296
+ const skillsDir = path_1.default.join(antigravityDir, 'skills');
297
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
298
+ const state = loadSyncedState(brewRoot);
299
+ return symlinkSkills(skills, skillsDir, state, 'antigravity', brewRoot);
300
+ }
301
+ /**
302
+ * Removes all Antigravity CLI skill symlinks previously created by syncSkillsToAntigravityCLI.
303
+ */
304
+ function unsyncSkillsFromAntigravityCLI(brewRoot) {
305
+ const skillsDir = path_1.default.join(os_1.default.homedir(), '.gemini', 'antigravity-cli', 'skills');
306
+ const state = loadSyncedState(brewRoot);
307
+ const results = removeTrackedSymlinks(state.antigravity, skillsDir);
308
+ state.antigravity = [];
309
+ saveSyncedState(state, brewRoot);
310
+ return results;
311
+ }
312
+ // ─── Orphan cleanup ──────────────────────────────────────────────────────────
313
+ /**
314
+ * Removes symlinks whose targets no longer exist (e.g. after a package is uninstalled).
315
+ * Call after `agentbrew uninstall` to prevent stale entries.
316
+ */
317
+ function cleanOrphanSkills(brewRoot) {
318
+ const state = loadSyncedState(brewRoot);
319
+ const results = [];
320
+ const agentDirs = [
321
+ { key: 'claude', dir: path_1.default.join(os_1.default.homedir(), '.claude', 'skills') },
322
+ { key: 'gemini', dir: path_1.default.join(os_1.default.homedir(), '.gemini', 'extensions', AGENTBREW_EXTENSION_NAME, 'skills') },
323
+ { key: 'windsurf', dir: path_1.default.join(os_1.default.homedir(), '.codeium', 'windsurf', 'skills') },
324
+ { key: 'antigravity', dir: path_1.default.join(os_1.default.homedir(), '.gemini', 'antigravity-cli', 'skills') },
325
+ ];
326
+ for (const { key, dir } of agentDirs) {
327
+ const remaining = [];
328
+ for (const entryName of state[key]) {
329
+ const entryPath = path_1.default.join(dir, entryName);
330
+ let symlinkTarget = null;
331
+ try {
332
+ symlinkTarget = fs_1.default.readlinkSync(entryPath);
333
+ }
334
+ catch { }
335
+ if (symlinkTarget !== null && !fs_1.default.existsSync(symlinkTarget)) {
336
+ try {
337
+ fs_1.default.rmSync(entryPath, { force: true });
338
+ results.push({ entryName, status: 'removed', path: entryPath });
339
+ }
340
+ catch (e) {
341
+ results.push({ entryName, status: 'error', note: e.message });
342
+ remaining.push(entryName);
343
+ }
344
+ }
345
+ else {
346
+ remaining.push(entryName);
347
+ }
348
+ }
349
+ state[key] = remaining;
350
+ }
351
+ // Handle Cursor index file: parse referenced SKILL.md paths and remove the file if any are stale
352
+ if (state.cursor) {
353
+ const indexPath = path_1.default.join(os_1.default.homedir(), '.cursor', 'rules', CURSOR_SKILLS_INDEX_FILE);
354
+ let indexContent = null;
355
+ try {
356
+ indexContent = fs_1.default.readFileSync(indexPath, 'utf-8');
357
+ }
358
+ catch { }
359
+ if (indexContent === null) {
360
+ state.cursor = false;
361
+ }
362
+ else {
363
+ const pathMatches = [...indexContent.matchAll(/`([^`]+SKILL\.md)`/gi)];
364
+ const hasStalePath = pathMatches.some(m => !fs_1.default.existsSync(m[1]));
365
+ if (hasStalePath) {
366
+ try {
367
+ fs_1.default.rmSync(indexPath, { force: true });
368
+ state.cursor = false;
369
+ results.push({ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'removed', path: indexPath });
370
+ }
371
+ catch (e) {
372
+ results.push({ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'error', note: e.message });
373
+ }
374
+ }
375
+ }
376
+ }
377
+ saveSyncedState(state, brewRoot);
378
+ return results;
379
+ }
380
+ // ─── Cursor MCP server registration ─────────────────────────────────────────
381
+ const CURSOR_MCP_ENTRY = 'agentbrew';
382
+ /**
383
+ * Adds agentbrew to ~/.cursor/mcp.json so Cursor can discover MCP tools directly.
384
+ * Merges into any existing config without disturbing other servers.
385
+ */
386
+ function syncMcpServerToCursor(brewRoot) {
387
+ const cursorDir = path_1.default.join(os_1.default.homedir(), '.cursor');
388
+ if (!fs_1.default.existsSync(cursorDir))
389
+ return [];
390
+ const mcpJsonPath = path_1.default.join(cursorDir, 'mcp.json');
391
+ const entryName = 'agentbrew (Cursor MCP)';
392
+ let config = {};
393
+ try {
394
+ config = JSON.parse(fs_1.default.readFileSync(mcpJsonPath, 'utf-8'));
395
+ }
396
+ catch { }
397
+ const mcpServers = config.mcpServers ?? {};
398
+ const existing = mcpServers[CURSOR_MCP_ENTRY];
399
+ if (existing?.command === 'agentbrew') {
400
+ const state = loadSyncedState(brewRoot);
401
+ state.cursorMcp = true;
402
+ saveSyncedState(state, brewRoot);
403
+ return [{ entryName, status: 'already_exists', path: mcpJsonPath }];
404
+ }
405
+ config.mcpServers = { ...mcpServers, [CURSOR_MCP_ENTRY]: { command: 'agentbrew' } };
406
+ try {
407
+ fs_1.default.writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2), 'utf-8');
408
+ const state = loadSyncedState(brewRoot);
409
+ state.cursorMcp = true;
410
+ saveSyncedState(state, brewRoot);
411
+ return [{ entryName, status: 'linked', path: mcpJsonPath }];
412
+ }
413
+ catch (e) {
414
+ return [{ entryName, status: 'error', note: e.message }];
415
+ }
416
+ }
417
+ /**
418
+ * Removes the agentbrew entry from ~/.cursor/mcp.json.
419
+ * Leaves other servers intact; removes the file only if it becomes empty.
420
+ */
421
+ function unsyncMcpServerFromCursor(brewRoot) {
422
+ const state = loadSyncedState(brewRoot);
423
+ if (!state.cursorMcp)
424
+ return [];
425
+ const mcpJsonPath = path_1.default.join(os_1.default.homedir(), '.cursor', 'mcp.json');
426
+ const entryName = 'agentbrew (Cursor MCP)';
427
+ let config = {};
428
+ try {
429
+ config = JSON.parse(fs_1.default.readFileSync(mcpJsonPath, 'utf-8'));
430
+ }
431
+ catch {
432
+ state.cursorMcp = false;
433
+ saveSyncedState(state, brewRoot);
434
+ return [{ entryName, status: 'skipped', note: 'Not found' }];
435
+ }
436
+ if (!config.mcpServers?.[CURSOR_MCP_ENTRY]) {
437
+ state.cursorMcp = false;
438
+ saveSyncedState(state, brewRoot);
439
+ return [{ entryName, status: 'skipped', note: 'Not found' }];
440
+ }
441
+ delete config.mcpServers[CURSOR_MCP_ENTRY];
442
+ if (Object.keys(config.mcpServers).length === 0)
443
+ delete config.mcpServers;
444
+ try {
445
+ if (Object.keys(config).length === 0) {
446
+ fs_1.default.rmSync(mcpJsonPath, { force: true });
447
+ }
448
+ else {
449
+ fs_1.default.writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2), 'utf-8');
450
+ }
451
+ state.cursorMcp = false;
452
+ saveSyncedState(state, brewRoot);
453
+ return [{ entryName, status: 'removed', path: mcpJsonPath }];
454
+ }
455
+ catch (e) {
456
+ return [{ entryName, status: 'error', note: e.message }];
457
+ }
458
+ }
459
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
460
+ function buildCursorSkillsIndex(skills) {
461
+ const lines = [
462
+ '---',
463
+ 'description: AgentBrew skills index — reference when asked about available skills, tools, or capabilities',
464
+ 'alwaysApply: false',
465
+ '---',
466
+ '<!-- Managed by AgentBrew. Run `agentbrew sync` to update. Do not edit manually. -->',
467
+ '',
468
+ '# AgentBrew Skills',
469
+ '',
470
+ 'Skills installed via AgentBrew. Read a SKILL.md file to learn how to invoke it.',
471
+ '',
472
+ ];
473
+ for (const skill of skills) {
474
+ const desc = skill.description ? ` — ${skill.description}` : '';
475
+ lines.push(`- **${skill.packageName}/${skill.skillName}**${desc}: \`${path_1.default.join(skill.skillDir, 'SKILL.md')}\``);
476
+ }
477
+ return lines.join('\n') + '\n';
478
+ }
479
+ /**
480
+ * Writes a single skills index file to ~/.cursor/rules/agentbrew-skills-index.md
481
+ * listing all AgentBrew skills with paths for on-demand discovery.
482
+ * Does NOT copy individual SKILL.md files — Cursor rules are always-on context.
483
+ */
484
+ function syncSkillsToCursor(skills, brewRoot) {
485
+ const cursorDir = path_1.default.join(os_1.default.homedir(), '.cursor');
486
+ if (!fs_1.default.existsSync(cursorDir))
487
+ return [];
488
+ if (skills.length === 0)
489
+ return unsyncSkillsFromCursor(brewRoot);
490
+ const rulesDir = path_1.default.join(cursorDir, 'rules');
491
+ fs_1.default.mkdirSync(rulesDir, { recursive: true });
492
+ const indexPath = path_1.default.join(rulesDir, CURSOR_SKILLS_INDEX_FILE);
493
+ const content = buildCursorSkillsIndex(skills);
494
+ const state = loadSyncedState(brewRoot);
495
+ try {
496
+ fs_1.default.writeFileSync(indexPath, content, 'utf-8');
497
+ state.cursor = true;
498
+ saveSyncedState(state, brewRoot);
499
+ return [{ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'linked', path: indexPath }];
500
+ }
501
+ catch (e) {
502
+ return [{ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'error', note: e.message }];
503
+ }
504
+ }
505
+ /**
506
+ * Removes the AgentBrew skills index file from ~/.cursor/rules/.
507
+ */
508
+ function unsyncSkillsFromCursor(brewRoot) {
509
+ const state = loadSyncedState(brewRoot);
510
+ if (!state.cursor)
511
+ return [];
512
+ const indexPath = path_1.default.join(os_1.default.homedir(), '.cursor', 'rules', CURSOR_SKILLS_INDEX_FILE);
513
+ let exists = false;
514
+ try {
515
+ fs_1.default.lstatSync(indexPath);
516
+ exists = true;
517
+ }
518
+ catch { }
519
+ if (!exists) {
520
+ state.cursor = false;
521
+ saveSyncedState(state, brewRoot);
522
+ return [{ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'skipped', note: 'Not found' }];
523
+ }
524
+ try {
525
+ fs_1.default.rmSync(indexPath, { force: true });
526
+ state.cursor = false;
527
+ saveSyncedState(state, brewRoot);
528
+ return [{ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'removed', path: indexPath }];
529
+ }
530
+ catch (e) {
531
+ return [{ entryName: CURSOR_SKILLS_INDEX_FILE, status: 'error', note: e.message }];
532
+ }
533
+ }
534
+ // ─── Instruction sync (unchanged) ───────────────────────────────────────────
535
+ exports.MARKER_START = '<!-- agentbrew:shared:start -->';
536
+ exports.MARKER_END = '<!-- agentbrew:shared:end -->';
537
+ exports.INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
538
+ const EXAMPLE_INSTRUCTIONS = `# AgentBrew Shared Instructions
539
+
540
+ These instructions are shared across all your AI agents via AgentBrew.
541
+ Edit this file and run \`agentbrew sync\` to push updates to all agent configs.
542
+
543
+ ## Example: API Usage Policy
544
+ - Always use Context7 (the \`context7\` MCP tool) to fetch live API documentation
545
+ before writing code that calls an external library. This prevents using stale or
546
+ hallucinated function signatures.
547
+
548
+ ## Notes
549
+ - This file is global. For project-specific context, add it directly to the
550
+ project's CLAUDE.md or GEMINI.md — or reference project docs from within this file.
551
+ `;
552
+ function getDefaultTargets() {
553
+ const home = os_1.default.homedir();
554
+ return [
555
+ {
556
+ name: 'Claude Code',
557
+ configPath: path_1.default.join(home, '.claude', 'CLAUDE.md'),
558
+ isFileOwned: false,
559
+ },
560
+ {
561
+ name: 'Gemini CLI',
562
+ configPath: path_1.default.join(home, '.gemini', 'GEMINI.md'),
563
+ isFileOwned: false,
564
+ },
565
+ {
566
+ name: 'OpenAI Codex CLI',
567
+ // Codex CLI's canonical instruction file is AGENTS.md (instructions.md is a legacy fallback)
568
+ configPath: path_1.default.join(home, '.codex', 'AGENTS.md'),
569
+ isFileOwned: false,
570
+ },
571
+ {
572
+ name: 'Cursor',
573
+ // Cursor "User Rules" directory (Cursor 0.47+): each .md file in this dir is a global rule.
574
+ // We own this specific file entirely — no markers needed.
575
+ configPath: path_1.default.join(home, '.cursor', 'rules', 'agentbrew-shared.md'),
576
+ isFileOwned: true,
577
+ },
578
+ {
579
+ name: 'Windsurf',
580
+ configPath: path_1.default.join(home, '.codeium', 'windsurf', 'memories', 'global_rules.md'),
581
+ isFileOwned: false,
582
+ },
583
+ ];
584
+ }
585
+ function getInstructionsPath(brewRoot) {
586
+ return path_1.default.join(brewRoot ?? (0, config_1.getBrewRoot)(), exports.INSTRUCTIONS_FILE);
587
+ }
588
+ function buildInjectedSection(content) {
589
+ const warning = `> ⚠️ Managed by AgentBrew. Edit \`~/.agentbrew/INSTRUCTIONS.md\` and run \`agentbrew sync\` to update.`;
590
+ return `${exports.MARKER_START}\n${warning}\n\n${content.trim()}\n${exports.MARKER_END}`;
591
+ }
592
+ /**
593
+ * Injects (or updates) the agentbrew section in a file.
594
+ * Creates the file and any parent directories if they don't exist.
595
+ */
596
+ function injectIntoFile(filePath, content) {
597
+ const section = buildInjectedSection(content);
598
+ if (!fs_1.default.existsSync(filePath)) {
599
+ fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
600
+ fs_1.default.writeFileSync(filePath, section + '\n', 'utf-8');
601
+ return 'created';
602
+ }
603
+ const existing = fs_1.default.readFileSync(filePath, 'utf-8');
604
+ const startIdx = existing.indexOf(exports.MARKER_START);
605
+ const endIdx = existing.indexOf(exports.MARKER_END);
606
+ if (startIdx !== -1 && endIdx !== -1) {
607
+ const replaced = existing.substring(0, startIdx) +
608
+ section +
609
+ existing.substring(endIdx + exports.MARKER_END.length);
610
+ if (replaced === existing)
611
+ return 'unchanged';
612
+ fs_1.default.writeFileSync(filePath, replaced, 'utf-8');
613
+ return 'updated';
614
+ }
615
+ // No markers yet — append to end
616
+ const appended = existing.trimEnd() + '\n\n' + section + '\n';
617
+ fs_1.default.writeFileSync(filePath, appended, 'utf-8');
618
+ return 'updated';
619
+ }
620
+ /**
621
+ * Removes the agentbrew section from a file, leaving surrounding content intact.
622
+ */
623
+ function removeFromFile(filePath) {
624
+ if (!fs_1.default.existsSync(filePath))
625
+ return 'not_found';
626
+ const existing = fs_1.default.readFileSync(filePath, 'utf-8');
627
+ const startIdx = existing.indexOf(exports.MARKER_START);
628
+ const endIdx = existing.indexOf(exports.MARKER_END);
629
+ if (startIdx === -1 || endIdx === -1)
630
+ return 'no_section';
631
+ const before = existing.substring(0, startIdx).trimEnd();
632
+ const after = existing.substring(endIdx + exports.MARKER_END.length).trimStart();
633
+ let result = before;
634
+ if (after)
635
+ result += '\n\n' + after;
636
+ result = result.trimEnd() + '\n';
637
+ fs_1.default.writeFileSync(filePath, result, 'utf-8');
638
+ return 'removed';
639
+ }
640
+ /**
641
+ * Syncs ~/.agentbrew/INSTRUCTIONS.md into each target agent's global config file.
642
+ * Accepts an optional `targets` override (used in tests).
643
+ */
644
+ function syncInstructions(targets, brewRoot) {
645
+ const instructionsPath = getInstructionsPath(brewRoot);
646
+ if (!fs_1.default.existsSync(instructionsPath)) {
647
+ fs_1.default.mkdirSync(path_1.default.dirname(instructionsPath), { recursive: true });
648
+ fs_1.default.writeFileSync(instructionsPath, EXAMPLE_INSTRUCTIONS, 'utf-8');
649
+ return [];
650
+ }
651
+ const content = fs_1.default.readFileSync(instructionsPath, 'utf-8');
652
+ const resolvedTargets = targets ?? getDefaultTargets();
653
+ const results = [];
654
+ for (const target of resolvedTargets) {
655
+ if (target.configPath === null) {
656
+ results.push({ agent: target.name, status: 'manual', note: target.manualInstructions });
657
+ continue;
658
+ }
659
+ if (target.isFileOwned) {
660
+ // We own this file entirely — write raw content, no markers needed.
661
+ // unsync deletes the file; there is no surrounding user content to delimit around.
662
+ const header = `> ⚠️ Managed by AgentBrew. Edit \`~/.agentbrew/INSTRUCTIONS.md\` and run \`agentbrew sync\` to update.\n`;
663
+ const fileContent = header + '\n' + content.trim() + '\n';
664
+ fs_1.default.mkdirSync(path_1.default.dirname(target.configPath), { recursive: true });
665
+ const existing = fs_1.default.existsSync(target.configPath)
666
+ ? fs_1.default.readFileSync(target.configPath, 'utf-8')
667
+ : null;
668
+ if (existing === fileContent) {
669
+ results.push({ agent: target.name, status: 'unchanged', path: target.configPath });
670
+ }
671
+ else {
672
+ fs_1.default.writeFileSync(target.configPath, fileContent, 'utf-8');
673
+ results.push({ agent: target.name, status: existing !== null ? 'updated' : 'created', path: target.configPath });
674
+ }
675
+ continue;
676
+ }
677
+ // Skip agents that aren't installed (config parent dir absent)
678
+ if (!fs_1.default.existsSync(path_1.default.dirname(target.configPath))) {
679
+ results.push({ agent: target.name, status: 'skipped', note: 'Agent not installed (config directory not found)' });
680
+ continue;
681
+ }
682
+ const status = injectIntoFile(target.configPath, content);
683
+ results.push({ agent: target.name, status, path: target.configPath });
684
+ }
685
+ return results;
686
+ }
687
+ /**
688
+ * Removes the agentbrew section from all target agent config files.
689
+ */
690
+ function unsyncInstructions(targets) {
691
+ const resolvedTargets = targets ?? getDefaultTargets();
692
+ const results = [];
693
+ for (const target of resolvedTargets) {
694
+ if (target.configPath === null) {
695
+ results.push({ agent: target.name, status: 'manual', note: 'Remove manually from your agent UI settings.' });
696
+ continue;
697
+ }
698
+ if (target.isFileOwned) {
699
+ if (fs_1.default.existsSync(target.configPath)) {
700
+ fs_1.default.rmSync(target.configPath);
701
+ results.push({ agent: target.name, status: 'removed', path: target.configPath });
702
+ }
703
+ else {
704
+ results.push({ agent: target.name, status: 'not_found', path: target.configPath });
705
+ }
706
+ continue;
707
+ }
708
+ const status = removeFromFile(target.configPath);
709
+ results.push({ agent: target.name, status, path: target.configPath });
710
+ }
711
+ return results;
712
+ }
713
+ //# sourceMappingURL=sync.js.map