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 6a6e9b9a..efc54e68 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -71,7 +71,11 @@ "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)", + "WebFetch(domain:docs.anthropic.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..47aa0664 --- /dev/null +++ b/src/skills/SkillLoader.ts @@ -0,0 +1,233 @@ +/** + * 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 前置数据 + * 完全对齐 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; +} + +/** + * 验证并规范化 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; +} + +/** + * 解析布尔值字段(支持 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 元数据 + */ +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})`, + }; + } + + // 解析 model 字段 + let model: string | undefined; + if (frontmatter.model) { + // 'inherit' 表示继承当前模型,其他值为具体模型名 + model = frontmatter.model === 'inherit' ? 'inherit' : frontmatter.model; + } + + return { + valid: true, + metadata: { + name: frontmatter.name, + 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(), + }, + }; +} + +/** + * 解析 SKILL.md 文件内容 + */ +export function parseSkillContent( + content: string, + filePath: string, + source: 'user' | 'project' | 'builtin' +): 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' | 'builtin' +): 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..1f0758f1 --- /dev/null +++ b/src/skills/SkillRegistry.ts @@ -0,0 +1,315 @@ +/** + * SkillRegistry - Skill 注册表 + * + * 负责发现、加载、管理所有可用的 Skills。 + * 使用 Progressive Disclosure:启动时仅加载元数据,执行时才加载完整内容。 + */ + +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, + SkillDiscoveryResult, + SkillMetadata, + SkillRegistryConfig, +} from './types.js'; + +/** + * 默认配置 + */ +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(), +}; + +/** + * 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 目录 + * + * 优先级(后加载的覆盖先加载的): + * 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) { + return { + skills: Array.from(this.skills.values()), + errors: [], + }; + } + + const errors: SkillDiscoveryResult['errors'] = []; + const discoveredSkills: SkillMetadata[] = []; + + // 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); + + // 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); + 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 loadBuiltinSkills(): void { + // 注册 skill-creator + this.skills.set(skillCreatorMetadata.name, skillCreatorMetadata); + } + + /** + * 扫描指定目录下的所有 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; + + // 内置 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 [argument-hint]: description` + * 仅包含可被 AI 自动调用的 Skills + */ + generateAvailableSkillsList(): string { + const modelInvocableSkills = this.getModelInvocableSkills(); + if (modelInvocableSkills.length === 0) { + return ''; + } + + const lines: string[] = []; + for (const skill of modelInvocableSkills) { + // 截断过长的描述,保持列表简洁 + const desc = + skill.description.length > 100 ? `${skill.description.substring(0, 97)}...` : skill.description; + + // 如果有 argument-hint,添加到名称后面 + const nameWithHint = skill.argumentHint + ? `${skill.name} ${skill.argumentHint}` + : skill.name; + + lines.push(`- ${nameWithHint}: ${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/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 + +\`\`\` +(): + + + +