Skip to content

Commit b07ad83

Browse files
committed
feat(rules): add adapter auto-sync mechanism (yarn sync-rules)
Add sync-rules CLI that keeps tool-specific config files in sync with .ai-rules/ as the single source of truth. - `yarn sync-rules` — generate/update all tool configs - `yarn sync-rules --dry-run` — preview changes without writing - `yarn sync-rules --check` — CI validation, exit 1 if out of sync - `yarn sync-rules cursor kiro` — sync specific tools only Reuses existing sync-settings readers/generators for the core logic. Includes 12 tests covering all modes and edge cases. Closes #997
1 parent 149b9f4 commit b07ad83

4 files changed

Lines changed: 418 additions & 1 deletion

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
"test:wiki": "yarn workspace codingbuddy exec vitest run --config ../../scripts/wiki/vitest.config.ts --root ../../scripts/wiki",
4242
"sync:settings": "npx tsx scripts/sync-settings/index.ts",
4343
"test:sync": "node apps/mcp-server/node_modules/.bin/vitest run --config scripts/sync-settings/vitest.config.ts --root .",
44-
"test:hooks": "cd packages/claude-code-plugin && python3 -m pytest tests/ -v"
44+
"test:hooks": "cd packages/claude-code-plugin && python3 -m pytest tests/ -v",
45+
"sync-rules": "npx tsx scripts/sync-rules.ts",
46+
"test:sync-rules": "node apps/mcp-server/node_modules/.bin/vitest run --config scripts/sync-rules.vitest.config.ts --root ."
4547
},
4648
"devDependencies": {
4749
"@eslint/js": "9.39.2",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import os from 'node:os';
5+
import {
6+
syncRules,
7+
readSourceData,
8+
generateFiles,
9+
diffFiles,
10+
writeFiles,
11+
} from '../sync-rules';
12+
13+
describe('sync-rules', () => {
14+
let tmpDir: string;
15+
16+
beforeEach(async () => {
17+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sync-rules-test-'));
18+
19+
// Create .ai-rules source structure
20+
const agentsDir = path.join(tmpDir, 'packages/rules/.ai-rules/agents');
21+
const rulesDir = path.join(tmpDir, 'packages/rules/.ai-rules/rules');
22+
await fs.mkdir(agentsDir, { recursive: true });
23+
await fs.mkdir(rulesDir, { recursive: true });
24+
25+
// Create agent fixture
26+
await fs.writeFile(
27+
path.join(agentsDir, 'frontend-developer.json'),
28+
JSON.stringify({
29+
name: 'Frontend Developer',
30+
description: 'Frontend specialist',
31+
expertise: ['React'],
32+
}),
33+
);
34+
35+
// Create rule fixture
36+
await fs.writeFile(path.join(rulesDir, 'core.md'), '# Core Rules\n');
37+
38+
// Create target directories
39+
for (const dir of [
40+
'.cursor/rules',
41+
'.claude/rules',
42+
'.antigravity/rules',
43+
'.codex/rules',
44+
'.q/rules',
45+
'.kiro/rules',
46+
]) {
47+
await fs.mkdir(path.join(tmpDir, dir), { recursive: true });
48+
}
49+
});
50+
51+
describe('readSourceData', () => {
52+
it('reads agents and rules from .ai-rules', async () => {
53+
const data = await readSourceData(tmpDir);
54+
55+
expect(data.agents).toHaveLength(1);
56+
expect(data.agents[0].displayName).toBe('Frontend Developer');
57+
expect(data.rules).toHaveLength(1);
58+
expect(data.rules[0].name).toBe('core');
59+
});
60+
});
61+
62+
describe('generateFiles', () => {
63+
it('generates files for all tools', async () => {
64+
const data = await readSourceData(tmpDir);
65+
const files = generateFiles(data);
66+
67+
expect(files.length).toBeGreaterThanOrEqual(7);
68+
expect(files.some(f => f.relativePath.startsWith('.cursor/'))).toBe(true);
69+
expect(files.some(f => f.relativePath.startsWith('.claude/'))).toBe(true);
70+
expect(files.some(f => f.relativePath.startsWith('.codex/'))).toBe(true);
71+
});
72+
73+
it('generates files for a specific tool', async () => {
74+
const data = await readSourceData(tmpDir);
75+
const files = generateFiles(data, ['cursor']);
76+
77+
expect(files).toHaveLength(2);
78+
expect(files.every(f => f.relativePath.startsWith('.cursor/'))).toBe(true);
79+
});
80+
});
81+
82+
describe('diffFiles', () => {
83+
it('reports added for missing files', async () => {
84+
const data = await readSourceData(tmpDir);
85+
const files = generateFiles(data, ['q']);
86+
// Don't write anything first — file is "new"
87+
// But we created the dir, so remove the file if it exists
88+
const absPath = path.join(tmpDir, '.q/rules/customizations.md');
89+
try {
90+
await fs.unlink(absPath);
91+
} catch {
92+
// doesn't exist, that's fine
93+
}
94+
95+
const changes = await diffFiles(tmpDir, files);
96+
97+
expect(changes).toHaveLength(1);
98+
expect(changes[0].status).toBe('added');
99+
});
100+
101+
it('reports unchanged for identical files', async () => {
102+
const data = await readSourceData(tmpDir);
103+
const files = generateFiles(data, ['q']);
104+
await writeFiles(tmpDir, files);
105+
106+
const changes = await diffFiles(tmpDir, files);
107+
108+
expect(changes).toHaveLength(1);
109+
expect(changes[0].status).toBe('unchanged');
110+
});
111+
112+
it('reports modified for differing files', async () => {
113+
const data = await readSourceData(tmpDir);
114+
const files = generateFiles(data, ['q']);
115+
// Write stale content
116+
const absPath = path.join(tmpDir, files[0].relativePath);
117+
await fs.writeFile(absPath, 'stale content', 'utf-8');
118+
119+
const changes = await diffFiles(tmpDir, files);
120+
121+
expect(changes).toHaveLength(1);
122+
expect(changes[0].status).toBe('modified');
123+
});
124+
});
125+
126+
describe('syncRules', () => {
127+
it('writes files in default mode', async () => {
128+
const result = await syncRules(tmpDir);
129+
130+
expect(result.outOfSync).toBe(true);
131+
expect(result.changes.some(c => c.status === 'added')).toBe(true);
132+
133+
// Verify files were actually written
134+
const content = await fs.readFile(
135+
path.join(tmpDir, '.cursor/rules/auto-agent.mdc'),
136+
'utf-8',
137+
);
138+
expect(content).toContain('frontend-developer');
139+
});
140+
141+
it('does not write files in dry-run mode', async () => {
142+
const result = await syncRules(tmpDir, { dryRun: true });
143+
144+
expect(result.outOfSync).toBe(true);
145+
146+
// Files should NOT have been written
147+
await expect(
148+
fs.readFile(path.join(tmpDir, '.codex/rules/system-prompt.md'), 'utf-8'),
149+
).rejects.toThrow();
150+
});
151+
152+
it('does not write files in check mode', async () => {
153+
const result = await syncRules(tmpDir, { check: true });
154+
155+
expect(result.outOfSync).toBe(true);
156+
157+
// Files should NOT have been written
158+
await expect(
159+
fs.readFile(path.join(tmpDir, '.codex/rules/system-prompt.md'), 'utf-8'),
160+
).rejects.toThrow();
161+
});
162+
163+
it('returns outOfSync=false when files match', async () => {
164+
// First sync to write all files
165+
await syncRules(tmpDir);
166+
167+
// Check mode should pass
168+
const result = await syncRules(tmpDir, { check: true });
169+
170+
expect(result.outOfSync).toBe(false);
171+
expect(result.changes.every(c => c.status === 'unchanged')).toBe(true);
172+
});
173+
174+
it('syncs only specified tools', async () => {
175+
const result = await syncRules(tmpDir, { tools: ['kiro'] });
176+
177+
expect(result.changes).toHaveLength(1);
178+
expect(result.changes[0].relativePath).toBe('.kiro/rules/guidelines.md');
179+
});
180+
181+
it('is idempotent — running twice produces same result', async () => {
182+
await syncRules(tmpDir);
183+
const first = await fs.readFile(
184+
path.join(tmpDir, '.cursor/rules/auto-agent.mdc'),
185+
'utf-8',
186+
);
187+
188+
await syncRules(tmpDir);
189+
const second = await fs.readFile(
190+
path.join(tmpDir, '.cursor/rules/auto-agent.mdc'),
191+
'utf-8',
192+
);
193+
194+
expect(first).toBe(second);
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)