From f2588b664a3ec6d04d7d164501496582ccb27def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Sun, 21 Dec 2025 00:11:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(skills):=20=E6=B7=BB=E5=8A=A0=20Skills?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81=20Pro?= =?UTF-8?q?mpt=20=E6=89=A9=E5=B1=95=E5=92=8C=E5=B7=A5=E5=85=B7=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- src/agent/Agent.ts | 131 ++++++++++++- src/prompts/builder.ts | 28 ++- src/prompts/default.ts | 10 + src/skills/SkillLoader.ts | 195 +++++++++++++++++++ src/skills/SkillRegistry.ts | 233 +++++++++++++++++++++++ src/skills/index.ts | 54 ++++++ src/skills/injectSkillsMetadata.ts | 74 +++++++ src/skills/types.ts | 100 ++++++++++ src/slash-commands/index.ts | 2 + src/slash-commands/skills.ts | 36 ++++ src/slash-commands/types.ts | 3 +- src/store/types.ts | 3 +- src/tools/builtin/system/skill.ts | 92 +++++++-- src/ui/components/BladeInterface.tsx | 8 + src/ui/components/PermissionsManager.tsx | 3 + src/ui/components/SkillsManager.tsx | 142 ++++++++++++++ src/ui/hooks/useCommandHandler.ts | 3 + 18 files changed, 1100 insertions(+), 22 deletions(-) create mode 100644 src/skills/SkillLoader.ts create mode 100644 src/skills/SkillRegistry.ts create mode 100644 src/skills/index.ts create mode 100644 src/skills/injectSkillsMetadata.ts create mode 100644 src/skills/types.ts create mode 100644 src/slash-commands/skills.ts create mode 100644 src/ui/components/SkillsManager.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6a6e9b9a..6a3fa6d5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -71,7 +71,10 @@ "WebFetch(domain:www.npmjs.com)", "WebFetch(domain:deepwiki.com)", "Bash(timeout 3 npm run start:*)", - "Bash(gtimeout 3 npm run start:*)" + "Bash(gtimeout 3 npm run start:*)", + "WebFetch(domain:leehanchung.github.io)", + "WebFetch(domain:simonwillison.net)", + "WebFetch(domain:scottspence.com)" ], "deny": [], "ask": [], diff --git a/src/agent/Agent.ts b/src/agent/Agent.ts index f99192ae..59bc9cdc 100644 --- a/src/agent/Agent.ts +++ b/src/agent/Agent.ts @@ -58,10 +58,21 @@ import type { LoopResult, UserMessageContent, } from './types.js'; +import { discoverSkills, injectSkillsMetadata } from '../skills/index.js'; // 创建 Agent 专用 Logger const logger = createLogger(LogCategory.AGENT); +/** + * Skill 执行上下文 + * 用于跟踪当前活动的 Skill 及其工具限制 + */ +interface SkillExecutionContext { + skillName: string; + allowedTools?: string[]; + basePath: string; +} + export class Agent { private config: BladeConfig; private runtimeOptions: AgentOptions; @@ -76,6 +87,9 @@ export class Agent { private executionEngine!: ExecutionEngine; private attachmentCollector?: AttachmentCollector; + // Skill 执行上下文(用于 allowed-tools 限制) + private activeSkillContext?: SkillExecutionContext; + constructor( config: BladeConfig, runtimeOptions: AgentOptions = {}, @@ -171,7 +185,10 @@ export class Agent { // 3. 加载 subagent 配置 await this.loadSubagents(); - // 4. 初始化核心组件 + // 4. 发现并注册 Skills + await this.discoverSkills(); + + // 5. 初始化核心组件 // 获取当前模型配置(从 Store) const modelConfig = getCurrentModel(); if (!modelConfig) { @@ -468,7 +485,11 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl // 根据 permissionMode 决定工具暴露策略(单一信息源:ToolRegistry.getFunctionDeclarationsByMode) const registry = this.executionPipeline.getRegistry(); const permissionMode = context.permissionMode as PermissionMode | undefined; - const tools = registry.getFunctionDeclarationsByMode(permissionMode); + let rawTools = registry.getFunctionDeclarationsByMode(permissionMode); + // 注入 Skills 元数据到 Skill 工具的 占位符 + rawTools = injectSkillsMetadata(rawTools); + // 应用 Skill 的 allowed-tools 限制(如果有活动的 Skill) + const tools = this.applySkillToolRestrictions(rawTools); const isPlanMode = permissionMode === PermissionMode.PLAN; if (isPlanMode) { @@ -1028,6 +1049,24 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl ); } + // 如果是 Skill 工具,设置执行上下文(用于 allowed-tools 限制) + if (toolCall.function.name === 'Skill' && result.success && result.metadata) { + const metadata = result.metadata as Record; + if (metadata.skillName) { + this.activeSkillContext = { + skillName: metadata.skillName as string, + allowedTools: metadata.allowedTools as string[] | undefined, + basePath: (metadata.basePath as string) || '', + }; + logger.debug( + `🎯 Skill "${this.activeSkillContext.skillName}" activated` + + (this.activeSkillContext.allowedTools + ? ` with allowed tools: ${this.activeSkillContext.allowedTools.join(', ')}` + : '') + ); + } + } + // 添加工具执行结果到消息历史 // 优先使用 llmContent(为 LLM 准备的详细内容),displayContent 仅用于终端显示 let toolResultContent = result.success @@ -1673,6 +1712,92 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl } } + /** + * 发现并注册 Skills + * Skills 是动态 Prompt 扩展机制,允许 AI 根据用户请求自动调用专业能力 + */ + private async discoverSkills(): Promise { + try { + const result = await discoverSkills({ + cwd: process.cwd(), + }); + + if (result.skills.length > 0) { + logger.debug( + `✅ Discovered ${result.skills.length} skills: ${result.skills.map((s) => s.name).join(', ')}` + ); + } else { + logger.debug('📦 No skills configured'); + } + + // 记录发现过程中的错误(不阻塞初始化) + for (const error of result.errors) { + logger.warn(`⚠️ Skill loading error at ${error.path}: ${error.error}`); + } + } catch (error) { + logger.warn('Failed to discover skills:', error); + // 不抛出错误,允许 Agent 继续初始化 + } + } + + /** + * 应用 Skill 的 allowed-tools 限制 + * 如果有活动的 Skill 且定义了 allowed-tools,则过滤可用工具列表 + * + * @param tools - 原始工具列表 + * @returns 过滤后的工具列表 + */ + private applySkillToolRestrictions( + tools: import('../tools/types/index.js').FunctionDeclaration[] + ): import('../tools/types/index.js').FunctionDeclaration[] { + // 如果没有活动的 Skill,或者 Skill 没有定义 allowed-tools,返回原始工具列表 + if (!this.activeSkillContext?.allowedTools) { + return tools; + } + + const allowedTools = this.activeSkillContext.allowedTools; + logger.debug( + `🔒 Applying Skill tool restrictions: ${allowedTools.join(', ')}` + ); + + // 过滤工具列表,只保留 allowed-tools 中指定的工具 + const filteredTools = tools.filter((tool) => { + // 检查工具名称是否在 allowed-tools 列表中 + // 支持精确匹配和通配符模式(如 Bash(git:*)) + return allowedTools.some((allowed) => { + // 精确匹配 + if (allowed === tool.name) { + return true; + } + + // 通配符匹配:Bash(git:*) 匹配 Bash + const match = allowed.match(/^(\w+)\(.*\)$/); + if (match && match[1] === tool.name) { + return true; + } + + return false; + }); + }); + + logger.debug( + `🔒 Filtered tools: ${filteredTools.map((t) => t.name).join(', ')} (${filteredTools.length}/${tools.length})` + ); + + return filteredTools; + } + + /** + * 清除 Skill 执行上下文 + * 当 Skill 执行完成或需要重置时调用 + */ + public clearSkillContext(): void { + if (this.activeSkillContext) { + logger.debug(`🎯 Skill "${this.activeSkillContext.skillName}" deactivated`); + this.activeSkillContext = undefined; + } + } + /** * 处理 @ 文件提及(支持纯文本和多模态消息) * 从用户消息中提取 @ 提及,读取文件内容,并追加到消息 @@ -1781,7 +1906,7 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl } /** - * 处理 @ 文件提及(纯文本版本) + * 处理 @ 文件提及 * 从用户消息中提取 @ 提及,读取文件内容,并追加到消息 * * @param message - 原始用户消息 diff --git a/src/prompts/builder.ts b/src/prompts/builder.ts index a941641b..a5ca9acb 100644 --- a/src/prompts/builder.ts +++ b/src/prompts/builder.ts @@ -16,9 +16,13 @@ import { promises as fs } from 'fs'; import path from 'path'; import { PermissionMode } from '../config/types.js'; +import { getSkillRegistry } from '../skills/index.js'; import { getEnvironmentContext } from '../utils/environment.js'; import { DEFAULT_SYSTEM_PROMPT, PLAN_MODE_SYSTEM_PROMPT } from './default.js'; +/** available_skills 占位符的正则表达式 */ +const AVAILABLE_SKILLS_REGEX = /\s*<\/available_skills>/; + /** * 提示词构建选项 */ @@ -141,11 +145,33 @@ export async function buildSystemPrompt( } // 组合各部分 - const prompt = parts.join('\n\n---\n\n'); + let prompt = parts.join('\n\n---\n\n'); + + // 注入 Skills 元数据到 占位符 + prompt = injectSkillsToPrompt(prompt); return { prompt, sources }; } +/** + * 注入 Skills 列表到系统提示的 占位符 + */ +function injectSkillsToPrompt(prompt: string): string { + const registry = getSkillRegistry(); + const skillsList = registry.generateAvailableSkillsList(); + + // 如果没有 skills,保持占位符为空(但保留标签结构) + if (!skillsList) { + return prompt; + } + + // 替换占位符 + return prompt.replace( + AVAILABLE_SKILLS_REGEX, + `\n${skillsList}\n` + ); +} + /** * 加载项目 BLADE.md 配置 */ diff --git a/src/prompts/default.ts b/src/prompts/default.ts index 012000a8..f09f81ac 100644 --- a/src/prompts/default.ts +++ b/src/prompts/default.ts @@ -140,6 +140,16 @@ The user will primarily request you perform software engineering tasks. This inc - You can call multiple tools in a single response. Make independent tool calls in parallel. If calls depend on previous results, run them sequentially. Never use placeholders or guess missing parameters. - Use specialized tools instead of bash commands: Read for files, Edit for editing, Write for creating. Reserve Bash for system commands only. +## Skills +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to invoke skills: +- Use the Skill tool with the skill name +- Example: \`skill: "commit-message"\` to generate commit messages + + + + ## Code References When referencing specific functions or pieces of code include the pattern \`file_path:line_number\` to allow the user to easily navigate to the source code location. diff --git a/src/skills/SkillLoader.ts b/src/skills/SkillLoader.ts new file mode 100644 index 00000000..0fd337f5 --- /dev/null +++ b/src/skills/SkillLoader.ts @@ -0,0 +1,195 @@ +/** + * SkillLoader - SKILL.md 文件解析器 + * + * 负责解析 SKILL.md 文件的 YAML 前置数据和 Markdown 正文内容。 + * 支持 Progressive Disclosure:可以只加载元数据,或加载完整内容。 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import type { SkillContent, SkillMetadata, SkillParseResult } from './types.js'; + +/** YAML 前置数据的分隔符 */ +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +/** Skill 名称验证:小写字母、数字、连字符,≤64字符 */ +const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$/; + +/** 描述最大长度 */ +const MAX_DESCRIPTION_LENGTH = 1024; + +/** + * 解析 SKILL.md 的 YAML 前置数据 + */ +interface RawFrontmatter { + name?: string; + description?: string; + 'allowed-tools'?: string | string[]; + version?: string; +} + +/** + * 验证并规范化 allowed-tools 字段 + */ +function parseAllowedTools(raw: string | string[] | undefined): string[] | undefined { + if (!raw) return undefined; + + if (typeof raw === 'string') { + // 支持逗号分隔的字符串格式:'Read, Grep, Glob' + return raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + } + + if (Array.isArray(raw)) { + return raw.map((t) => String(t).trim()).filter(Boolean); + } + + return undefined; +} + +/** + * 验证 Skill 元数据 + */ +function validateMetadata( + frontmatter: RawFrontmatter, + filePath: string +): { valid: true; metadata: Omit } | { valid: false; error: string } { + // 验证 name + if (!frontmatter.name) { + return { valid: false, error: 'Missing required field: name' }; + } + if (!NAME_REGEX.test(frontmatter.name)) { + return { + valid: false, + error: `Invalid name "${frontmatter.name}": must be lowercase letters, numbers, and hyphens only, 1-64 characters`, + }; + } + + // 验证 description + if (!frontmatter.description) { + return { valid: false, error: 'Missing required field: description' }; + } + if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) { + return { + valid: false, + error: `Description too long: ${frontmatter.description.length} characters (max ${MAX_DESCRIPTION_LENGTH})`, + }; + } + + return { + valid: true, + metadata: { + name: frontmatter.name, + description: frontmatter.description.trim(), + allowedTools: parseAllowedTools(frontmatter['allowed-tools']), + version: frontmatter.version, + }, + }; +} + +/** + * 解析 SKILL.md 文件内容 + */ +export function parseSkillContent( + content: string, + filePath: string, + source: 'user' | 'project' +): SkillParseResult { + // 匹配 YAML 前置数据 + const match = content.match(FRONTMATTER_REGEX); + if (!match) { + return { + success: false, + error: 'Invalid SKILL.md format: missing YAML frontmatter (must start with ---)', + }; + } + + const [, yamlContent, markdownContent] = match; + + // 解析 YAML + let frontmatter: RawFrontmatter; + try { + frontmatter = parseYaml(yamlContent) as RawFrontmatter; + } catch (e) { + return { + success: false, + error: `Failed to parse YAML frontmatter: ${e instanceof Error ? e.message : String(e)}`, + }; + } + + // 验证元数据 + const validation = validateMetadata(frontmatter, filePath); + if (!validation.valid) { + return { + success: false, + error: validation.error, + }; + } + + const basePath = path.dirname(filePath); + + return { + success: true, + content: { + metadata: { + ...validation.metadata, + path: filePath, + basePath, + source, + }, + instructions: markdownContent.trim(), + }, + }; +} + +/** + * 从文件加载 Skill(仅元数据) + */ +export async function loadSkillMetadata( + filePath: string, + source: 'user' | 'project' +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return parseSkillContent(content, filePath, source); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return { + success: false, + error: `File not found: ${filePath}`, + }; + } + return { + success: false, + error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`, + }; + } +} + +/** + * 加载完整 Skill 内容 + */ +export async function loadSkillContent(metadata: SkillMetadata): Promise { + try { + const content = await fs.readFile(metadata.path, 'utf-8'); + const result = parseSkillContent(content, metadata.path, metadata.source); + return result.success ? result.content! : null; + } catch { + return null; + } +} + +/** + * 检查目录中是否存在 SKILL.md + */ +export async function hasSkillFile(dirPath: string): Promise { + try { + await fs.access(path.join(dirPath, 'SKILL.md')); + return true; + } catch { + return false; + } +} diff --git a/src/skills/SkillRegistry.ts b/src/skills/SkillRegistry.ts new file mode 100644 index 00000000..6134c0f9 --- /dev/null +++ b/src/skills/SkillRegistry.ts @@ -0,0 +1,233 @@ +/** + * SkillRegistry - Skill 注册表 + * + * 负责发现、加载、管理所有可用的 Skills。 + * 使用 Progressive Disclosure:启动时仅加载元数据,执行时才加载完整内容。 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; +import { hasSkillFile, loadSkillContent, loadSkillMetadata } from './SkillLoader.js'; +import type { + SkillContent, + SkillDiscoveryResult, + SkillMetadata, + SkillRegistryConfig, +} from './types.js'; + +/** + * 默认配置 + */ +const DEFAULT_CONFIG: Required = { + userSkillsDir: path.join(homedir(), '.blade', 'skills'), + projectSkillsDir: '.blade/skills', + cwd: process.cwd(), +}; + +/** + * SkillRegistry 单例 + */ +let instance: SkillRegistry | null = null; + +/** + * Skill 注册表 + */ +export class SkillRegistry { + private skills: Map = new Map(); + private config: Required; + private initialized = false; + + constructor(config?: SkillRegistryConfig) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * 获取单例实例 + */ + static getInstance(config?: SkillRegistryConfig): SkillRegistry { + if (!instance) { + instance = new SkillRegistry(config); + } + return instance; + } + + /** + * 重置单例(用于测试) + */ + static resetInstance(): void { + instance = null; + } + + /** + * 初始化注册表,扫描所有 skills 目录 + */ + async initialize(): Promise { + if (this.initialized) { + return { + skills: Array.from(this.skills.values()), + errors: [], + }; + } + + const errors: SkillDiscoveryResult['errors'] = []; + const discoveredSkills: SkillMetadata[] = []; + + // 扫描用户级 skills(优先级 1) + const userResult = await this.scanDirectory(this.config.userSkillsDir, 'user'); + discoveredSkills.push(...userResult.skills); + errors.push(...userResult.errors); + + // 扫描项目级 skills(优先级 2,可覆盖用户级同名 skill) + const projectDir = path.isAbsolute(this.config.projectSkillsDir) + ? this.config.projectSkillsDir + : path.join(this.config.cwd, this.config.projectSkillsDir); + const projectResult = await this.scanDirectory(projectDir, 'project'); + discoveredSkills.push(...projectResult.skills); + errors.push(...projectResult.errors); + + // 注册所有发现的 skills(后发现的覆盖先发现的) + for (const skill of discoveredSkills) { + this.skills.set(skill.name, skill); + } + + this.initialized = true; + + return { + skills: Array.from(this.skills.values()), + errors, + }; + } + + /** + * 扫描指定目录下的所有 skills + */ + private async scanDirectory( + dirPath: string, + source: 'user' | 'project' + ): Promise { + const skills: SkillMetadata[] = []; + const errors: SkillDiscoveryResult['errors'] = []; + + try { + // 检查目录是否存在 + await fs.access(dirPath); + } catch { + // 目录不存在,静默返回空结果 + return { skills, errors }; + } + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillDir = path.join(dirPath, entry.name); + const skillFile = path.join(skillDir, 'SKILL.md'); + + // 检查是否存在 SKILL.md + if (!(await hasSkillFile(skillDir))) continue; + + // 加载元数据 + const result = await loadSkillMetadata(skillFile, source); + if (result.success && result.content) { + skills.push(result.content.metadata); + } else { + errors.push({ + path: skillFile, + error: result.error || 'Unknown error', + }); + } + } + } catch (e) { + errors.push({ + path: dirPath, + error: `Failed to scan directory: ${e instanceof Error ? e.message : String(e)}`, + }); + } + + return { skills, errors }; + } + + /** + * 获取所有已注册的 skills 元数据 + */ + getAll(): SkillMetadata[] { + return Array.from(this.skills.values()); + } + + /** + * 根据名称获取 skill 元数据 + */ + get(name: string): SkillMetadata | undefined { + return this.skills.get(name); + } + + /** + * 检查 skill 是否存在 + */ + has(name: string): boolean { + return this.skills.has(name); + } + + /** + * 加载 skill 的完整内容(懒加载) + */ + async loadContent(name: string): Promise { + const metadata = this.skills.get(name); + if (!metadata) return null; + return loadSkillContent(metadata); + } + + /** + * 生成 列表内容 + * 格式:每个 skill 一行 `- name: description` + */ + generateAvailableSkillsList(): string { + if (this.skills.size === 0) { + return ''; + } + + const lines: string[] = []; + for (const skill of this.skills.values()) { + // 截断过长的描述,保持列表简洁 + const desc = + skill.description.length > 100 ? `${skill.description.substring(0, 97)}...` : skill.description; + lines.push(`- ${skill.name}: ${desc}`); + } + + return lines.join('\n'); + } + + /** + * 获取 skills 数量 + */ + get size(): number { + return this.skills.size; + } + + /** + * 重新扫描并刷新注册表 + */ + async refresh(): Promise { + this.skills.clear(); + this.initialized = false; + return this.initialize(); + } +} + +/** + * 获取 SkillRegistry 单例 + */ +export function getSkillRegistry(config?: SkillRegistryConfig): SkillRegistry { + return SkillRegistry.getInstance(config); +} + +/** + * 初始化并获取所有 skills + */ +export async function discoverSkills(config?: SkillRegistryConfig): Promise { + const registry = getSkillRegistry(config); + return registry.initialize(); +} diff --git a/src/skills/index.ts b/src/skills/index.ts new file mode 100644 index 00000000..5329f863 --- /dev/null +++ b/src/skills/index.ts @@ -0,0 +1,54 @@ +/** + * Skills 系统 + * + * Skills 是动态 Prompt 扩展机制,允许 AI 根据用户请求自动调用专业能力。 + * + * 核心特性: + * - Progressive Disclosure:启动时仅加载元数据,执行时才加载完整内容 + * - 基于文件系统:SKILL.md + 可选脚本/模板 + * - AI 自动决策:LLM 根据 description 判断何时调用 + * + * 目录结构: + * - ~/.blade/skills/ - 用户级 skills(全局) + * - .blade/skills/ - 项目级 skills(优先级更高) + * + * SKILL.md 格式: + * ```yaml + * --- + * name: skill-name + * description: 描述该 Skill 的功能和使用场景 + * allowed-tools: Read, Grep, Glob # 可选 + * version: 1.0.0 # 可选 + * --- + * + * ## Skill 指令内容 + * + * 这里是完整的 Skill 执行指令... + * ``` + */ + +// 类型导出 +export type { + SkillContent, + SkillDiscoveryResult, + SkillExecutionContext, + SkillMetadata, + SkillParseResult, + SkillRegistryConfig, +} from './types.js'; + +// 核心功能导出 +export { discoverSkills, getSkillRegistry, SkillRegistry } from './SkillRegistry.js'; + +export { + hasSkillFile, + loadSkillContent, + loadSkillMetadata, + parseSkillContent, +} from './SkillLoader.js'; + +export { + getAvailableSkillsCount, + hasAvailableSkills, + injectSkillsMetadata, +} from './injectSkillsMetadata.js'; diff --git a/src/skills/injectSkillsMetadata.ts b/src/skills/injectSkillsMetadata.ts new file mode 100644 index 00000000..e7a7c36e --- /dev/null +++ b/src/skills/injectSkillsMetadata.ts @@ -0,0 +1,74 @@ +/** + * Skills 元数据注入 + * + * 在工具函数声明中动态替换 占位符。 + * 使用 Progressive Disclosure:仅注入元数据(name + description),不注入完整内容。 + */ + +import type { FunctionDeclaration } from '../tools/types/index.js'; +import { getSkillRegistry } from './SkillRegistry.js'; + +/** Skill 工具名称 */ +const SKILL_TOOL_NAME = 'Skill'; + +/** available_skills 占位符的正则表达式 */ +const AVAILABLE_SKILLS_REGEX = /\s*<\/available_skills>/; + +/** + * 在工具函数声明列表中注入 Skills 元数据 + * + * 查找 Skill 工具的描述,将 占位符 + * 替换为已发现的 skills 列表。 + * + * @param tools - 工具函数声明列表 + * @returns 注入后的工具函数声明列表(新数组,不修改原数组) + */ +export function injectSkillsMetadata(tools: FunctionDeclaration[]): FunctionDeclaration[] { + const registry = getSkillRegistry(); + const skillsList = registry.generateAvailableSkillsList(); + + // 如果没有发现任何 skills,返回原数组 + if (!skillsList) { + return tools; + } + + return tools.map((tool) => { + // 只处理 Skill 工具 + if (tool.name !== SKILL_TOOL_NAME) { + return tool; + } + + // 替换 available_skills 占位符 + const newDescription = tool.description.replace( + AVAILABLE_SKILLS_REGEX, + `\n${skillsList}\n` + ); + + // 如果描述没有变化,返回原对象 + if (newDescription === tool.description) { + return tool; + } + + // 返回新的工具声明对象 + return { + ...tool, + description: newDescription, + }; + }); +} + +/** + * 检查是否有可用的 skills + */ +export function hasAvailableSkills(): boolean { + const registry = getSkillRegistry(); + return registry.size > 0; +} + +/** + * 获取可用 skills 的数量 + */ +export function getAvailableSkillsCount(): number { + const registry = getSkillRegistry(); + return registry.size; +} diff --git a/src/skills/types.ts b/src/skills/types.ts new file mode 100644 index 00000000..b350a18d --- /dev/null +++ b/src/skills/types.ts @@ -0,0 +1,100 @@ +/** + * Skills 系统类型定义 + * + * Skills 是动态 Prompt 扩展机制,允许 AI 根据用户请求自动调用专业能力。 + * 基于文件系统的简单架构(SKILL.md + 可选脚本/模板)。 + */ + +/** + * Skill 元数据(从 SKILL.md YAML 前置数据解析) + * 用于 Progressive Disclosure - 仅加载元数据到系统提示,节省 token + */ +export interface SkillMetadata { + /** 唯一标识,小写+数字+连字符,≤64字符 */ + name: string; + + /** 激活描述,≤1024字符,包含"什么"和"何时使用" */ + description: string; + + /** 工具访问限制,如 ['Read', 'Grep', 'Bash(git:*)'] */ + allowedTools?: string[]; + + /** 版本号 */ + version?: string; + + /** SKILL.md 文件完整路径 */ + path: string; + + /** Skill 目录路径(用于引用 scripts/templates/references) */ + basePath: string; + + /** 来源:user(~/.blade/skills)或 project(.blade/skills) */ + source: 'user' | 'project'; +} + +/** + * Skill 完整内容(懒加载) + */ +export interface SkillContent { + /** 元数据 */ + metadata: SkillMetadata; + + /** SKILL.md 正文内容(去除 YAML 前置数据后的 Markdown) */ + instructions: string; +} + +/** + * Skill 执行上下文 + */ +export interface SkillExecutionContext { + /** Skill 名称 */ + skillName: string; + + /** 执行期间的工具限制 */ + allowedTools?: string[]; + + /** Skill 目录路径(可引用脚本/模板) */ + basePath: string; +} + +/** + * SKILL.md 解析结果 + */ +export interface SkillParseResult { + /** 是否解析成功 */ + success: boolean; + + /** 解析后的内容 */ + content?: SkillContent; + + /** 错误信息 */ + error?: string; +} + +/** + * Skill 注册表配置 + */ +export interface SkillRegistryConfig { + /** 用户级 skills 目录,默认 ~/.blade/skills */ + userSkillsDir?: string; + + /** 项目级 skills 目录,默认 .blade/skills */ + projectSkillsDir?: string; + + /** 当前工作目录 */ + cwd?: string; +} + +/** + * Skill 发现结果 + */ +export interface SkillDiscoveryResult { + /** 发现的 skills 列表 */ + skills: SkillMetadata[]; + + /** 发现过程中的错误(不阻止其他 skills 加载) */ + errors: Array<{ + path: string; + error: string; + }>; +} diff --git a/src/slash-commands/index.ts b/src/slash-commands/index.ts index 1333aea7..f984c7eb 100644 --- a/src/slash-commands/index.ts +++ b/src/slash-commands/index.ts @@ -9,6 +9,7 @@ import ideCommand from './ide.js'; import initCommand from './init.js'; import modelCommand from './model.js'; import permissionsCommand from './permissions.js'; +import skillsCommand from './skills.js'; import themeCommand from './theme.js'; import type { CommandSuggestion, @@ -27,6 +28,7 @@ const slashCommands: SlashCommandRegistry = { model: modelCommand, git: gitCommand, ide: ideCommand, + skills: skillsCommand, }; /** diff --git a/src/slash-commands/skills.ts b/src/slash-commands/skills.ts new file mode 100644 index 00000000..ea4f31cb --- /dev/null +++ b/src/slash-commands/skills.ts @@ -0,0 +1,36 @@ +/** + * /skills 命令 - 查看所有可用的 Skills + */ + +import { discoverSkills } from '../skills/index.js'; +import { sessionActions } from '../store/vanilla.js'; +import type { SlashCommand, SlashCommandResult } from './types.js'; + +const skillsCommand: SlashCommand = { + name: 'skills', + description: '查看所有可用的 Skills', + fullDescription: '显示所有已发现的 Skills 及其详细信息,包括名称、描述、来源和允许的工具。', + usage: '/skills', + category: 'system', + examples: ['/skills'], + + handler: async (_args, context): Promise => { + try { + // 确保 SkillRegistry 已初始化 + await discoverSkills({ cwd: context.cwd }); + + // 显示 Skills 管理面板 + return { + success: true, + message: 'show_skills_manager', + data: { action: 'show_skills_manager' }, + }; + } catch (error) { + const errorMessage = `获取 Skills 失败: ${error instanceof Error ? error.message : '未知错误'}`; + sessionActions().addAssistantMessage(errorMessage); + return { success: false, error: errorMessage }; + } + }, +}; + +export default skillsCommand; diff --git a/src/slash-commands/types.ts b/src/slash-commands/types.ts index 7de180d6..787978c5 100644 --- a/src/slash-commands/types.ts +++ b/src/slash-commands/types.ts @@ -13,7 +13,8 @@ export type SlashCommandAction = | 'show_agents_manager' | 'show_agent_creation_wizard' | 'show_theme_selector' - | 'show_permissions_editor'; + | 'show_permissions_editor' + | 'show_skills_manager'; /** * Slash command 返回的结构化数据 diff --git a/src/store/types.ts b/src/store/types.ts index 96081059..0e2b5bed 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -160,7 +160,8 @@ export type ActiveModal = | 'modelAddWizard' | 'modelEditWizard' | 'agentsManager' - | 'agentCreationWizard'; + | 'agentCreationWizard' + | 'skillsManager'; /** * 应用状态(纯 UI 状态) diff --git a/src/tools/builtin/system/skill.ts b/src/tools/builtin/system/skill.ts index 4a56f9fe..645e0bad 100644 --- a/src/tools/builtin/system/skill.ts +++ b/src/tools/builtin/system/skill.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { getSkillRegistry } from '../../../skills/index.js'; import { createTool } from '../../core/createTool.js'; import type { ToolResult } from '../../types/ToolTypes.js'; import { ToolErrorType, ToolKind } from '../../types/ToolTypes.js'; @@ -6,6 +7,11 @@ import { ToolErrorType, ToolKind } from '../../types/ToolTypes.js'; /** * Skill tool * Execute a skill within the main conversation + * + * Skills 是动态 Prompt 扩展机制,允许 AI 根据用户请求自动调用专业能力。 + * 执行 Skill 时,返回双消息: + * - displayContent: 可见的加载提示(用户看到) + * - llmContent: 完整的 Skill 指令(发送给 LLM) */ export const skillTool = createTool({ name: 'Skill', @@ -15,7 +21,11 @@ export const skillTool = createTool({ schema: z.object({ skill: z .string() - .describe('The skill name (no arguments). E.g., "pdf" or "xlsx"'), + .describe('The skill name. E.g., "commit-message" or "code-review"'), + args: z + .string() + .optional() + .describe('Optional arguments for the skill'), }), description: { @@ -25,14 +35,10 @@ export const skillTool = createTool({ When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. -How to use skills: -- Invoke skills using this tool with the skill name only (no arguments) +When using the Skill tool: +- Invoke skills using this tool with the skill name only - When you invoke a skill, you will see The "{name}" skill is loading - The skill's prompt will expand and provide detailed instructions on how to complete the task -- Examples: - - \`skill: "pdf"\` - invoke the pdf skill - - \`skill: "xlsx"\` - invoke the xlsx skill - - \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name Important: - Only use skills listed in below @@ -49,17 +55,73 @@ Important: async execute(params, _context): Promise { const { skill } = params; - // TODO: Implement skill handler in ExecutionContext when skill system is ready - // For now, return a message indicating the skill system is not yet implemented + // 获取 SkillRegistry + const registry = getSkillRegistry(); + + // 检查 skill 是否存在 + if (!registry.has(skill)) { + return { + success: false, + llmContent: `Skill "${skill}" not found. Available skills: ${registry.getAll().map((s) => s.name).join(', ') || 'none'}`, + displayContent: `❌ Skill "${skill}" not found`, + error: { + type: ToolErrorType.VALIDATION_ERROR, + message: `Skill "${skill}" is not registered`, + }, + }; + } + + // 加载完整的 Skill 内容 + const content = await registry.loadContent(skill); + if (!content) { + return { + success: false, + llmContent: `Failed to load skill "${skill}" content`, + displayContent: `❌ Failed to load skill "${skill}"`, + error: { + type: ToolErrorType.EXECUTION_ERROR, + message: `Could not read SKILL.md for "${skill}"`, + }, + }; + } + + // 构建完整的 Skill 指令(发送给 LLM) + const skillInstructions = buildSkillInstructions(content.metadata.name, content.instructions, content.metadata.basePath); + // 返回双消息 return { - success: false, - llmContent: `Skill system not yet implemented. The skill "${skill}" could not be executed.`, - displayContent: 'Skill system not available', - error: { - type: ToolErrorType.EXECUTION_ERROR, - message: 'Skill handler not configured', + success: true, + // llmContent: 完整的 Skill 指令(发送给 LLM,用户不可见) + llmContent: skillInstructions, + // displayContent: 可见的加载提示(用户看到) + displayContent: `The "${skill}" skill is loading`, + metadata: { + skillName: skill, + basePath: content.metadata.basePath, + version: content.metadata.version, + // allowed-tools: 限制 Skill 执行期间可用的工具 + allowedTools: content.metadata.allowedTools, }, }; }, }); + +/** + * 构建完整的 Skill 指令 + */ +function buildSkillInstructions(name: string, instructions: string, basePath: string): string { + return `# Skill: ${name} + +You are now operating in the "${name}" skill mode. Follow the instructions below to complete the task. + +**Skill Base Path:** ${basePath} +(You can reference scripts, templates, and references relative to this path) + +--- + +${instructions} + +--- + +Remember: Follow the above instructions carefully to complete the user's request.`; +} diff --git a/src/ui/components/BladeInterface.tsx b/src/ui/components/BladeInterface.tsx index 9dfe0d20..d34090f8 100644 --- a/src/ui/components/BladeInterface.tsx +++ b/src/ui/components/BladeInterface.tsx @@ -32,6 +32,7 @@ import { useMainInput } from '../hooks/useMainInput.js'; import { useRefreshStatic } from '../hooks/useRefreshStatic.js'; import { AgentCreationWizard } from './AgentCreationWizard.js'; import { AgentsManager } from './AgentsManager.js'; +import { SkillsManager } from './SkillsManager.js'; import { ChatStatusBar } from './ChatStatusBar.js'; import { CommandSuggestions } from './CommandSuggestions.js'; import { ConfirmationPrompt } from './ConfirmationPrompt.js'; @@ -500,6 +501,7 @@ export const BladeInterface: React.FC = ({ const agentsManagerVisible = activeModal === 'agentsManager'; const agentCreationWizardVisible = activeModal === 'agentCreationWizard'; + const skillsManagerVisible = activeModal === 'skillsManager'; const editingInitialConfig = editingModel ? { @@ -584,6 +586,12 @@ export const BladeInterface: React.FC = ({ )} + {skillsManagerVisible && ( + + + + )} + {/* 命令建议列表 - 显示在输入框下方 */} = ({ onClose const tabKey = activeTab as Exclude; const items: RuleSelectItem[] = [ { + key: 'add-new-rule', label: `› ${addLabels[tabKey]}`, value: { type: 'add' }, }, ...entries[tabKey].map( (entry): RuleSelectItem => ({ + key: entry.key, label: formatRuleLabel(entry.rule, entry.source), value: { type: 'rule', entry }, }) diff --git a/src/ui/components/SkillsManager.tsx b/src/ui/components/SkillsManager.tsx new file mode 100644 index 00000000..b7443dbc --- /dev/null +++ b/src/ui/components/SkillsManager.tsx @@ -0,0 +1,142 @@ +/** + * SkillsManager - Skills 查看器 + * + * 显示所有可用的 Skills 及其详细信息 + */ + +import { Box, Text, useInput } from 'ink'; +import { getSkillRegistry } from '../../skills/index.js'; +import { useCtrlCHandler } from '../hooks/useCtrlCHandler.js'; + +export interface SkillsManagerProps { + /** 完成回调 */ + onComplete?: () => void; + /** 取消回调 */ + onCancel?: () => void; +} + +/** + * Skills 查看器主组件 + */ +export function SkillsManager({ onCancel }: SkillsManagerProps) { + const registry = getSkillRegistry(); + const skills = registry.getAll(); + + // 按来源分组 + const userSkills = skills.filter((s) => s.source === 'user'); + const projectSkills = skills.filter((s) => s.source === 'project'); + + // 使用智能 Ctrl+C 处理 + const handleCtrlC = useCtrlCHandler(false, onCancel); + + // ESC 和 Ctrl+C 处理 + useInput((input, key) => { + if (key.escape) { + onCancel?.(); + } else if ((key.ctrl && input === 'c') || (key.meta && input === 'c')) { + handleCtrlC(); + } + }); + + if (skills.length === 0) { + return ( + + + + 📚 所有 Skills + + + + 没有找到任何 Skill + + + + 配置文件位置: ~/.blade/skills/ 或 .blade/skills/ + + + + 按 ESC 返回菜单 + + + ); + } + + return ( + + + + 📚 所有 Skills + + (找到 {skills.length} 个) + + + {/* 项目级 Skills */} + {projectSkills.length > 0 && ( + + + + 项目级 + + (.blade/skills/) + + {projectSkills.map((skill) => ( + + + + • {skill.name} + + - {skill.description} + + {skill.allowedTools && skill.allowedTools.length > 0 && ( + + 工具: {skill.allowedTools.join(', ')} + + )} + + + {skill.path} + + + + ))} + + )} + + {/* 用户级 Skills */} + {userSkills.length > 0 && ( + + + + 用户级 + + (~/.blade/skills/) + + {userSkills.map((skill) => ( + + + + • {skill.name} + + - {skill.description} + + {skill.allowedTools && skill.allowedTools.length > 0 && ( + + 工具: {skill.allowedTools.join(', ')} + + )} + + + {skill.path} + + + + ))} + + )} + + + 按 ESC 返回菜单 + + + ); +} diff --git a/src/ui/hooks/useCommandHandler.ts b/src/ui/hooks/useCommandHandler.ts index a62d00a5..e462d2e7 100644 --- a/src/ui/hooks/useCommandHandler.ts +++ b/src/ui/hooks/useCommandHandler.ts @@ -57,6 +57,9 @@ function handleSlashMessage( case 'show_agents_manager': appActions.setActiveModal('agentsManager'); return true; + case 'show_skills_manager': + appActions.setActiveModal('skillsManager'); + return true; case 'show_agent_creation_wizard': appActions.setActiveModal('agentCreationWizard'); return true; From af6f40fd79b4275e36315be79deab651045aa89a Mon Sep 17 00:00:00 2001 From: "huzijie.sea" Date: Wed, 24 Dec 2025 18:51:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(skills):=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E6=8A=80=E8=83=BD=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增技能注册表,支持用户级、项目级和内置技能 - 添加技能创建工具 skill-creator - 支持通过斜杠命令调用用户可调用的技能 - 扩展技能元数据以兼容 Claude Code 规范 - 改进技能管理界面显示详细信息 - 优化斜杠命令建议和补全逻辑 --- .blade/settings.local.json | 21 ++- .claude/settings.local.json | 3 +- src/skills/SkillLoader.ts | 42 +++++- src/skills/SkillRegistry.ts | 94 +++++++++++- src/skills/builtin/skill-creator.ts | 218 +++++++++++++++++++++++++++ src/skills/types.ts | 49 +++++- src/slash-commands/index.ts | 102 +++++++++++-- src/slash-commands/skills.ts | 10 +- src/slash-commands/types.ts | 3 +- src/ui/App.tsx | 28 +++- src/ui/components/BladeInterface.tsx | 21 ++- src/ui/components/SkillsManager.tsx | 112 ++++++++++---- src/ui/hooks/useCommandHandler.ts | 112 +++++++++++--- src/ui/hooks/useMainInput.ts | 27 ++-- src/ui/utils/toolFormatters.ts | 16 ++ 15 files changed, 751 insertions(+), 107 deletions(-) create mode 100644 src/skills/builtin/skill-creator.ts diff --git a/.blade/settings.local.json b/.blade/settings.local.json index 1d1cc1fa..05061a52 100644 --- a/.blade/settings.local.json +++ b/.blade/settings.local.json @@ -5,7 +5,26 @@ "Bash(git status)", "Bash(git diff *)", "Bash(ls -l)", - "Write(**/*.md)" + "Write(**/*.md)", + "Bash(ls -la *)", + "Bash(bun install *)", + "Skill", + "Write(**/*.py)", + "Bash(python test_echovic_website.py)", + "Bash(pip install *)", + "Bash(playwright install *)", + "Bash(python test_echovic_comprehensive.py)", + "Bash(curl -s *)", + "Bash(curl -I *)", + "Edit(**/*.py)", + "Bash(python -c *)", + "Bash(python test_echovic_debug.py)", + "Bash(python test_echovic_fixed.py)", + "Bash(python test_echovic_wait.py)", + "Bash(python test_echovic_basic.py)", + "Bash(python test_echovic_individual.py)", + "Bash(python test_echovic_analysis.py)", + "Bash(python3 -c *)" ], "ask": [], "deny": [] diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6a3fa6d5..efc54e68 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -74,7 +74,8 @@ "Bash(gtimeout 3 npm run start:*)", "WebFetch(domain:leehanchung.github.io)", "WebFetch(domain:simonwillison.net)", - "WebFetch(domain:scottspence.com)" + "WebFetch(domain:scottspence.com)", + "WebFetch(domain:docs.anthropic.com)" ], "deny": [], "ask": [], diff --git a/src/skills/SkillLoader.ts b/src/skills/SkillLoader.ts index 0fd337f5..47aa0664 100644 --- a/src/skills/SkillLoader.ts +++ b/src/skills/SkillLoader.ts @@ -21,12 +21,23 @@ const MAX_DESCRIPTION_LENGTH = 1024; /** * 解析 SKILL.md 的 YAML 前置数据 + * 完全对齐 Claude Code Skills 规范 */ interface RawFrontmatter { name?: string; description?: string; 'allowed-tools'?: string | string[]; version?: string; + /** 参数提示,如 '' */ + 'argument-hint'?: string; + /** 是否支持 /skill-name 调用 */ + 'user-invocable'?: boolean | string; + /** 是否禁止 AI 自动调用 */ + 'disable-model-invocation'?: boolean | string; + /** 指定模型 */ + model?: string; + /** 额外触发条件 */ + when_to_use?: string; } /** @@ -50,6 +61,20 @@ function parseAllowedTools(raw: string | string[] | undefined): string[] | undef return undefined; } +/** + * 解析布尔值字段(支持 true/false 字符串) + */ +function parseBoolean(value: boolean | string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const lower = value.toLowerCase().trim(); + if (lower === 'true' || lower === 'yes' || lower === '1') return true; + if (lower === 'false' || lower === 'no' || lower === '0') return false; + } + return undefined; +} + /** * 验证 Skill 元数据 */ @@ -79,6 +104,13 @@ function validateMetadata( }; } + // 解析 model 字段 + let model: string | undefined; + if (frontmatter.model) { + // 'inherit' 表示继承当前模型,其他值为具体模型名 + model = frontmatter.model === 'inherit' ? 'inherit' : frontmatter.model; + } + return { valid: true, metadata: { @@ -86,6 +118,12 @@ function validateMetadata( description: frontmatter.description.trim(), allowedTools: parseAllowedTools(frontmatter['allowed-tools']), version: frontmatter.version, + // 新增字段 + argumentHint: frontmatter['argument-hint']?.trim(), + userInvocable: parseBoolean(frontmatter['user-invocable']), + disableModelInvocation: parseBoolean(frontmatter['disable-model-invocation']), + model, + whenToUse: frontmatter.when_to_use?.trim(), }, }; } @@ -96,7 +134,7 @@ function validateMetadata( export function parseSkillContent( content: string, filePath: string, - source: 'user' | 'project' + source: 'user' | 'project' | 'builtin' ): SkillParseResult { // 匹配 YAML 前置数据 const match = content.match(FRONTMATTER_REGEX); @@ -150,7 +188,7 @@ export function parseSkillContent( */ export async function loadSkillMetadata( filePath: string, - source: 'user' | 'project' + source: 'user' | 'project' | 'builtin' ): Promise { try { const content = await fs.readFile(filePath, 'utf-8'); diff --git a/src/skills/SkillRegistry.ts b/src/skills/SkillRegistry.ts index 6134c0f9..1f0758f1 100644 --- a/src/skills/SkillRegistry.ts +++ b/src/skills/SkillRegistry.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { homedir } from 'node:os'; +import { getSkillCreatorContent, skillCreatorMetadata } from './builtin/skill-creator.js'; import { hasSkillFile, loadSkillContent, loadSkillMetadata } from './SkillLoader.js'; import type { SkillContent, @@ -22,6 +23,9 @@ import type { const DEFAULT_CONFIG: Required = { userSkillsDir: path.join(homedir(), '.blade', 'skills'), projectSkillsDir: '.blade/skills', + // Claude Code 兼容路径 + claudeUserSkillsDir: path.join(homedir(), '.claude', 'skills'), + claudeProjectSkillsDir: '.claude/skills', cwd: process.cwd(), }; @@ -61,6 +65,13 @@ export class SkillRegistry { /** * 初始化注册表,扫描所有 skills 目录 + * + * 优先级(后加载的覆盖先加载的): + * 1. 内置 Skills(builtin) + * 2. Claude Code 用户级 Skills(~/.claude/skills/) + * 3. Blade 用户级 Skills(~/.blade/skills/) + * 4. Claude Code 项目级 Skills(.claude/skills/) + * 5. Blade 项目级 Skills(.blade/skills/)- 优先级最高 */ async initialize(): Promise { if (this.initialized) { @@ -73,12 +84,28 @@ export class SkillRegistry { const errors: SkillDiscoveryResult['errors'] = []; const discoveredSkills: SkillMetadata[] = []; - // 扫描用户级 skills(优先级 1) + // 1. 加载内置 Skills(优先级最低,可被覆盖) + this.loadBuiltinSkills(); + + // 2. 扫描 Claude Code 用户级 skills(~/.claude/skills/) + const claudeUserResult = await this.scanDirectory(this.config.claudeUserSkillsDir, 'user'); + discoveredSkills.push(...claudeUserResult.skills); + errors.push(...claudeUserResult.errors); + + // 3. 扫描 Blade 用户级 skills(~/.blade/skills/) const userResult = await this.scanDirectory(this.config.userSkillsDir, 'user'); discoveredSkills.push(...userResult.skills); errors.push(...userResult.errors); - // 扫描项目级 skills(优先级 2,可覆盖用户级同名 skill) + // 4. 扫描 Claude Code 项目级 skills(.claude/skills/) + const claudeProjectDir = path.isAbsolute(this.config.claudeProjectSkillsDir) + ? this.config.claudeProjectSkillsDir + : path.join(this.config.cwd, this.config.claudeProjectSkillsDir); + const claudeProjectResult = await this.scanDirectory(claudeProjectDir, 'project'); + discoveredSkills.push(...claudeProjectResult.skills); + errors.push(...claudeProjectResult.errors); + + // 5. 扫描 Blade 项目级 skills(.blade/skills/)- 优先级最高 const projectDir = path.isAbsolute(this.config.projectSkillsDir) ? this.config.projectSkillsDir : path.join(this.config.cwd, this.config.projectSkillsDir); @@ -99,6 +126,14 @@ export class SkillRegistry { }; } + /** + * 加载内置 Skills + */ + private loadBuiltinSkills(): void { + // 注册 skill-creator + this.skills.set(skillCreatorMetadata.name, skillCreatorMetadata); + } + /** * 扫描指定目录下的所有 skills */ @@ -177,24 +212,71 @@ export class SkillRegistry { async loadContent(name: string): Promise { const metadata = this.skills.get(name); if (!metadata) return null; + + // 内置 Skill 直接返回内容 + if (metadata.source === 'builtin') { + return this.loadBuiltinContent(name); + } + + // 文件系统 Skill 从文件加载 return loadSkillContent(metadata); } + /** + * 加载内置 Skill 的完整内容 + */ + private loadBuiltinContent(name: string): SkillContent | null { + switch (name) { + case 'skill-creator': + return getSkillCreatorContent(); + default: + return null; + } + } + + /** + * 获取可被 AI 自动调用的 Skills(Model-invoked) + * 排除设置了 disable-model-invocation: true 的 Skills + */ + getModelInvocableSkills(): SkillMetadata[] { + return Array.from(this.skills.values()).filter( + (skill) => !skill.disableModelInvocation + ); + } + + /** + * 获取可通过 /skill-name 命令调用的 Skills(User-invoked) + * 仅包含设置了 user-invocable: true 的 Skills + */ + getUserInvocableSkills(): SkillMetadata[] { + return Array.from(this.skills.values()).filter( + (skill) => skill.userInvocable === true + ); + } + /** * 生成 列表内容 - * 格式:每个 skill 一行 `- name: description` + * 格式:每个 skill 一行 `- name [argument-hint]: description` + * 仅包含可被 AI 自动调用的 Skills */ generateAvailableSkillsList(): string { - if (this.skills.size === 0) { + const modelInvocableSkills = this.getModelInvocableSkills(); + if (modelInvocableSkills.length === 0) { return ''; } const lines: string[] = []; - for (const skill of this.skills.values()) { + for (const skill of modelInvocableSkills) { // 截断过长的描述,保持列表简洁 const desc = skill.description.length > 100 ? `${skill.description.substring(0, 97)}...` : skill.description; - lines.push(`- ${skill.name}: ${desc}`); + + // 如果有 argument-hint,添加到名称后面 + const nameWithHint = skill.argumentHint + ? `${skill.name} ${skill.argumentHint}` + : skill.name; + + lines.push(`- ${nameWithHint}: ${desc}`); } return lines.join('\n'); diff --git a/src/skills/builtin/skill-creator.ts b/src/skills/builtin/skill-creator.ts new file mode 100644 index 00000000..f21c26de --- /dev/null +++ b/src/skills/builtin/skill-creator.ts @@ -0,0 +1,218 @@ +/** + * 内置 skill-creator Skill + * + * 帮助用户交互式创建新的 Blade Skills。 + * 对齐 Claude Code 的 skill-creator 实现。 + */ + +import type { SkillContent, SkillMetadata } from '../types.js'; + +/** + * skill-creator 的元数据 + */ +export const skillCreatorMetadata: SkillMetadata = { + name: 'skill-creator', + description: + 'Create new Skills interactively. Use when the user wants to create a new Skill, define a custom workflow, or add a specialized capability to Blade.', + allowedTools: ['Read', 'Write', 'Glob', 'Bash', 'AskUserQuestion'], + version: '1.0.0', + argumentHint: undefined, + userInvocable: true, // 允许用户通过 /skill-creator 调用 + disableModelInvocation: false, // AI 可以自动调用 + model: undefined, + whenToUse: + 'User wants to create a new skill, define a custom workflow, or add a specialized capability.', + path: 'builtin://skill-creator', + basePath: '', + source: 'builtin', +}; + +/** + * skill-creator 的完整指令内容 + */ +export const skillCreatorInstructions = `# Skill Creator + +帮助用户创建新的 Blade Skills。 + +## Instructions + +当用户想要创建新 Skill 时,按以下步骤进行: + +### 1. 了解需求 + +询问用户: +- Skill 的目的是什么?解决什么问题? +- 什么场景下应该使用这个 Skill? +- 需要访问哪些工具?(Read, Write, Bash, Grep, Glob, Task, WebFetch 等) +- 是否需要支持用户通过 \`/skill-name\` 命令调用? + +### 2. 设计 Skill + +根据用户需求,设计以下内容: + +**必填字段:** +- \`name\`: kebab-case 格式,≤64 字符(如 \`code-review\`, \`commit-helper\`) +- \`description\`: 简洁描述,≤1024 字符,包含"什么"和"何时使用" + +**可选字段:** +- \`allowed-tools\`: 限制可用工具列表(提高安全性) +- \`argument-hint\`: 参数提示(如 \`\`) +- \`user-invocable\`: 是否支持 \`/skill-name\` 调用(默认 false) +- \`disable-model-invocation\`: 是否禁止 AI 自动调用(默认 false) +- \`version\`: 版本号 + +**系统提示词设计要点:** +- 清晰的任务描述 +- 具体的执行步骤 +- 边界条件和错误处理 +- 输出格式规范 + +### 3. 确认保存位置 + +询问用户希望将 Skill 保存到: +- **项目级** (\`.blade/skills/\`): 与团队共享,通过 git 同步 +- **用户级** (\`~/.blade/skills/\`): 个人使用,跨项目可用 + +### 4. 生成文件 + +使用 Write 工具创建 SKILL.md 文件: + +\`\`\` +{location}/.blade/skills/{name}/SKILL.md +\`\`\` + +文件格式: +\`\`\`yaml +--- +name: {name} +description: {description} +allowed-tools: + - {tool1} + - {tool2} +user-invocable: true # 如果需要 /skill-name 命令 +--- + +# {Skill Title} + +## Instructions + +{详细指令} + +## Examples + +{使用示例} +\`\`\` + +### 5. 验证 + +- 检查目录和文件是否创建成功 +- 提示用户可以通过以下方式使用新 Skill: + - AI 自动调用(如果未禁用) + - \`/skill-name\` 命令(如果启用了 user-invocable) + - 执行 \`/skills\` 查看所有 Skills + +## Best Practices + +1. **名称规范** + - 使用 kebab-case:\`code-review\`, \`commit-helper\`, \`test-generator\` + - 名称应该简洁且描述性强 + +2. **描述要具体** + - 包含触发词,帮助 AI 识别何时使用 + - 例如:"Generate commit messages following conventional commits format. Use when the user wants to commit changes or asks for a commit message." + +3. **限制工具访问** + - 仅授予必要的工具权限,提高安全性 + - 例如:只读 Skill 只需 Read, Grep, Glob + +4. **提供清晰的指令** + - 使用 Markdown 格式组织内容 + - 包含具体步骤和示例 + - 处理边界情况 + +5. **考虑调用方式** + - 频繁使用的 Skill 可设置 \`user-invocable: true\` + - 仅限用户手动触发的 Skill 可设置 \`disable-model-invocation: true\` + +## Example Skills + +### 代码审查 Skill + +\`\`\`yaml +--- +name: code-review +description: Review code for best practices, bugs, and improvements. Use when reviewing PRs, checking code quality, or before committing. +allowed-tools: + - Read + - Grep + - Glob +argument-hint: +user-invocable: true +--- + +# Code Review + +审查代码质量、最佳实践和潜在问题。 + +## Instructions + +1. 读取指定的文件或目录 +2. 检查以下方面: + - 代码风格一致性 + - 潜在的 bug 或错误 + - 性能问题 + - 安全漏洞 + - 可读性和可维护性 +3. 提供具体的改进建议 + +## Output Format + +- 问题严重程度:🔴 严重 | 🟡 警告 | 🔵 建议 +- 具体位置和代码片段 +- 改进建议和示例代码 +\`\`\` + +### 提交消息生成 Skill + +\`\`\`yaml +--- +name: commit-message +description: Generate conventional commit messages. Use when committing changes or when user asks for a commit message. +allowed-tools: + - Bash + - Read +user-invocable: true +--- + +# Commit Message Generator + +生成符合 Conventional Commits 规范的提交消息。 + +## Instructions + +1. 运行 \`git diff --staged\` 查看暂存的更改 +2. 分析更改类型(feat/fix/docs/style/refactor/test/chore) +3. 生成简洁的提交消息 +4. 询问用户是否满意或需要调整 + +## Output Format + +\`\`\` +(): + + + +