diff --git a/docs/skills.md b/docs/skills.md index b71d0c9..e560020 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -6,12 +6,9 @@ Coding Code 支持可插拔的 Markdown 技能包,扩展 Agent 在特定场景 ## 什么是技能 -技能是一组以 Markdown 编写的指令和资源,在 Agent 调用前注入到 system prompt 中。每个技能包含: +技能是一个包含 `SKILL.md` 的目录。发现阶段只读取少量元数据,具体内容由 Agent 在需要时通过文件工具读取。 -- **instruction**:SKILL.md 的 Markdown 正文,作为技能指令注入 -- **references**:附带的参考文件(代码片段、文档等) -- **scripts**:附带的脚本文件 -- **assets**:附带的二进制资源 +发现阶段保存技能名称、描述和 `SKILL.md` 的绝对路径。 --- @@ -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 @@ -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; // 自定义元数据 + 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 | 列出所有技能元数据 | diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 024b322..9f7629b 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -226,10 +226,6 @@ export async function createHttpClient(serverUrl: string): Promise 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 }); }, diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 2fa5e13..5768be6 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -24,8 +24,7 @@ export interface SettingsClient { createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; - listSkills(): Promise>; - toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + listSkills(): Promise>; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; @@ -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)}`); }, diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 131333f..3254257 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -77,8 +77,7 @@ export interface AgentClient { deleteMcpServer(name: string, query: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; - listSkills(): Promise>; - toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + listSkills(): Promise>; listHooks(query: { cwd: string }): Promise; setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index 50361fb..f9b282c 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -65,8 +65,7 @@ export interface SettingsClient { createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; - listSkills(): Promise>; - toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + listSkills(): Promise>; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; @@ -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()); }) ); }, diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 4f5125a..a584371 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -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, @@ -471,7 +466,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { 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 : []; @@ -490,7 +485,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { 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 : []; @@ -508,17 +503,5 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { ); }); - 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; } diff --git a/packages/codingcode/src/skills/loader.ts b/packages/codingcode/src/skills/loader.ts index b38a9a7..6c142b4 100644 --- a/packages/codingcode/src/skills/loader.ts +++ b/packages/codingcode/src/skills/loader.ts @@ -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 = {}; - 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'), + }; } diff --git a/packages/codingcode/src/skills/service.ts b/packages/codingcode/src/skills/service.ts index 14e9aac..7a3c3b9 100644 --- a/packages/codingcode/src/skills/service.ts +++ b/packages/codingcode/src/skills/service.ts @@ -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()('Skill', { effect: Effect.gen(function* () { const cachedByProject = new Map(); @@ -25,21 +21,16 @@ export class SkillService extends Effect.Service()('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); }), @@ -49,10 +40,9 @@ export class SkillService extends Effect.Service()('Skill', { matcher: (all: readonly Skill[], q: string) => Effect.Effect ): Effect.Effect => 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); }), @@ -62,29 +52,12 @@ export class SkillService extends Effect.Service()('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); diff --git a/packages/codingcode/src/skills/source.ts b/packages/codingcode/src/skills/source.ts index 35c9104..d669f56 100644 --- a/packages/codingcode/src/skills/source.ts +++ b/packages/codingcode/src/skills/source.ts @@ -1,13 +1,11 @@ -import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs'; +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { join, basename } from 'path'; import { homedir } from 'os'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { createDisabledStore } from '@codingcode/infra/disabled-store'; +import { parse as parseYaml } from 'yaml'; interface SkillFrontMatter { - name: string; - description: string; - [key: string]: unknown; + name?: string; + description?: string; } export interface SkillDirectory { @@ -45,81 +43,25 @@ export function discoverSkillDirs(projectRoot: string): SkillDirectory[] { return dirs; } -/** Parse SKILL.md: returns { frontMatter, body } */ -export function readSkillMd( - dirPath: string -): { frontMatter: SkillFrontMatter; body: string } | null { +/** Parse only the SKILL.md front matter used for skill discovery. */ +export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null { const skillMdPath = join(dirPath, 'SKILL.md'); if (!existsSync(skillMdPath)) return null; const raw = readFileSync(skillMdPath, 'utf8'); - // Parse YAML front matter between --- delimiters - const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); if (!match) { - // No front matter: use directory name as skill name - return { - frontMatter: { name: basename(dirPath), description: '' }, - body: raw.trim(), - }; + return { name: basename(dirPath), description: '' }; } const frontMatter = parseYaml(match[1]!) as SkillFrontMatter; - const body = match[2]!.trim(); - - return { frontMatter, body }; -} - -export function readFileContent(filePath: string): string | null { - try { - return readFileSync(filePath, 'utf8'); - } catch { - return null; - } -} - -export function getFilesInDir(dirPath: string): string[] { - if (!existsSync(dirPath)) return []; - return readdirSync(dirPath) - .map((f) => join(dirPath, f)) - .filter((f) => statSync(f).isFile()); -} - -export function getMimeType(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase(); - const map: Record = { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - svg: 'image/svg+xml', - pdf: 'application/pdf', - tsx: 'text/typescript-jsx', - ts: 'text/typescript', - js: 'text/javascript', - json: 'application/json', - py: 'text/x-python', - html: 'text/html', - css: 'text/css', - md: 'text/markdown', - txt: 'text/plain', + return { + name: frontMatter.name, + description: frontMatter.description, }; - return map[ext ?? ''] ?? 'application/octet-stream'; } -// ---- Skill disabled state ---- - -const skillDisabledStore = createDisabledStore({ - globalKeyPath: ['skills', 'disabledSkills'], - getGlobalConfigDir: () => join(homedir(), '.codingcode'), -}); -export const getGlobalSkillDisabledState = skillDisabledStore.getGlobal; -export const setGlobalSkillDisabledState = skillDisabledStore.setGlobal; -export const getProjectSkillDisabledState = skillDisabledStore.getProject; -export const setProjectSkillDisabledState = skillDisabledStore.setProject; -export const resetProjectSkillDisabledState = skillDisabledStore.resetProject; -export const resolveSkillDisabled = skillDisabledStore.resolve; - // ---- 辅助函数:分别获取全局/项目级 Skill 目录 ---- export function discoverGlobalSkillDirs(): SkillDirectory[] { diff --git a/packages/codingcode/src/skills/types.ts b/packages/codingcode/src/skills/types.ts index e20a30b..354a830 100644 --- a/packages/codingcode/src/skills/types.ts +++ b/packages/codingcode/src/skills/types.ts @@ -1,23 +1,8 @@ export interface Skill { readonly name: string; readonly description: string; - /** Markdown body of SKILL.md (injected into system prompt) */ - readonly instruction: string; - readonly references: ReadonlyArray<{ - readonly path: string; - readonly content: string; - }>; - readonly scripts: ReadonlyArray<{ - readonly path: string; - readonly content: string; - }>; - readonly assets: ReadonlyArray<{ - readonly path: string; - readonly mimeType: string; - readonly size: number; - }>; - /** Extra fields from YAML front matter (license, cursor-globs, etc.) */ - readonly metadata: Record; + /** Absolute path to the skill's SKILL.md file. */ + readonly skillPath: string; } export interface SkillServiceApi { diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 68994c7..324baed 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -326,39 +326,6 @@ describe('agentLoop', () => { expect(textEvents.map((e: any) => e.text)).toEqual(['\n[Using: readFile]\n']); }); - it('should not pass skill instructions into the system prompt sent to LLM', async () => { - let capturedSystem: string | undefined; - const mockLlm = { - completeStream: (params: any) => { - capturedSystem = params.system; - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'done' })), - }; - }, - }; - - const deps = makeDeps(); - const opts = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - } as any; - opts.skillInstruction = 'Use strict TypeScript'; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q - ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - - expect(capturedSystem).not.toContain('## Skill Instructions'); - expect(capturedSystem).not.toContain('Use strict TypeScript'); - }); - it('should yield a single maxSteps error and a single turn.end hook when maxSteps is exhausted', async () => { const mockLlm = { completeStream: (_params: any) => ({ diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts index 82de2ec..27f33ad 100644 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -54,15 +54,6 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('Always use TypeScript strict mode.'); }); - it('does not append skill instructions to the system prompt', () => { - const prompt = buildSystemPrompt({ - cwd: '/x', - platform: 'linux', - shell: 'bash', - }); - expect(prompt).not.toContain('## Skill Instructions'); - }); - it('plan profile prompt limits implementation work to submit_plan', () => { const prompt = buildSystemPrompt({ cwd: '/x', diff --git a/packages/codingcode/test/client/missing-methods.test.ts b/packages/codingcode/test/client/missing-methods.test.ts index 5170f98..01d138a 100644 --- a/packages/codingcode/test/client/missing-methods.test.ts +++ b/packages/codingcode/test/client/missing-methods.test.ts @@ -31,9 +31,6 @@ const TestLayer = Layer.mergeAll( select: () => Effect.succeed(undefined), selectImplicit: () => Effect.succeed(undefined), extractSkill: () => Effect.succeed([undefined, '']), - enableSkill: () => Effect.void, - disableSkill: () => Effect.void, - listWithStatus: () => Effect.succeed([]), evictProject: () => Effect.void, } as any), Layer.succeed(MemoryService, { diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index d546f2e..c82ee8c 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -113,9 +113,6 @@ const MockSkillLayer = Layer.succeed(SkillService, { extractSkill: vi.fn((_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]) ), - disableSkill: vi.fn(() => Effect.void), - enableSkill: vi.fn(() => Effect.void), - listWithStatus: vi.fn(() => Effect.succeed([])), evictProject: vi.fn(() => Effect.void), } as any); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index ed9f5a8..2a844ce 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -101,9 +101,6 @@ const MockSkillLayer = Layer.succeed(SkillService, { select: () => Effect.succeed(undefined), selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - enableSkill: () => Effect.void, - disableSkill: () => Effect.void, - listWithStatus: () => Effect.succeed([]), evictProject: () => Effect.void, } as any); diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 5e7504b..b7399b4 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -55,9 +55,6 @@ const MockSkillLayer = Layer.succeed(SkillService, { select: () => Effect.succeed(undefined), selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - enableSkill: () => Effect.void, - disableSkill: () => Effect.void, - listWithStatus: () => Effect.succeed([]), evictProject: () => Effect.void, } as any); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index b57d86c..9f9b155 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -104,9 +104,6 @@ const MockSkillLayer = Layer.succeed(SkillService, { select: () => Effect.succeed(undefined), selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - enableSkill: () => Effect.void, - disableSkill: () => Effect.void, - listWithStatus: () => Effect.succeed([]), evictProject: () => Effect.void, } as any); diff --git a/packages/codingcode/test/skills/index.test.ts b/packages/codingcode/test/skills/index.test.ts index 9f71ab6..a2937c5 100644 --- a/packages/codingcode/test/skills/index.test.ts +++ b/packages/codingcode/test/skills/index.test.ts @@ -68,8 +68,35 @@ Test the skill system. const basic = skills.find((s) => s.name === 'test-basic'); expect(basic).toBeDefined(); expect(basic!.description).toBe('A basic test skill for unit testing'); - expect(basic!.instruction).toContain('Test the skill system'); - expect(basic!.metadata.version).toBe('1.0.0'); + expect(basic!.skillPath).toBe(join(TEST_CODINGCODE_DIR, 'skills', 'test-basic', 'SKILL.md')); + expect(basic).toEqual({ + name: 'test-basic', + description: 'A basic test skill for unit testing', + skillPath: join(TEST_CODINGCODE_DIR, 'skills', 'test-basic', 'SKILL.md'), + }); + }); + + it('does not load skill body or attachment files during discovery', () => { + const skillDir = join(TEST_CODINGCODE_DIR, 'skills', 'metadata-only'); + mkdirSync(join(skillDir, 'references'), { recursive: true }); + mkdirSync(join(skillDir, 'scripts'), { recursive: true }); + mkdirSync(join(skillDir, 'assets'), { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: metadata-only\ndescription: Metadata only\n---\nsecret body\n` + ); + writeFileSync(join(skillDir, 'references', 'guide.md'), 'secret reference'); + writeFileSync(join(skillDir, 'scripts', 'run.sh'), 'secret script'); + writeFileSync(join(skillDir, 'assets', 'image.bin'), Buffer.from([0, 1, 2, 3])); + + runWithSkill((s) => s.evictProject(TEST_ROOT)); + const skill = runWithSkill((s) => s.findByName(TEST_ROOT, 'metadata-only')); + + expect(skill).toEqual({ + name: 'metadata-only', + description: 'Metadata only', + skillPath: join(skillDir, 'SKILL.md'), + }); }); it('should cache skills per session (added files not visible without new session)', () => { @@ -144,30 +171,4 @@ Testing kebab-case name parsing. expect(matched!.name).toBe('test-basic'); expect(cleanQuery).toBe('do the refactoring work'); }); - - it('disableSkill should hide skill from findByName and select', () => { - runWithSkill((s) => s.disableSkill(TEST_ROOT, 'test-basic')); - const byName = runWithSkill((s) => s.findByName(TEST_ROOT, 'test-basic')); - const selected = runWithSkill((s) => s.select(TEST_ROOT, '@test-basic do something')); - expect(byName).toBeUndefined(); - expect(selected).toBeUndefined(); - }); - - it('enableSkill should restore skill visibility after disable', () => { - runWithSkill((s) => s.disableSkill(TEST_ROOT, 'test-basic')); - runWithSkill((s) => s.enableSkill(TEST_ROOT, 'test-basic')); - const found = runWithSkill((s) => s.findByName(TEST_ROOT, 'test-basic')); - expect(found).toBeDefined(); - expect(found!.name).toBe('test-basic'); - }); - - it('listWithStatus should reflect enabled/disabled state', () => { - const before = runWithSkill((s) => s.listWithStatus(TEST_ROOT)); - runWithSkill((s) => s.disableSkill(TEST_ROOT, 'test-basic')); - const after = runWithSkill((s) => s.listWithStatus(TEST_ROOT)); - const beforeEntry = before.find((s) => s.name === 'test-basic'); - const afterEntry = after.find((s) => s.name === 'test-basic'); - expect(beforeEntry?.enabled).toBe(true); - expect(afterEntry?.enabled).toBe(false); - }); }); diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index a67584e..596eaee 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -198,16 +198,18 @@ export function deleteMcpServer(cwd: string | undefined, name: string): Promise< // ---- Settings: Skills ---- -export function listSkills( - _cwd?: string -): Promise> { +export function listSkills(_cwd?: string): Promise< + Array<{ + name: string; + description: string; + skillPath: string; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; + }> +> { return clients.settings.listSkills() as any; } -export function toggleSkill(name: string, enabled: boolean, cwd?: string): Promise { - return clients.settings.toggleSkill({ name, enabled, cwd: cwd ?? '' }); -} - // ---- Settings: Hooks ---- export function listHooks(cwd?: string): Promise { diff --git a/packages/desktop/src/settings/SkillPanel.tsx b/packages/desktop/src/settings/SkillPanel.tsx index 286d5a2..a0568ef 100644 --- a/packages/desktop/src/settings/SkillPanel.tsx +++ b/packages/desktop/src/settings/SkillPanel.tsx @@ -1,12 +1,11 @@ import { useState, useEffect } from 'react'; -import Toggle from './Toggle'; import { useWorkspaceStore } from '../stores/workspace.store'; -import { listSkills, toggleSkill } from '../lib/core-api'; +import { listSkills } from '../lib/core-api'; interface SkillEntry { name: string; description: string; - disabled: boolean; + skillPath: string; source?: 'global' | 'project'; hasProjectOverride?: boolean; } @@ -17,78 +16,60 @@ export default function SkillPanel({ global: isGlobal }: { global?: boolean }) { const rootPath = useWorkspaceStore((s) => s.rootPath); const cwd = isGlobal ? undefined : rootPath; - const load = async () => { - setLoading(true); - try { - const data = await listSkills(cwd); - setSkills(data ?? []); - } catch { - setSkills([]); - } finally { - setLoading(false); - } - }; - useEffect(() => { - load(); + setLoading(true); + listSkills(cwd) + .then((data) => setSkills(data ?? [])) + .catch(() => setSkills([])) + .finally(() => setLoading(false)); }, [rootPath, isGlobal]); - const toggle = async (name: string, disabled: boolean) => { - await toggleSkill(name, !disabled, cwd); - setSkills((prev) => prev.map((s) => (s.name === name ? { ...s, disabled } : s))); - }; - - if (loading) { + if (loading) return
加载中…
; - } + if (skills.length === 0) + return ( +
+ 未找到 Skill +
+ ); return (
- {skills.length === 0 ? ( -
- 未找到 Skill -
- - 在 .codingcode/skills/ 目录下创建 skill 文件夹以添加 - -
- ) : ( -
- {skills.map((s) => ( -
-
-
- {s.name} - {s.source === 'global' && ( - - 全局 - - )} - {s.source === 'project' && ( - - 项目 - - )} - {s.hasProjectOverride && ( - - 覆盖全局 - - )} -
- {s.description && ( -
- {s.description} -
- )} +
+ {skills.map((skill) => ( +
+
+ {skill.name} + {skill.source === 'global' && ( + + 全局 + + )} + {skill.source === 'project' && ( + + 项目 + + )} + {skill.hasProjectOverride && ( + + 覆盖全局 + + )} +
+ {skill.description && ( +
+ {skill.description}
- toggle(s.name, !v)} /> + )} +
+ {skill.skillPath}
- ))} -
- )} +
+ ))} +
); } diff --git a/packages/desktop/test/rollback-usage-reset.test.ts b/packages/desktop/test/rollback-usage-reset.test.ts index 8c71b90..6cec314 100644 --- a/packages/desktop/test/rollback-usage-reset.test.ts +++ b/packages/desktop/test/rollback-usage-reset.test.ts @@ -51,7 +51,6 @@ vi.mock('../src/lib/core-api', () => ({ updateMcpServer: vi.fn(), deleteMcpServer: vi.fn(), listSkills: vi.fn(), - toggleSkill: vi.fn(), listHooks: vi.fn(), createHook: vi.fn(), updateHook: vi.fn(), diff --git a/packages/desktop/test/thread-delete.test.ts b/packages/desktop/test/thread-delete.test.ts index 0b0c145..a818b55 100644 --- a/packages/desktop/test/thread-delete.test.ts +++ b/packages/desktop/test/thread-delete.test.ts @@ -51,7 +51,6 @@ vi.mock('../src/lib/core-api', () => ({ updateMcpServer: vi.fn(), deleteMcpServer: vi.fn(), listSkills: vi.fn(), - toggleSkill: vi.fn(), listHooks: vi.fn(), createHook: vi.fn(), updateHook: vi.fn(), diff --git a/packages/tui/src/commands/registry.ts b/packages/tui/src/commands/registry.ts index 2040edf..c1a0139 100644 --- a/packages/tui/src/commands/registry.ts +++ b/packages/tui/src/commands/registry.ts @@ -38,7 +38,7 @@ export const COMMAND_REGISTRY = { quick: true, }, mcp: { name: 'mcp', description: '管理 MCP 服务器', usage: '/mcp', title: 'MCP 服务器' }, - skill: { name: 'skill', description: '管理 Skill', usage: '/skill', title: 'Skill' }, + skill: { name: 'skill', description: '查看 Skill', usage: '/skill', title: 'Skill' }, approve: { name: 'approve', description: '切换工具审批模式', diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index 9cb2bef..9f6bafe 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -445,24 +445,14 @@ export function App({ client }: AppProps) { panel.skills.length === 0 ? [{ label: '无已加载的 Skill', value: '' }] : panel.skills.map((s) => ({ - label: `${s.enabled ? '✓' : '✗'} ${s.name} ${s.description}`, + label: `${s.name} ${s.description}`, value: s.name, + description: s.skillPath, })) } - onSelect={async (value) => { - if (!value) return; - const skill = panel.skills.find((s) => s.name === value); - if (!skill) return; - try { - await client.toggleSkill({ name: value, enabled: !skill.enabled, cwd: '' }); - const updated = await client.listSkills(); - setPanel({ type: 'skill', skills: updated }); - } catch { - setPanel({ type: 'none' }); - } - }} + onSelect={() => setPanel({ type: 'none' })} onCancel={() => setPanel({ type: 'none' })} - width={Math.min(70, width - 4)} + width={Math.min(80, width - 4)} /> )} {panel.type === 'permission' && ( diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 19710f4..e2803f7 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -23,8 +23,7 @@ export interface TuiClient { listSessions(): Promise; getMcpStatus(query: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - listSkills(): Promise; - toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + listSkills(): Promise>; getPermissionMode(input: { sessionId: string; cwd: string; @@ -67,7 +66,6 @@ export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiC getMcpStatus: (query) => settings.getMcpStatus(query), setMcpDisabled: (body) => settings.setMcpDisabled(body), listSkills: () => settings.listSkills(), - toggleSkill: (body) => settings.toggleSkill(body), getPermissionMode: (input) => settings.getGlobalPermissionMode(input), setPermissionMode: (input) => settings.setGlobalPermissionMode(input), resumeSession: (sid) => sessions.resumeSession({ sessionId: sid, cwd: '' }), diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 453e4d0..2167024 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -36,7 +36,7 @@ export interface McpServerStatus { export interface SkillStatus { name: string; description: string; - enabled: boolean; + skillPath: string; } export type PanelState = diff --git a/packages/tui/test/components/App.test.tsx b/packages/tui/test/components/App.test.tsx index 1c2aa1b..d96913b 100644 --- a/packages/tui/test/components/App.test.tsx +++ b/packages/tui/test/components/App.test.tsx @@ -47,7 +47,6 @@ const client = { getMcpStatus: vi.fn(), setMcpDisabled: vi.fn(), listSkills: vi.fn(), - toggleSkill: vi.fn(), getPermissionMode: vi.fn(), setPermissionMode: vi.fn(), resumeSession: vi.fn(),