Skip to content

Commit 935f203

Browse files
committed
feat: implement skills system
- Implement cross-platform skills system and add initial skills - Implement skill schema and validation logic (Zod-based, YAML frontmatter parsing) - Add MCP tools (list_skills, get_skill) - Add initial skills (TDD, systematic-debugging, writing-plans, executing-plans, etc.) Update adapter documentation (claude-code, codex, cursor) - Add implementation plan documents - Add yaml package dependency close #116
1 parent 664034c commit 935f203

21 files changed

Lines changed: 2717 additions & 48 deletions

File tree

apps/mcp-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"codingbuddy-rules": "workspace:*",
6969
"reflect-metadata": "^0.2.2",
7070
"rxjs": "^7.8.1",
71+
"yaml": "^2.8.2",
7172
"zod": "^4.2.1"
7273
},
7374
"devDependencies": {

apps/mcp-server/src/mcp/mcp-serverless.spec.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, it, expect, beforeEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import * as path from 'path';
3+
import * as fs from 'fs/promises';
34
import { McpServerlessService } from './mcp-serverless';
45

56
// ============================================================================
@@ -505,6 +506,121 @@ describe('McpServerlessService', () => {
505506
}
506507
});
507508
});
509+
510+
// ==========================================================================
511+
// Skills Tests
512+
// ==========================================================================
513+
514+
describe('listSkills', () => {
515+
const testSkillsDir = path.join(TEST_RULES_DIR, 'skills', 'test-skill');
516+
517+
afterEach(async () => {
518+
// Cleanup test skill directory
519+
try {
520+
await fs.rm(testSkillsDir, { recursive: true, force: true });
521+
} catch {
522+
// Ignore cleanup errors
523+
}
524+
});
525+
526+
it('should return all skills with name and description', async () => {
527+
// Create test skill
528+
await fs.mkdir(testSkillsDir, { recursive: true });
529+
await fs.writeFile(
530+
path.join(testSkillsDir, 'SKILL.md'),
531+
`---
532+
name: test-skill
533+
description: A test skill for testing
534+
---
535+
536+
# Test Skill Content
537+
538+
This is test content.
539+
`,
540+
);
541+
542+
const result = await invokeToolHandler(service, 'listSkills');
543+
const data = JSON.parse(result.content[0].text);
544+
545+
expect(Array.isArray(data)).toBe(true);
546+
const testSkill = data.find(
547+
(s: { name: string }) => s.name === 'test-skill',
548+
);
549+
expect(testSkill).toBeDefined();
550+
expect(testSkill.description).toBe('A test skill for testing');
551+
});
552+
553+
it('should return empty array when no skills exist', async () => {
554+
// Use a service with empty skills directory
555+
const emptyService = new McpServerlessService(
556+
'/nonexistent/rules',
557+
TEST_PROJECT_ROOT,
558+
);
559+
560+
const result = await invokeToolHandler(emptyService, 'listSkills');
561+
const data = JSON.parse(result.content[0].text);
562+
563+
expect(Array.isArray(data)).toBe(true);
564+
expect(data.length).toBe(0);
565+
});
566+
});
567+
568+
describe('getSkill', () => {
569+
const testSkillsDir = path.join(TEST_RULES_DIR, 'skills', 'my-skill');
570+
571+
afterEach(async () => {
572+
// Cleanup test skill directory
573+
try {
574+
await fs.rm(testSkillsDir, { recursive: true, force: true });
575+
} catch {
576+
// Ignore cleanup errors
577+
}
578+
});
579+
580+
it('should return skill content by name', async () => {
581+
// Create test skill
582+
await fs.mkdir(testSkillsDir, { recursive: true });
583+
await fs.writeFile(
584+
path.join(testSkillsDir, 'SKILL.md'),
585+
`---
586+
name: my-skill
587+
description: My skill description
588+
---
589+
590+
# My Skill
591+
592+
Detailed content here.
593+
`,
594+
);
595+
596+
const result = await invokeToolHandler(service, 'getSkill', 'my-skill');
597+
expect(result.isError).toBeUndefined();
598+
599+
const skill = JSON.parse(result.content[0].text);
600+
expect(skill.name).toBe('my-skill');
601+
expect(skill.description).toBe('My skill description');
602+
expect(skill.content).toContain('# My Skill');
603+
});
604+
605+
it('should throw for non-existent skill', async () => {
606+
const result = await invokeToolHandler(
607+
service,
608+
'getSkill',
609+
'non-existent-skill',
610+
);
611+
expect(result.isError).toBe(true);
612+
expect(result.content[0].text).toContain('not found');
613+
});
614+
615+
it('should validate skill name format', async () => {
616+
const result = await invokeToolHandler(
617+
service,
618+
'getSkill',
619+
'Invalid Name!',
620+
);
621+
expect(result.isError).toBe(true);
622+
});
623+
});
508624
});
509625

