Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 14 additions & 42 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,9 @@ Coding Code 支持可插拔的 Markdown 技能包,扩展 Agent 在特定场景

## 什么是技能

技能是一组以 Markdown 编写的指令和资源,在 Agent 调用前注入到 system prompt 中。每个技能包含:
技能是一个包含 `SKILL.md` 的目录。发现阶段只读取少量元数据,具体内容由 Agent 在需要时通过文件工具读取。

- **instruction**:SKILL.md 的 Markdown 正文,作为技能指令注入
- **references**:附带的参考文件(代码片段、文档等)
- **scripts**:附带的脚本文件
- **assets**:附带的二进制资源
发现阶段保存技能名称、描述和 `SKILL.md` 的绝对路径。

---

Expand All @@ -29,19 +26,16 @@ Coding Code 支持可插拔的 Markdown 技能包,扩展 Agent 在特定场景
```
.codingcode/skills/
├── code-review/
│ ├── SKILL.md # 技能指令(必需)
│ ├── review-checklist.md # 参考文件
│ └── run-review.sh # 脚本文件
│ └── SKILL.md # 必需
└── api-design/
├── SKILL.md
└── openapi-template.yaml
└── SKILL.md
```

---

## SKILL.md 格式

SKILL.md 是纯 Markdown 文件,正文部分作为技能指令注入 system prompt
SKILL.md 使用 YAML front matter 提供发现元数据。正文不会在发现阶段读取

```markdown
# Code Review Skill
Expand All @@ -65,52 +59,30 @@ You are now performing a code review. Follow these steps:

```typescript
interface Skill {
readonly name: string; // 技能名称
readonly description: string; // 技能描述
readonly instruction: string; // SKILL.md 的 Markdown body
readonly references: ReadonlyArray<{ // 参考文件
path: string;
content: string;
}>;
readonly scripts: ReadonlyArray<{ // 脚本文件
path: string;
content: string;
}>;
readonly assets: ReadonlyArray<{ // 二进制资源
path: string;
mimeType: string;
size: number;
}>;
readonly metadata: Record<string, unknown>; // 自定义元数据
readonly name: string;
readonly description: string;
readonly skillPath: string;
}
```

Agent 判断技能相关后,使用 `read_file` 读取 `skillPath`,再按需读取其他文件或执行脚本。

---

## 技能管理 API
## 技能列表 API

通过 `AgentClient` SDK 管理技能
通过 `AgentClient` SDK 读取技能元数据

```typescript
const client = await createHttpClient('http://localhost:8080');

// 列出所有技能
const skills = await client.listSkills();
// 返回:Array<{ name: string, description: string, enabled: boolean }>

