Skip to content

Commit c526cf4

Browse files
committed
feat: add custom rules system for .codingbuddy/ folder support
- Enable users to add custom rules, agents, and skills via .codingbuddy/ folder: - CustomService: discovers and parses .codingbuddy/{rules,agents,skills}/ - RulesService integration: merges custom rules with built-in rules - MCP tools: include source field ('custom' | 'default') in responses - Validation: agents validated against AgentProfile schema close #124
1 parent 14d223c commit c526cf4

10 files changed

Lines changed: 612 additions & 12 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { CustomService } from './custom.service';
3+
4+
@Module({
5+
providers: [CustomService],
6+
exports: [CustomService],
7+
})
8+
export class CustomModule {}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { CustomService } from './custom.service';
3+
import * as fs from 'fs/promises';
4+
5+
vi.mock('fs/promises');
6+
7+
describe('CustomService', () => {
8+
let service: CustomService;
9+
const mockFs = vi.mocked(fs);
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
service = new CustomService();
14+
});
15+
16+
describe('findCustomPath', () => {
17+
it('returns path when .codingbuddy exists', async () => {
18+
mockFs.access.mockResolvedValue(undefined);
19+
20+
const result = await service.findCustomPath('/project');
21+
22+
expect(result).toBe('/project/.codingbuddy');
23+
});
24+
25+
it('returns null when .codingbuddy does not exist', async () => {
26+
mockFs.access.mockRejectedValue(new Error('ENOENT'));
27+
28+
const result = await service.findCustomPath('/project');
29+
30+
expect(result).toBeNull();
31+
});
32+
});
33+
34+
describe('listCustomRules', () => {
35+
it('returns rules from .codingbuddy/rules/', async () => {
36+
mockFs.access.mockResolvedValue(undefined);
37+
mockFs.readdir.mockResolvedValue([
38+
{ name: 'api.md', isFile: () => true, isDirectory: () => false },
39+
{ name: 'naming.md', isFile: () => true, isDirectory: () => false },
40+
] as any);
41+
mockFs.readFile.mockResolvedValue('# Rule content');
42+
43+
const result = await service.listCustomRules('/project');
44+
45+
expect(result).toHaveLength(2);
46+
expect(result[0].name).toBe('api.md');
47+
expect(result[0].source).toBe('custom');
48+
expect(result[0].content).toBe('# Rule content');
49+
});
50+
51+
it('returns empty array when no .codingbuddy folder', async () => {
52+
mockFs.access.mockRejectedValue(new Error('ENOENT'));
53+
54+
const result = await service.listCustomRules('/project');
55+
56+
expect(result).toEqual([]);
57+
});
58+
59+
it('filters non-md files', async () => {
60+
mockFs.access.mockResolvedValue(undefined);
61+
mockFs.readdir.mockResolvedValue([
62+
{ name: 'api.md', isFile: () => true, isDirectory: () => false },
63+
{ name: 'readme.txt', isFile: () => true, isDirectory: () => false },
64+
] as any);
65+
mockFs.readFile.mockResolvedValue('# Content');
66+
67+
const result = await service.listCustomRules('/project');
68+
69+
expect(result).toHaveLength(1);
70+
expect(result[0].name).toBe('api.md');
71+
});
72+
});
73+
74+
describe('listCustomAgents', () => {
75+
it('returns valid agents from .codingbuddy/agents/', async () => {
76+
const validAgent = JSON.stringify({
77+
name: 'API Specialist',
78+
description: 'API design expert',
79+
role: {
80+
title: 'API Specialist',
81+
expertise: ['REST'],
82+
},
83+
});
84+
mockFs.access.mockResolvedValue(undefined);
85+
mockFs.readdir.mockResolvedValue([
86+
{ name: 'api.json', isFile: () => true, isDirectory: () => false },
87+
] as any);
88+
mockFs.readFile.mockResolvedValue(validAgent);
89+
90+
const result = await service.listCustomAgents('/project');
91+
92+
expect(result).toHaveLength(1);
93+
expect(result[0].parsed.name).toBe('API Specialist');
94+
expect(result[0].source).toBe('custom');
95+
});
96+
97+
it('skips invalid JSON files', async () => {
98+
mockFs.access.mockResolvedValue(undefined);
99+
mockFs.readdir.mockResolvedValue([
100+
{ name: 'invalid.json', isFile: () => true, isDirectory: () => false },
101+
] as any);
102+
mockFs.readFile.mockResolvedValue('not valid json');
103+
104+
const result = await service.listCustomAgents('/project');
105+
106+
expect(result).toEqual([]);
107+
});
108+
109+
it('skips agents missing required fields', async () => {
110+
const invalidAgent = JSON.stringify({
111+
name: 'Missing role and description',
112+
});
113+
mockFs.access.mockResolvedValue(undefined);
114+
mockFs.readdir.mockResolvedValue([
115+
{ name: 'bad.json', isFile: () => true, isDirectory: () => false },
116+
] as any);
117+
mockFs.readFile.mockResolvedValue(invalidAgent);
118+
119+
const result = await service.listCustomAgents('/project');
120+
121+
expect(result).toEqual([]);
122+
});
123+
});
124+
125+
describe('listCustomSkills', () => {
126+
it('returns skills from .codingbuddy/skills/*/SKILL.md', async () => {
127+
mockFs.access.mockResolvedValue(undefined);
128+
mockFs.readdir.mockResolvedValue([
129+
{ name: 'my-workflow', isFile: () => false, isDirectory: () => true },
130+
] as any);
131+
mockFs.readFile.mockResolvedValue('# Skill content');
132+
133+
const result = await service.listCustomSkills('/project');
134+
135+
expect(result).toHaveLength(1);
136+
expect(result[0].name).toBe('my-workflow');
137+
expect(result[0].source).toBe('custom');
138+
});
139+
140+
it('skips folders without SKILL.md', async () => {
141+
mockFs.access.mockResolvedValue(undefined);
142+
mockFs.readdir.mockResolvedValue([
143+
{ name: 'incomplete', isFile: () => false, isDirectory: () => true },
144+
] as any);
145+
mockFs.readFile.mockRejectedValue(new Error('ENOENT'));
146+
147+
const result = await service.listCustomSkills('/project');
148+
149+
expect(result).toEqual([]);
150+
});
151+
});
152+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import * as fs from 'fs/promises';
3+
import * as path from 'path';
4+
import {
5+
CUSTOM_DIR,
6+
CUSTOM_SUBDIRS,
7+
CustomRule,
8+
CustomAgent,
9+
CustomAgentSchema,
10+
CustomSkill,
11+
} from './custom.types';
12+
13+
@Injectable()
14+
export class CustomService {
15+
private readonly logger = new Logger(CustomService.name);
16+
17+
async findCustomPath(projectRoot: string): Promise<string | null> {
18+
const customPath = path.join(projectRoot, CUSTOM_DIR);
19+
try {
20+
await fs.access(customPath);
21+
this.logger.debug(`Found custom rules at: ${customPath}`);
22+
return customPath;
23+
} catch {
24+
return null;
25+
}
26+
}
27+
28+
async listCustomRules(projectRoot: string): Promise<CustomRule[]> {
29+
const customPath = await this.findCustomPath(projectRoot);
30+
if (!customPath) return [];
31+
32+
const rulesPath = path.join(customPath, CUSTOM_SUBDIRS.rules);
33+
try {
34+
await fs.access(rulesPath);
35+
} catch {
36+
return [];
37+
}
38+
39+
const entries = await fs.readdir(rulesPath, { withFileTypes: true });
40+
const rules: CustomRule[] = [];
41+
42+
for (const entry of entries) {
43+
if (entry.isFile() && entry.name.endsWith('.md')) {
44+
const filePath = path.join(rulesPath, entry.name);
45+
const content = await fs.readFile(filePath, 'utf-8');
46+
rules.push({
47+
type: 'rule',
48+
name: entry.name,
49+
path: filePath,
50+
content,
51+
source: 'custom',
52+
});
53+
}
54+
}
55+
56+
return rules;
57+
}
58+
59+
async listCustomAgents(projectRoot: string): Promise<CustomAgent[]> {
60+
const customPath = await this.findCustomPath(projectRoot);
61+
if (!customPath) return [];
62+
63+
const agentsPath = path.join(customPath, CUSTOM_SUBDIRS.agents);
64+
try {
65+
await fs.access(agentsPath);
66+
} catch {
67+
return [];
68+
}
69+
70+
const entries = await fs.readdir(agentsPath, { withFileTypes: true });
71+
const agents: CustomAgent[] = [];
72+
73+
for (const entry of entries) {
74+
if (entry.isFile() && entry.name.endsWith('.json')) {
75+
const filePath = path.join(agentsPath, entry.name);
76+
try {
77+
const content = await fs.readFile(filePath, 'utf-8');
78+
const parsed = JSON.parse(content) as CustomAgentSchema;
79+
80+
// Validate required fields (compatible with AgentProfile)
81+
if (!parsed.name || !parsed.description || !parsed.role) {
82+
this.logger.warn(
83+
`Invalid agent file (missing required fields): ${filePath}`,
84+
);
85+
continue;
86+
}
87+
88+
agents.push({
89+
type: 'agent',
90+
name: entry.name,
91+
path: filePath,
92+
content,
93+
source: 'custom',
94+
parsed,
95+
});
96+
} catch (error) {
97+
this.logger.warn(`Invalid JSON in agent file: ${filePath}`);
98+
// Skip invalid JSON
99+
}
100+
}
101+
}
102+
103+
return agents;
104+
}
105+
106+
async listCustomSkills(projectRoot: string): Promise<CustomSkill[]> {
107+
const customPath = await this.findCustomPath(projectRoot);
108+
if (!customPath) return [];
109+
110+
const skillsPath = path.join(customPath, CUSTOM_SUBDIRS.skills);
111+
try {
112+
await fs.access(skillsPath);
113+
} catch {
114+
return [];
115+
}
116+
117+
const entries = await fs.readdir(skillsPath, { withFileTypes: true });
118+
const skills: CustomSkill[] = [];
119+
120+
for (const entry of entries) {
121+
if (entry.isDirectory()) {
122+
const skillFile = path.join(skillsPath, entry.name, 'SKILL.md');
123+
try {
124+
const content = await fs.readFile(skillFile, 'utf-8');
125+
skills.push({
126+
type: 'skill',
127+
name: entry.name,
128+
path: skillFile,
129+
content,
130+
source: 'custom',
131+
});
132+
} catch {
133+
// Skip folders without SKILL.md
134+
}
135+
}
136+
}
137+
138+
return skills;
139+
}
140+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export interface CustomFile {
2+
name: string;
3+
path: string;
4+
content: string;
5+
source: 'custom' | 'default';
6+
}
7+
8+
export interface CustomRule extends CustomFile {
9+
type: 'rule';
10+
}
11+
12+
export interface CustomAgent extends CustomFile {
13+
type: 'agent';
14+
parsed: CustomAgentSchema;
15+
}
16+
17+
export interface CustomSkill extends CustomFile {
18+
type: 'skill';
19+
}
20+
21+
/**
22+
* Schema for custom agent JSON files.
23+
* Compatible with AgentProfile from rules.types.ts
24+
*/
25+
export interface CustomAgentSchema {
26+
name: string;
27+
description: string;
28+
role: {
29+
title: string;
30+
expertise: string[];
31+
tech_stack_reference?: string;
32+
responsibilities?: string[];
33+
};
34+
[key: string]: unknown;
35+
}
36+
37+
export const CUSTOM_DIR = '.codingbuddy';
38+
39+
export const CUSTOM_SUBDIRS = {
40+
rules: 'rules',
41+
agents: 'agents',
42+
skills: 'skills',
43+
} as const;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './custom.module';
2+
export * from './custom.service';
3+
export * from './custom.types';

0 commit comments

Comments
 (0)