510626
// ============================================================================
@@ -534,6 +650,8 @@ async function invokeToolHandler(
534650
parseMode: 'handleParseMode',
535651
getProjectConfig: 'handleGetProjectConfig',
536652
suggestConfigUpdates: 'handleSuggestConfigUpdates',
653+
listSkills: 'handleListSkills',
654+
getSkill: 'handleGetSkill',
537655
};
538656

539657
const methodName = methodMap[handlerName];

apps/mcp-server/src/mcp/mcp-serverless.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { loadConfig } from '../config/config.loader';
1515
import type { CodingBuddyConfig } from '../config/config.schema';
1616
import { isPathSafe } from '../shared/security.utils';
1717
import { parseAgentProfile, AgentSchemaError } from '../rules/agent.schema';
18+
import { parseSkill, SkillSchemaError } from '../rules/skill.schema';
19+
import type { Skill } from '../rules/skill.schema';
1820
import {
1921
validateQuery,
2022
validatePrompt,
@@ -36,6 +38,11 @@ interface ParseModeResponse extends ParseModeResult {
3638
language?: string;
3739
}
3840

41+
interface SkillSummary {
42+
name: string;
43+
description: string;
44+
}
45+
3946
// ============================================================================
4047
// Default Configuration
4148
// ============================================================================
@@ -226,6 +233,34 @@ export class McpServerlessService {
226233
return this.handleSuggestConfigUpdates(projectRoot);
227234
},
228235
);
236+
237+
// list_skills tool
238+
this.server.registerTool(
239+
'list_skills',
240+
{
241+
title: 'List Skills',
242+
description: 'List all available skills with descriptions',
243+
inputSchema: {},
244+
},
245+
async (): Promise<ToolResponse> => {
246+
return this.handleListSkills();
247+
},
248+
);
249+
250+
// get_skill tool
251+
this.server.registerTool(
252+
'get_skill',
253+
{
254+
title: 'Get Skill',
255+
description: 'Get skill content by name',
256+
inputSchema: {
257+
skillName: z.string().describe('Name of the skill'),
258+
},
259+
},
260+
async ({ skillName }): Promise<ToolResponse> => {
261+
return this.handleGetSkill(skillName);
262+
},
263+
);
229264
}
230265

231266
private registerResources(): void {
@@ -304,6 +339,31 @@ export class McpServerlessService {
304339
}
305340
}
306341