// 启用/禁用技能
await client.toggleSkill({ name: 'code-review', enabled: true });
// 返回:Array<{ name: string, description: string, skillPath: string }>
```

也可通过 HTTP API:

| 路由 | 方法 | 说明 |
|------|------|------|
| `/api/settings/skills` | GET | 列出所有技能 |
| `/api/settings/skills/toggle` | POST | 启用/禁用技能 |

---

## 配置

技能的启用/禁用状态持久化在项目配置中。禁用的技能不会被注入 system prompt,但仍保留在技能目录中,可随时重新启用。
| `/api/settings/skills` | GET | 列出所有技能元数据 |
4 changes: 0 additions & 4 deletions packages/codingcode/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,6 @@ export async function createHttpClient(serverUrl: string): Promise<AgentClient>
return clients.settings.listSkills();
},

async toggleSkill(body: { name: string; enabled: boolean; cwd: string }) {
await clients.settings.toggleSkill(body);
},

async createMcpServer(server: McpServerConfig, { cwd }: { cwd: string }) {
await clients.settings.createMcpServer({ cwd, server });
},
Expand Down
7 changes: 1 addition & 6 deletions packages/codingcode/src/client/http/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ export interface SettingsClient {
createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise<void>;
updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise<void>;
deleteMcpServer(input: { cwd: string; name: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; enabled: boolean }>>;
toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; skillPath: string }>>;
listHooks(input: { cwd: string }): Promise<UserHookConfig[]>;
createHook(input: { cwd: string; hook: UserHookConfig }): Promise<void>;
updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise<void>;
Expand Down Expand Up @@ -124,10 +123,6 @@ export function createHttpSettingsClient(
return apiGet('/api/settings/skills');
},

async toggleSkill({ name, enabled, cwd }) {
await apiPost(`/api/settings/skills${qsCwd(cwd)}`, { name, enabled });
},

async listHooks({ cwd }) {
return apiGet(`/api/settings/hooks${qsCwd(cwd)}`);
},
Expand Down
3 changes: 1 addition & 2 deletions packages/codingcode/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ export interface AgentClient {
deleteMcpServer(name: string, query: { cwd: string }): Promise<void>;
setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise<void>;
resetMcpDisabled(body: { name: string; cwd: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; enabled: boolean }>>;
toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; skillPath: string }>>;
listHooks(query: { cwd: string }): Promise<UserHookConfig[]>;
setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise<void>;
resetHookDisabled(body: { name: string; cwd: string }): Promise<void>;
Expand Down
19 changes: 2 additions & 17 deletions packages/codingcode/src/direct/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ export interface SettingsClient {
createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise<void>;
updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise<void>;
deleteMcpServer(input: { cwd: string; name: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; enabled: boolean }>>;
toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise<void>;
listSkills(): Promise<Array<{ name: string; description: string; skillPath: string }>>;
listHooks(input: { cwd: string }): Promise<UserHookConfig[]>;
createHook(input: { cwd: string; hook: UserHookConfig }): Promise<void>;
updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise<void>;
Expand Down Expand Up @@ -385,21 +384,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient {
return rt.runPromise(
Effect.gen(function* () {
const skill = yield* SkillService;
return yield* skill.listWithStatus(process.cwd());
})
);
},

async toggleSkill({ name, enabled, cwd }) {
const skillCwd = cwd || process.cwd();
await rt.runPromise(
Effect.gen(function* () {
const skill = yield* SkillService;
if (enabled) {
yield* skill.enableSkill(skillCwd, name);
} else {
yield* skill.disableSkill(skillCwd, name);
}
return yield* skill.getAll(process.cwd());
})
);
},
Expand Down
23 changes: 3 additions & 20 deletions packages/codingcode/src/server/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ import {
resetProjectHookDisabledState,
} from '../../hooks/config.js';
import { setHookRuntimeEnabled } from '../../hooks/executor.js';
import {
setGlobalSkillDisabledState,
setProjectSkillDisabledState,
discoverGlobalSkillDirs,
discoverProjectSkillDirs,
} from '../../skills/source.js';
import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js';
import {
getMemoryConfig,
getAllTypesWithStatus,
Expand Down Expand Up @@ -471,7 +466,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise<Hono> {
const result = await runWithLayer(
Effect.gen(function* () {
const skill = yield* SkillService;
return yield* skill.listWithStatus(cwd);
return yield* skill.getAll(cwd);
})
);
const skills = result.ok ? result.value : [];
Expand All @@ -490,7 +485,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise<Hono> {
const result = await runWithLayer(
Effect.gen(function* () {
const skill = yield* SkillService;
return yield* skill.listWithStatus(cwd);
return yield* skill.getAll(cwd);
})
);
const skills = result.ok ? result.value : [];
Expand All @@ -508,17 +503,5 @@ export async function createSettingsRouter(rt: ManagedRt): Promise<Hono> {
);
});

settingsRouter.post('/skills', async (c) => {
const body = (await c.req.json()) as { name: string; enabled: boolean };
const rawCwd = c.req.query('cwd');
if (isGlobalCwd(rawCwd)) {
setGlobalSkillDisabledState(body.name, !body.enabled);
return c.json({ ok: true });
}
const cwd = resolveWorkspaceCwd(rawCwd);
setProjectSkillDisabledState(cwd, body.name, !body.enabled);
return c.json({ ok: true });
});

return settingsRouter;
}
67 changes: 10 additions & 57 deletions packages/codingcode/src/skills/loader.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,14 @@
import { statSync } from 'fs';
import { basename } from 'path';
import { basename, resolve } from 'path';
import type { Skill } from './types.js';
import { readSkillMd, readFileContent, getFilesInDir, getMimeType } from './source.js';
import { readSkillFrontMatter } from './source.js';

export function loadSkill(dirPath: string): Skill | null {
const parsed = readSkillMd(dirPath);
if (!parsed) return null;

const { frontMatter, body } = parsed;

const name = frontMatter.name || basename(dirPath);
const description = frontMatter.description || '';
const instruction = body;

// Extract metadata (everything except name and description)
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(frontMatter)) {
if (key !== 'name' && key !== 'description') {
metadata[key] = value;
}
}

// Load references
const refsDir = `${dirPath}/references`;
const refs: Array<{ path: string; content: string }> = [];
for (const refPath of getFilesInDir(refsDir)) {
const content = readFileContent(refPath);
if (content !== null) {
refs.push({ path: refPath, content });
}
}

// Load scripts
const scriptsDir = `${dirPath}/scripts`;
const scripts: Array<{ path: string; content: string }> = [];
for (const scriptPath of getFilesInDir(scriptsDir)) {
const content = readFileContent(scriptPath);
if (content !== null) {
scripts.push({ path: scriptPath, content });
}
}

// Load assets (metadata only, not binary content)
const assetsDir = `${dirPath}/assets`;
const assets: Array<{ path: string; mimeType: string; size: number }> = [];
for (const assetPath of getFilesInDir(assetsDir)) {
try {
const st = statSync(assetPath);
assets.push({
path: assetPath,
mimeType: getMimeType(assetPath),
size: st.size,
});
} catch {
// skip
}
}

return { name, description, instruction, references: refs, scripts, assets, metadata };
const frontMatter = readSkillFrontMatter(dirPath);
if (!frontMatter) return null;

return {
name: frontMatter.name || basename(dirPath),
description: frontMatter.description || '',
skillPath: resolve(dirPath, 'SKILL.md'),
};
}
37 changes: 5 additions & 32 deletions packages/codingcode/src/skills/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { Effect } from 'effect';
import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './source.js';
import { discoverSkillDirs } from './source.js';
import { loadSkill } from './loader.js';
import type { Skill } from './types.js';

function filterEnabled(projectPath: string, skills: Skill[]): Skill[] {
return skills.filter((s) => !resolveSkillDisabled(projectPath, s.name));
}

export class SkillService extends Effect.Service<SkillService>()('Skill', {
effect: Effect.gen(function* () {
const cachedByProject = new Map<string, Skill[]>();
Expand All @@ -25,21 +21,16 @@ export class SkillService extends Effect.Service<SkillService>()('Skill', {
}

return {
getAll: (projectPath: string) =>
Effect.sync(() => filterEnabled(projectPath, readAll(projectPath))),
getAll: (projectPath: string) => Effect.sync(() => readAll(projectPath)),

findByName: (projectPath: string, name: string) =>
Effect.sync(() => {
if (resolveSkillDisabled(projectPath, name)) return undefined;
return readAll(projectPath).find((s) => s.name === name);
}),
Effect.sync(() => readAll(projectPath).find((s) => s.name === name)),

select: (projectPath: string, query: string) =>
Effect.sync(() => {
const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/);
if (!match) return undefined;
const name = match[1]!;
if (resolveSkillDisabled(projectPath, name)) return undefined;
return readAll(projectPath).find((s) => s.name === name);
}),

Expand All @@ -49,10 +40,9 @@ export class SkillService extends Effect.Service<SkillService>()('Skill', {
matcher: (all: readonly Skill[], q: string) => Effect.Effect<string | undefined>
): Effect.Effect<Skill | undefined> =>
Effect.gen(function* () {
const all = filterEnabled(projectPath, readAll(projectPath));
const all = readAll(projectPath);
const name = yield* matcher(all, query);
if (!name) return undefined;
if (resolveSkillDisabled(projectPath, name)) return undefined;
return all.find((s) => s.name === name);
}),

Expand All @@ -62,29 +52,12 @@ export class SkillService extends Effect.Service<SkillService>()('Skill', {
let skill: Skill | undefined;
if (match) {
const name = match[1]!;
if (!resolveSkillDisabled(projectPath, name)) {
skill = readAll(projectPath).find((s) => s.name === name);
}
skill = readAll(projectPath).find((s) => s.name === name);
}
const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, '');
return [skill, actualQuery] as [Skill | undefined, string];
}),

disableSkill: (projectPath: string, name: string) =>
Effect.sync(() => setProjectSkillDisabledState(projectPath, name, true)),

enableSkill: (projectPath: string, name: string) =>
Effect.sync(() => setProjectSkillDisabledState(projectPath, name, false)),

listWithStatus: (projectPath: string) =>
Effect.sync(() =>
readAll(projectPath).map((s) => ({
name: s.name,
description: s.description,
enabled: !resolveSkillDisabled(projectPath, s.name),
}))
),

evictProject: (projectPath: string) =>
Effect.sync(() => {
cachedByProject.delete(projectPath);
Expand Down
Loading
Loading