342+
private async handleListSkills(): Promise<ToolResponse> {
343+
try {
344+
const skills = await this.listSkills();
345+
return this.jsonResponse(skills);
346+
} catch (error) {
347+
return this.errorResponse(
348+
`Failed to list skills: ${sanitizeError(error)}`,
349+
);
350+
}
351+
}
352+
353+
private async handleGetSkill(skillName: string): Promise<ToolResponse> {
354+
// Validate skill name
355+
if (!skillName || !/^[a-z0-9-]+$/.test(skillName)) {
356+
return this.errorResponse('Invalid skill name format');
357+
}
358+
359+
try {
360+
const skill = await this.getSkill(skillName);
361+
return this.jsonResponse(skill);
362+
} catch {
363+
return this.errorResponse(`Skill '${skillName}' not found.`);
364+
}
365+
}
366+
307367
private async handleSuggestConfigUpdates(
308368
projectRoot?: string,
309369
): Promise<ToolResponse> {
@@ -467,6 +527,65 @@ export class McpServerlessService {
467527
return results.sort((a, b) => b.score - a.score);
468528
}
469529

530+
// ============================================================================
531+
// Skills Operations
532+
// ============================================================================
533+
534+
async listSkills(): Promise<SkillSummary[]> {
535+
const skillsDir = path.join(this.rulesDir, 'skills');
536+
const summaries: SkillSummary[] = [];
537+
538+
try {
539+
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
540+
541+
for (const entry of entries) {
542+
if (entry.isDirectory()) {
543+
const skillPath = path.join(skillsDir, entry.name, 'SKILL.md');
544+
try {
545+
const content = await fs.readFile(skillPath, 'utf-8');
546+
const skill = parseSkill(content, `skills/${entry.name}/SKILL.md`);
547+
summaries.push({
548+
name: skill.name,
549+
description: skill.description,
550+
});
551+
} catch {
552+
// Skip invalid skills
553+
}
554+
}
555+
}
556+
} catch {
557+
// Skills directory doesn't exist
558+
}
559+
560+
return summaries;
561+
}
562+
563+
async getSkill(name: string): Promise<Skill> {
564+
// Validate name format
565+
if (!/^[a-z0-9-]+$/.test(name)) {
566+
throw new Error(`Invalid skill name format: ${name}`);
567+
}
568+
569+
const skillPath = `skills/${name}/SKILL.md`;
570+
571+
// Security check
572+
if (!isPathSafe(this.rulesDir, skillPath)) {
573+
throw new Error('Access denied: Invalid path');
574+
}
575+
576+
const fullPath = path.join(this.rulesDir, skillPath);
577+
578+
try {
579+
const content = await fs.readFile(fullPath, 'utf-8');
580+
return parseSkill(content, skillPath);
581+
} catch (error) {
582+
if (error instanceof SkillSchemaError) {
583+
throw new Error(`Invalid skill: ${name}`);
584+
}
585+
throw new Error(`Skill not found: ${name}`);
586+
}
587+
}
588+
470589
// ============================================================================
471590
// Keyword/Mode Operations (extracted from KeywordService)
472591
// ============================================================================

apps/mcp-server/src/rules/agent.schema.ts

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,7 @@
88
*/
99

1010
import * as z from 'zod';
11-
12-
// ============================================================================
13-
// Dangerous Keys (Prototype Pollution Prevention)
14-
// ============================================================================
15-
16-
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;
17-
18-
/**
19-
* Recursively check for dangerous keys in an object
20-
* Uses Object.getOwnPropertyNames to also check non-enumerable properties
21-
*/
22-
function containsDangerousKeys(obj: unknown, path = ''): string | null {
23-
if (obj === null || typeof obj !== 'object') {
24-
return null;
25-
}
26-
27-
if (Array.isArray(obj)) {
28-
for (let i = 0; i < obj.length; i++) {
29-
const result = containsDangerousKeys(obj[i], `${path}[${i}]`);
30-
if (result) return result;
31-
}
32-
return null;
33-
}
34-
35-
// Use Object.getOwnPropertyNames to catch all properties including non-enumerable
36-
// Also check with hasOwnProperty for keys like __proto__ that might be special
37-
const keys = Object.getOwnPropertyNames(obj);
38-
39-
for (const key of keys) {
40-
if (DANGEROUS_KEYS.includes(key as (typeof DANGEROUS_KEYS)[number])) {
41-
return path ? `${path}.${key}` : key;
42-
}
43-
}
44-
45-
// Recursively check nested objects
46-
for (const key of keys) {
47-
if (!DANGEROUS_KEYS.includes(key as (typeof DANGEROUS_KEYS)[number])) {
48-
const result = containsDangerousKeys(
49-
(obj as Record<string, unknown>)[key],
50-
path ? `${path}.${key}` : key,
51-
);
52-
if (result) return result;
53-
}
54-
}
55-
56-
return null;
57-
}
11+
import { containsDangerousKeys } from '../shared/security.utils';
5812

5913
// ============================================================================
6014
// Custom Error

0 commit comments

Comments
 (0)