From bab4a512a7ccc8ae7a9d1061d441a28ba27d41ad Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 2 Feb 2026 14:45:23 +0800 Subject: [PATCH 1/3] docs: init skills --- packages/skills/README.md | 1 + packages/skills/controller/SKILL.md | 58 +++ .../controller/references/http-controller.md | 466 ++++++++++++++++++ packages/skills/egg/SKILL.md | 217 ++++++++ packages/skills/egg/references/.gitkeep | 0 packages/skills/package.json | 44 ++ packages/skills/tegg-core/SKILL.md | 312 ++++++++++++ packages/skills/tegg-core/references/.gitkeep | 0 8 files changed, 1098 insertions(+) create mode 100644 packages/skills/README.md create mode 100644 packages/skills/controller/SKILL.md create mode 100644 packages/skills/controller/references/http-controller.md create mode 100644 packages/skills/egg/SKILL.md create mode 100644 packages/skills/egg/references/.gitkeep create mode 100644 packages/skills/package.json create mode 100644 packages/skills/tegg-core/SKILL.md create mode 100644 packages/skills/tegg-core/references/.gitkeep diff --git a/packages/skills/README.md b/packages/skills/README.md new file mode 100644 index 0000000000..706698cbe5 --- /dev/null +++ b/packages/skills/README.md @@ -0,0 +1 @@ +# @eggjs/skills diff --git a/packages/skills/controller/SKILL.md b/packages/skills/controller/SKILL.md new file mode 100644 index 0000000000..a45fb624b0 --- /dev/null +++ b/packages/skills/controller/SKILL.md @@ -0,0 +1,58 @@ +--- +name: egg-controller +description: Use when creating API endpoints, implementing protocol handlers, or exposing interfaces for specific clients. Covers HTTP, MCP and Schedule controllers for EGG framework applications. +allowed-tools: Read +--- + +# EGG 控制器 + +--- + +## 控制器选择决策树 + +``` +需要暴露什么接口/客户端协议? + +1. HTTP 接口?例如 HTML/JSON/SSR/SSE,可以使用 HTTPController,参考 `references/http-controller.md` + +2. 定时任务,可以使用 Schedule,参考 `references/schedule.md` + +3. AI集成 MCP,可以使用 MCPController,参考 `refercens/mcp-controller.md` +``` +--- + +## 控制器快速参考 + +### HTTPController +- **装饰器**:`@HTTPController`、`@HTTPMethod` +- **参数**:`@HTTPParam`、`@HTTPQuery`、`@HTTPBody`、`@HTTPHeaders`、`@Cookies`、`@Request`、`@Context` +- **详细文档**:`references/httpcontroller.md` + +### MCPController +- **装饰器**:`@MCPController`、`@MCPTool`、`@MCPPrompt`、`@MCPResource` +- **特点**:集成 LLM、Zod 验证、登录态支持 + +### Schedule +- **装饰器**:`@Schedule`、配置 +- **模式**:Worker/All + +--- + +## 最佳实践 + +- **控制器精简**:业务逻辑委托给 Service 层 +- **参数验证**:使用装饰器和类型定义 +- **错误处理**:根据协议转换错误和响应码 +- **RESTful 设计**:遵循 HTTP 方法和资源命名 +- **响应一致性**:统一响应格式 + +--- + +## 参考资料 + +详细的控制器开发文档: +- `references/http-controller.md` - HTTP 接口完整指南 +- `references/mcp-controller.md` - MCP/LLM 集成 +- `references/schedule.md` - 定时任务 + +核心概念(@eggjs/skills-core):模块、依赖注入、对象生命周期 diff --git a/packages/skills/controller/references/http-controller.md b/packages/skills/controller/references/http-controller.md new file mode 100644 index 0000000000..6487795942 --- /dev/null +++ b/packages/skills/controller/references/http-controller.md @@ -0,0 +1,466 @@ +# HTTPController 开发指南 + +## 快速开始 + +### 创建基本 HTTP 接口 + +使用 `@HTTPController` 和 `@HTTPMethod` 装饰器创建 HTTP 接口: + +```typescript +import { HTTPController, HTTPMethod, HTTPMethodEnum } from 'egg/tegg'; + +@HTTPController() +export class DemoController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/hello/:name' }) + async hello(@HTTPParam() name: string) { + return { message: 'hello ' + name }; + } +} +``` + +### 设置路径前缀 + +为控制器设置统一路径前缀: + +```typescript +@HTTPController({ path: '/api' }) +export class PathController { + // 最终路径: GET /api/hello + @HTTPMethod({ method: HTTPMethodEnum.GET, path: 'hello' }) + async hello() { } + + // 最终路径: POST /api/create + @HTTPMethod({ method: HTTPMethodEnum.POST, path: 'create' }) + async create() { } +} +``` + +--- + +## 参数装饰器决策 + +### 决策树:选择合适的参数装饰器 + +``` +需要从 HTTP 请求获取什么信息? + +├─ URL 路径参数 +│ └─ → @HTTPParam() +│ +├─ URL 查询参数 +│ ├─ 单个值 → @HTTPQuery() +│ └─ 多个值(数组) → @HTTPQueries() +│ +├─ 请求体(POST/PUT) +│ └─ → @HTTPBody() +│ ├─ json → 对象 +│ ├─ text → 字符串 +│ +├─ 请求头 +│ └─ → @HTTPHeaders() +│ └─ 注意:key 自动转小写 +│ +├─ Cookie +│ └─ → @Cookies() +│ +├─ 原始 HTTP 请求对象 +│ └─ → @Request() +│ └─ 注意:不要和 @HTTPBody 一起消费请求体 +│ +└─ Egg Context(框架功能) + └─ → @Context() +``` + +### 参考对照表(参数装饰器) + +| 装饰器 | 获取内容 | 类型 | 默认值 | 支持选项 | +|--------|---------|------|--------|----------| +| `@HTTPParam()` | URL 路径参数 | `string` | 变量名 | `{ name?: string }` | +| `@HTTPQuery()` | 查询参数(单个) | `string` | 变量名 | `{ name?: string }` | +| `@HTTPQueries()` | 查询参数(多个) | `string[]` | 变量名 | `{ name?: string }` | +| `@HTTPBody()` | 请求体 | `object \| string` | - | - | +| `@HTTPHeaders()` | 请求头 | `IncomingHttpHeaders` | - | - | +| `@Cookies()` | Cookie | `HTTPCookies` | - | - | +| `@Request()` | HTTP 请求对象 | `HTTPRequest` | - | - | +| `@Context()` | Egg Context | `EggContext` | - | - | + +### 快速选择指南 + +**需要从 URL 获取参数(id)** → `@HTTPParam` +**需要从查询字符串获取参数(category=books)** → `@HTTPQuery` +**需要从查询字符串获取全部值(tag=tech&tag=dev)** → `@HTTPQueries` +**需要获取请求体(POST JSON)** → `@HTTPBody` +**需要获取请求头字段** → `@HTTPHeaders`(注意:使用小写key) +**需要读取 Cookie** → `@Cookies` +**需要访问原始请求对象** → `@Request` + +--- + +## HTTP 响应 + +### JSON 响应(默认) + +直接返回对象,框架自动序列化为 JSON: + +```typescript +@HTTPController({ path: '/api' }) +export class JsonController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/data' }) + async getData() { + return { result: 'hello world' }; + } +} +``` + +### 自定义响应 + +```typescript +@HTTPController({ path: '/api' }) +export class ResponseController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/custom' }) + async customResponse(@Context() ctx: EggContext) { + ctx.status = 201; + ctx.set('X-Custom', 'value'); + ctx.type = 'json'; + return { message: 'Created' }; + } +} +``` + +--- + +## 服务端渲染(SSR) + +```typescript +@HTTPController({ path: '/' }) +export class SSRController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async render(@Context() ctx: EggContext) { + ctx.type = 'html'; + return ` + + + Home +

Hello World

+ + `; + } +} +``` + +--- + +## Server-Sent Events(SSE) + +```typescript +import { Readable } from 'node:stream'; +import { setTimeout } from 'node:timers/promises'; + +async function* generateHtml() { + yield ''; + for (let i = 1; i <= 5; i++) { + yield `

Chunk ${i}

`; + await setTimeout(1000); + } + yield ''; +} + +@HTTPController({ path: '/api' }) +export class StreamController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/stream' }) + async streamHtml(@Context() ctx: EggContext) { + ctx.type = 'html'; + return Readable.from(generateHtml()); + } +} +``` + +--- + +## 路由优先级管理 + +### 默认优先级规则 + +| Path | RegExp Index | Priority | 说明 | +|------|-------------|----------|------| +| `/*` | `[0 ` | 0 | 通配符,最低 | +| `/hello/:name` | `[1 ` | 1000 | 单参数 | +| `/hello/world/message/:message` | `[3 ` | 3000 | 三参数 | +| `/hello/:name/message/:message` | `[1, 3 ` | 4000 | 多参数,索引更大 | +| `/hello/world` | `[ ` | 100000 | 静态路径,最高 | + +### 手动设置优先级 + +```typescript +@HTTPController({ path: '/api' }) +export class PriorityController { + @HTTPMethod({ + method: HTTPMethodEnum.GET, + path: '/(api|openapi)/version', + priority: 100000, // 提升优先级 + }) + async high() { } + + @HTTPMethod({ + method: HTTPMethodEnum.POST, + path: '/(api|openapi)/(.+)', + }) + async low() { } +} +``` + +--- + +## 参数装饰器详解 + +### @HTTPParam + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:从 URL 路径中提取参数 + +**语法**:`@HTTPParam(param?: HTTPParamParams)` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api/users' }) +export class UserController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: ':userId/posts/:postId' }) + async getPost( + @HTTPParam() userId: string, + @HTTPParam() postId: string + ) { + return { userId, postId }; + } +} +``` + +#### 使用要点 + +- 参数类型必须是 `string` +- 参数名默认和变量名相同 +- 支持正则表达式捕获:`path: '/files/(.*)'` +- 使用 `{ name: '0' }` 获取正则第一个匹配 +- 支持多参数路径 + +#### 示例 + +```typescript +// 路径参数 +@HTTPController({ path: '/api/users' }) +export class UserController { + // GET /users/:id + @HTTPMethod({ method: HTTPMethodEnum.GET, path: ':id' }) + async getUser(@HTTPParam() id: string) { + return { userId: id }; + } + + // GET /users/:userId/posts/:postId + @HTTPMethod({ method: HTTPMethodEnum.GET, path: ':userId/posts/:postId' }) + async getPost(@HTTPParam() userId: string, @HTTPParam() postId: string) { + return { userId, postId }; + } + + // 获取正则匹配 + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/files/(.*)' }) + async getFile(@HTTPParam({ name: '0' }) path: string) { + return { path }; + } +} +``` + +--- + +### @HTTPQuery + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:从 URL 查询字符串提取参数(`?key=value`) + +**语法**: +- `@HTTPQuery(param?: HTTPQueryParams)` - 返回首个匹配的值(`string`) +- `@HTTPQueries(param?: HTTPQueriesParams)` - 返回全部值的数组(`string[]`) + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api/search' }) +export class SearchController { + // GET /api/search?category=books + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async searchByCategory(@HTTPQuery() category: string) { + return { category }; + } + + // GET /api/search?tag=tech&tag=dev + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/' }) + async searchByTags(@HTTPQueries({ name: 'tag' }) tags: string[]) { + return { tags }; + } +} +``` + +#### 使用要点 + +- `@HTTPQuery`:返回首个匹配的值 +- `@HTTPQueries`:返回全部值的数组 +- 参数名默认与变量名匹配 +- 使用 `{ name: 'key' }` 指定查询参数名 + +--- + +### @HTTPBody + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:从请求体提取数据(POST/PUT 请求的 body) + +**语法**:`@HTTPBody()` + +**类型**:`object | string | FormData` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api/users' }) +export class UserController { + @HTTPMethod({ method: HTTPMethodEnum.POST, path: '/' }) + async createUser(@HTTPBody() body: { name: string; email: string }) { + return { userId: '123', ...body }; + } +} +``` + +#### Content-Type 解析 + +| Content-Type | 解析结果 | +|-------------|--------------| +| `application/json` | 对象 `object` | +| `text/plain` | 字符串 `string` | +| `application/x-www-form-urlencoded` | 对象 `object` | + +**注意**:其他类型注入空值,需用 `@Request` 手动处理 + +--- + +### @HTTPHeaders + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:获取 HTTP 请求头中的字段 + +**语法**:`@HTTPHeaders()` + +**类型**:`IncomingHttpHeaders` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api' }) +export class HeaderController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/info' }) + async getInfo(@HTTPHeaders() headers: IncomingHttpHeaders) { + const auth = headers['authorization']; + const custom = headers['x-custom']; + return { auth, custom }; + } +} +``` + +#### 使用要点 + +- Headers key 会自动转为小写,取值时使用小写字符 +- 获取单个值:`headers['x-custom']` + +--- + +### @Cookies + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:从 HTTP Cookie 中读取会话数据 + +**语法**:`@Cookies()` + +**类型**:`HTTPCookies` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api' }) +export class SessionController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/session' }) + async getSession(@Cookies() cookies: HTTPCookies) { + const session = cookies.get('sessionId'); + return { session }; + } +} +``` + +#### 使用要点 + +- 使用 `cookies.get(key)` 读取 Cookie +- 使用 `{ signed: false }` 读取未签名的 Cookie + +--- + +### @Request + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:访问完整的 HTTP 请求对象 + +**语法**:`@Request()` + +**类型**:`HTTPRequest` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api' }) +export class RequestController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/debug' }) + async getDebug(@Request() request: HTTPRequest) { + const url = request.url; + const method = request.method; + const contentType = request.headers.get('content-type'); + const rawBody = await request.text(); + return { url, method, contentType, bodyLength: rawBody.length }; + } +} +``` + +#### 使用要点 + +- `request.url`:请求 URL +- `request.method`:请求方法 +- `request.headers`:Headers 对象 +- `request.text()`:读取请求体为文本 +- `request.arrayBuffer()`:读取请求体为 ArrayBuffer + +### @Context + +**装饰器类型**:参数装饰器(Parameter Decorator) + +**使用场景**:访问 Egg 框架的 Context 对象 + +**语法**:`@Context()` + +**类型**:`EggContext` + +#### 快速参考 + +```typescript +@HTTPController({ path: '/api' }) +export class DebugController { + @HTTPMethod({ method: HTTPMethodEnum.GET, path: '/debug' }) + async debug(@Context() ctx: EggContext) { + return { + app: ctx.app.name, + ip: ctx.ip, + userAgent: ctx.get('user-agent') + }; + } +} +``` diff --git a/packages/skills/egg/SKILL.md b/packages/skills/egg/SKILL.md new file mode 100644 index 0000000000..1c22fa5160 --- /dev/null +++ b/packages/skills/egg/SKILL.md @@ -0,0 +1,217 @@ +--- +name: egg +description: 本技能用于处理 EGG 框架。它提供基于用户意图在核心概念和控制器之间做选择的决策指导。作为所有 EGG 相关问题的入口点使用。 +allowed-tools: Read +--- + +# EGG 决策指南 + +## 概述 + +本技能帮助根据用户意图和任务类型确定使用哪个专用的 EGG 技能。EGG 文档组织为两个主要领域: + +1. **核心概念**(`@eggjs/skills-core`):模块架构、依赖注入、对象生命周期 +2. **控制器**(`@eggjs/skills-controller`):用于 API 端点的各种协议特定控制器 + +## 技能选择逻辑 + +### 使用 @eggjs/skills-core 当用户询问: + +**用户询问关于:** +- 模块架构和组织 +- `@SingletonProto` vs `@ContextProto` 的使用 +- 使用 `@Inject` 的依赖注入 +- 对象生命周期和实例化 +- 模块之间的访问控制(`AccessLevel`) +- 模块配置(`module.yml`、`package.json`) +- 使用限定符解决命名冲突 + +**触发关键词:** +- module、workspace、modules +- singleton、单例、@SingletonProto +- context、request context、@ContextProto +- inject、injection、dependency injection、@Inject +- prototype、lifecycle、实例化 +- access level、private、public、@ModuleQualifier +- configuration、module config + +**示例查询:** +- "如何在 EGG 中创建模块?" +- "SingletonProto 和 ContextProto 有什么区别?" +- "如何注入服务?" +- "如何访问其他模块的对象?" +- "TEGG 中的 AccessLevel 是什么?" + +### 使用 @eggjs/skills-controller 当用户询问: + +**用户询问关于:** +- 创建 API 端点或接口 +- 实现特定协议处理器(HTTP、MCP 等) +- 连接到外部系统或客户端 +- 处理传入的请求/响应 +- 控制器级别的装饰器和模式 + +**触发关键词:** +- controller、控制器 +- HTTP、API、REST、endpoint +- MCP、LLM、AI、tool +- schedule、timer、cron、scheduled、定时 +- SSE、streaming、server-sent events + +**示例查询:** +- "如何创建 HTTP controller?" +--- + +## 决策框架 + +### 步骤 1:识别意图类型 + +询问:**用户是在询问构建块/框架内部 OR 实现特定接口?** + +**构建块/框架内部** → 使用 `@eggjs/skills-core` +- 理解 EGG 如何工作 +- 组织代码结构 +- 管理对象生命周期 +- 设置模块 + +**实现特定接口** → 使用 `@eggjs/skills-controller` +- 创建 API/端点 +- 处理不同协议 +- 处理请求/响应 + +### 步骤 2:检查模糊意图 + +如果用户的意图可能是核心 OR 控制器(例如,"如何实现一个需要跨模块访问的服务?"): + +**决策优先级**:核心概念优先 + +理由:即使服务将在控制器中使用,关于跨模块访问(`AccessLevel`)的基本问题是一个核心概念。一旦理解了核心结构,用户就可以在控制器中应用它。 + +**行动**: +1. 加载 `@eggjs/skills-core` +2. 解释概念(例如,`AccessLevel.PUBLIC`) +3. 核心解释后,建议:"如果你需要在特定控制器中使用它,请参阅 `@eggjs/skills-controller`" + +### 步骤 3:协议/用例特定指示器 + +| 协议/用例 | 主要技能 | 次要技能 | +|---------|---------|---------| +| HTTP API | Controller | - | +| MCP | Controller | - | +| Scheduled Tasks | Controller | - | +| Cross-module injection | **Core** | - | +| Module structure | **Core** | - | +| Object lifecycle | **Core** | - | + +--- + +## 冲突解决规则 + +### 规则 1:基础优先 + +当问题同时涉及核心概念 AND 控制器实现时: +- **示例**:"如何实现一个 HTTP 控制器可以使用的单例服务?" +- **决策**:从 `@eggjs/skills-core` 开始解释 SingletonProto 和 AccessLevel +- **后续**:"现在你理解了服务定义,使用 `@eggjs/skills-controller` 实现注入此服务的 HTTP 控制器。" + +### 规则 2:显式覆盖 + +如果用户明确提及特定控制器类型: +- **示例**:"如何使用 HTTPController 配合 ContextProto 服务?" +- **决策**:加载 `@eggjs/skills-controller`(HTTPController 是显式的) +- **后续**:解释 HTTPController 实现,如果需要简要提及来自核心概念的 ContextProto + +### 规则 3:学习语境 + +如果用户问"什么是 X?"或"Y 如何工作?": +- **核心概念问题** → `@eggjs/skills-core` +- **控制器类型问题** → `@eggjs/skills-controller` +- **一般 EGG 问题** → 使用本技能的决策框架 + +如果用户问"如何实现 X?"或"给我看 Y 的代码?": +- **实现特定问题** → 根据决策框架加载特定技能 + +--- + +## 快速参考表 + +| 用户意图 | 关键词 | 使用技能 | +|---------|--------|---------| +| Module architecture | module、workspace、organization | @eggjs/skills-core | +| Object lifecycle | singleton、context、lifecycle | @eggjs/skills--core | +| Dependency injection | inject、@Inject、dependency | @eggjs/skills-core | +| Access control | private、public、cross-module | @eggjs/skills-core | +| HTTP endpoints | HTTP、API、REST | @eggjs/skills-controller | +| LLM/AI integration | MCP、tool、prompt | @eggjs/skills-controller | +| Scheduling | schedule、cron、timer | @eggjs/skills-controller | + +--- + +## 示例 + +### 示例 1:明确的核心意图 + +**用户**:"@SingletonProto 和 @ContextProto 有什么区别?" + +**分析**:问题关于核心装饰器和对象生命周期 +**决策**:加载 `@eggjs/skills-core` + +**用户**:"如何在 EGG 中创建模块?" + +**分析**:问题关于模块架构(核心概念) +**决策**:加载 `@eggjs/skills-core` + +### 示例 2:明确的控制器意图 + +**用户**:"如何创建返回 JSON 的 HTTP controller?" + +**分析**:问题关于实现特定协议(HTTP) +**决策**:加载 `@eggjs/skills-controller` + +### 示例 3:模糊意图(核心 > 控制器) + +**用户**:"我需要创建一个可以被 HTTP 控制器使用的服务。如何实现?" + +**分析**:用户需要理解核心概念(跨模块访问)AND 控制器实现 +**决策**:首先加载 `@eggjs/skills-core`(基础) + +**响应**: +1. 解释 `@SingletonProto` 配合 `AccessLevel.PUBLIC` 使服务可访问 +2. 展示如何注入服务:`@Inject() myService: MyService` +3. 后续:"现在你可以在 HTTPController 中注入此服务。实现详情请参阅 `@eggjs/skills-controller`。" + +### 示例 4:显式控制器加上核心知识 + +**用户**:"如何在 HTTPController 中使用 @Inject 访问用户服务?" + +**分析**:用户明确提及 HTTPController(控制器类型)但询问 @Inject(核心概念) +**决策**:加载 `@eggjs/skills-controller`(HTTPController 是明确意图) + +**响应**: +1. 展示配合 `@Inject` 的 HTTPController 实现 +2. 简要解释 @Inject 如何工作(核心概念摘要) +3. 注意:"包含限定符的详细 @Inject 使用,请参阅 `@eggjs/skills-core`" + +--- + +## 路由最佳实践 + +1. **优先考虑显式控制器提及**:如果用户命名特定控制器(HTTP),即使涉及核心概念也使用控制器技能 + +2. **基础先行**:如果实现前需要理解核心概念,先解释核心概念 + +3. **简短上下文可以接受**:当路由到一个技能时,提及另一个技能的存在以供后续问题 + +4. **混合响应可接受**:当意图真正混合时,提供两个技能的简短上下文但专注于主要意图 + +5. **渐进式披露**:除非明确要求,不要同时加载两个技能。让用户引导探索 + +--- + +## 技能交互 + +本技能(`@eggjs/skills-tegg`)应该: +- 为框架内部概念路由到 `@eggjs/skills-core` +- 为协议特定实现路由到 `@eggjs/skills-controller` +- 当意图模糊时提供决策指导 +- 当存在有用的上下文时交叉引用技能 diff --git a/packages/skills/egg/references/.gitkeep b/packages/skills/egg/references/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/skills/package.json b/packages/skills/package.json new file mode 100644 index 0000000000..990bea0304 --- /dev/null +++ b/packages/skills/package.json @@ -0,0 +1,44 @@ +{ + "name": "@eggjs/skills", + "version": "4.0.0-beta.36", + "description": "agent skills for egg", + "keywords": [ + "egg", + "skill" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/packages/skills", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "author": "eggjs", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "packages/skills" + }, + "files": [ + "**/*.md" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": {}, + "dependencies": {}, + "devDependencies": {}, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/skills/tegg-core/SKILL.md b/packages/skills/tegg-core/SKILL.md new file mode 100644 index 0000000000..fa3b155bec --- /dev/null +++ b/packages/skills/tegg-core/SKILL.md @@ -0,0 +1,312 @@ +--- +name: egg-core +description: 本技能用于处理 EGG 基础核心概念,包括模块架构、@SingletonProto、@ContextProto 和 @Inject 装饰器。用于理解 EGG 的基础构建块、依赖注入和对象生命周期管理。 +allowed-tools: Read +--- + +# EGG 核心概念 + +## 概述 + +EGG 是一个基于 TypeScript 的企业应用框架,提供装饰器驱动的依赖注入和基于模块的架构。本技能涵盖核心概念:模块架构、对象生命周期(SingletonProto/ContextProto)和依赖注入(Inject)。 + +## 模块架构 + +### 什么是模块? + +模块是 EGG 中基础的代码组织单元。只有模块内的控制器、原型和其他对象会被框架扫描和加载。模块之间相互独立,但可以通过 `@Inject` 装饰器访问其他模块的对象。 + +### 定义模块 + +在目录中添加包含 `eggModule.name` 字段的 `package.json` 文件来声明该目录为模块: + +```json +{ + "name": "foo", + "eggModule": { + "name": "foo" + } +} +``` + +**重要提示**:模块名称不能包含 `-` 或其他特殊字符;使用驼峰命名规则。 + +### 模块发现 + +**自动扫描**(默认): +- 框架扫描最多 10 层目录 +- 查找所有在 `package.json` 中有 `eggModule.name` 的目录 +- 同时扫描 `dependencies` 中有 `eggModule.name` 的 npm 包 + +**手动声明**(仅标准应用,通过 `config/module.json`): +```json +[ + { "path": "../app/module-a" }, + { "package": "@alipay/common-module" } +] +``` + +### 模块配置 + +在模块根目录创建 `module.yml` 用于模块特定配置: + +```yaml +oneapi: + - appname: eggmosn + api: + EchoFacade: {} +foo: bar +``` + +通过 `@Inject()` 注入配置,使用 `moduleConfig`: + +```typescript +interface ModuleConfig { + foo: string; +} + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class ConfigService { + @Inject() + private readonly moduleConfig: ModuleConfig; + + async hello(): Promise { + return `hello ${this.moduleConfig.foo}`; + } +} +``` + +### 模块组织(最佳实践) + +- 新应用:按功能在 `app/` 目录中组织 +- 存量应用:保留存量代码在 `app/controller`/`app/service`,将模块放在 `app/module/` + +- 可以在 `dependencies` 中导入 npm 包作为额外模块 + +## 对象生命周期:SingletonProto vs ContextProto + +### SingletonProto + +**定义**:在整个应用生命周期内只实例化一次。由于只全局初始化一个对象,可以提升性能。 + +**使用场景**: +- 大多数服务的默认选择 +- 不存储请求上下文的无状态服务 +- 可以注入 ContextProto 对象 + +**装饰器模式**: +```typescript +@SingletonProto({ + name?: string, // 可选的实例名称 + accessLevel?: AccessLevel // PRIVATE 或 PUBLIC(默认:PRIVATE) +}) +``` + +**示例**: +```typescript +@SingletonProto() +export class HelloService { + async hello(): Promise { + return 'hello'; + } +} + +@SingletonProto({ + name: 'worldInterface', // 自定义引用名称 + accessLevel: AccessLevel.PUBLIC // 跨模块可用 +}) +export class WorldService { + async world(): Promise { + return 'world!'; + } +} +``` + +### ContextProto + +**定义**:每个请求都会创建一个新实例。 + +**使用场景**: +- 存储必须在服务之间共享的请求上下文信息 +- 需要不同请求之间的隔离 + +**装饰器模式**: +```typescript +@ContextProto({ + name?: string, // 可选的实例名称 + accessLevel?: AccessLevel // PRIVATE 或 PUBLIC(默认:PRIVATE) +}) +``` + +**示例**: +```typescript +@ContextProto() +export class RequestContext { + userId: string; + traceId: string; +} + +@SingletonProto() +export class UserService { + @Inject() + requestContext: RequestContext; + + async getProfile(): Promise { + return { userId: this.requestContext.userId }; + } +} +``` + +**重要提示**:大多数服务应该使用 `SingletonProto` 以获得更好的性能。只有当请求上下文必须在服务之间共享以确保请求之间隔离时,才使用 `ContextProto`。 + +### AccessLevel + +- `AccessLevel.PRIVATE`:仅在相同模块内可访问(默认) +- `AccessLevel.PUBLIC`:可从其他模块访问 + +## 依赖注入:@Inject + +### 基本用法 + +使用 `@Inject()` 注入其他 Proto 或 Egg 对象: + +```typescript +@SingletonProto() +export class HelloService { + @Inject() + fooService: FooService; // 注入另一个 Proto + + @Inject() + logger: EggLogger; // 注入 Egg 对象 + + async hello(user: User): Promise { + this.logger.info(`[HelloService] ${this.fooService.hello()}`); + } +} +``` + +### @Inject 参数 + +```typescript +@Inject({ + name?: string, // 实例名称(默认:属性名称) + proto?: string // Proto 名称(默认:属性名称) +}) +``` + +### 注入配置示例 + +使用自定义名称: +```typescript +@SingletonProto() +export class Foo { + @Inject({ name: 'worldInterface' }) + worldService: WorldService; +} +``` + +### 解决命名冲突 + +**模块内冲突**(相同名称,不同初始化类型): +```typescript +@SingletonProto() +export class HelloService { + @Inject() + @InitTypeQualifier(ObjectInitType.CONTEXT) + logger: EggLogger; // 指定 CONTEXT 级别的 logger +} +``` + +**跨模块冲突**: +```typescript +@SingletonProto() +export class HelloService { + @Inject() + @ModuleQualifier('foo') // 指定模块名称 + helloAdapter: HelloAdapter; +} +``` + +### 注入 Egg 对象 + +**配置**: +```typescript +@SingletonProto() +class Foo { + @Inject() + config: EggAppConfig; +} +``` + +**日志**: +```typescript +@SingletonProto() +class FooService { + @Inject() + logger: EggLogger; // 应用特定的 logger + + @Inject() + coreLogger: EggLogger; // 核心 logger + + @Inject() + fooLogger: EggLogger; // 配置中的自定义 logger +} +``` + +**服务**: +```typescript +@SingletonProto() +class FooService { + @Inject() + service: Service; + + get xxxService() { + return this.service.xxxService; + } +} +``` + +**HttpClient**: +```typescript +@SingletonProto() +class Foo { + @Inject() + httpclient: EggHttpClient; +} +``` + +## 重要约束 + +- **无循环依赖**:Proto 或模块之间都不能有循环依赖 +- **无同名冲突**:一个模块不能有相同名称和初始化类型的 Proto +- **只注入需要的对象**:不要直接注入 `app` 或 `ctx`;注入特定对象 + +## 快速决策指南 + +| 场景 | 使用装饰器 | +|------|----------| +| 无状态服务 | `@SingletonProto()` | +| 跨服务共享的请求级状态 | `@ContextProto()` | +| 需要跨模块访问 | `@SingletonProto({ accessLevel: AccessLevel.PUBLIC })` | +| 注入依赖 | `@Inject()` | +| 使用自定义名称注入 | `@Inject({ name: 'customName' })` | +| 从特定模块注入 | `@Inject() @ModuleQualifier('moduleName')` | +| 注入特定初始化类型 | `@Inject() @InitTypeQualifier(ObjectInitType.CONTEXT)` | + +## 最佳实践 + +- 默认使用 `@SingletonProto()` 以获得更好的性能 +- 只有在需要隔离和共享请求上下文时才使用 `@ContextProto()` +- 只有在真正需要跨模块访问时才使用 `AccessLevel.PUBLIC` +- 按业务领域或功能组织模块 +- 保持模块小巧且专注 + +## 参考资料 + +详细的模块文档,请参阅:`references/module.md` + +Inject 装饰器使用,请参阅:`references/inject.md` + +SingletonProto 详情,请参阅:`references/singleton-proto.md` + +ContextProto 详情,请参阅:`references/context-proto.md` diff --git a/packages/skills/tegg-core/references/.gitkeep b/packages/skills/tegg-core/references/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From c40e58542ad41c987f367c7a8386100c33d86d4c Mon Sep 17 00:00:00 2001 From: killa Date: Thu, 5 Feb 2026 11:08:18 +0800 Subject: [PATCH 2/3] docs(skills): add MCP controller reference and skill writing guidelines (#5786) - Add references/mcp-controller.md with common errors, file conventions, scenario-based decision tree, and end-to-end examples - Update CLAUDE.md with skill writing methodology: prioritize gap-filling over doc reformatting, interview maintainers for undocumented knowledge - Fix typo in controller SKILL.md (refercens -> references) Co-authored-by: Claude Opus 4.5 --- CLAUDE.md | 114 +++++++ packages/skills/controller/SKILL.md | 7 +- .../controller/references/mcp-controller.md | 286 ++++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 packages/skills/controller/references/mcp-controller.md diff --git a/CLAUDE.md b/CLAUDE.md index 6b3853d968..07cdd220ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,12 @@ This is the **Eggjs** framework - a progressive Node.js framework for building e - Multi-client support with singleton pattern - Weak dependency mode for optional Redis connections - Extends Application and Agent with redis property +- **`packages/skills/`** - AI agent skills for Egg framework (@eggjs/skills) + - Pure markdown documentation package (no source code) + - Provides structured guidance for AI assistants working with Egg + - `egg/` - Entry point skill that routes to specialized skills + - `controller/` - Controller implementation skill (HTTP, MCP, Schedule) + - `tegg-core/` - Core framework concepts skill (modules, DI, lifecycle) - **`examples/`** - Example applications - `helloworld-commonjs/` - CommonJS example - `helloworld-typescript/` - TypeScript example @@ -442,6 +448,114 @@ Key points: - The `publishConfig.exports` overrides `exports` during npm publish - All plugins must include `build`, `clean`, and `prepublishOnly` scripts +### Skills Package Structure + +The `packages/skills/` directory contains AI agent skills — pure markdown documentation that guides AI assistants when working with the Egg framework. Skills are published as the `@eggjs/skills` npm package containing only `.md` files. + +> **Skill 编写基础知识**:SKILL.md 格式、frontmatter 规范、目录结构、progressive disclosure、写作风格等通用知识请使用 `/ant-skill-creator` skill 获取指导。以下仅记录 Egg 项目特有的约定。 + +#### Egg Skills 架构 + +Skills 采用分层路由模式: + +- **入口 skill** (`egg/`) — 分析用户意图,通过关键词匹配和决策逻辑路由到专业 skill +- **专业 skills** — 提供特定领域的深度指导: + - `tegg-core/` — 核心概念:模块、依赖注入、生命周期、AccessLevel + - `controller/` — 实现指导:HTTPController、MCPController、Schedule + +#### Egg Skill Frontmatter 约定 + +- **`name`**:入口 skill 使用 `egg`,专业 skill 以 `egg-` 为前缀(如 `egg-controller`、`egg-core`) +- **`allowed-tools`**:统一使用 `Read`(纯文档指导型,不修改文件) +- **`description`**: + - 中文:以"本技能用于..."开头,包含触发关键词 + - 英文:以"Use when..."开头,包含触发关键词 + +#### 专业 Skill 编写规范 + +**入口 Skill(如 `egg/`)编写要点:** + +对应 ant-skill-creator 的 **Workflow-Based** 模式。 + +1. 决策框架包含明确步骤:识别意图 → 检查模糊意图 → 协议/用例特定指示 +2. 为每个专业 skill 列出中英文触发关键词 +3. 冲突解决规则:明确意图模糊时的优先级(如"基础优先"——核心概念优先于控制器实现) +4. 示例分析:多个完整示例,格式为 用户查询 → 分析 → 决策 → 响应策略 +5. 快速参考表:用户意图 → 关键词 → 推荐 skill 映射 +6. 交叉引用话术:回答完主问题后,附"如需了解 X,请参阅 `@eggjs/skills-xxx`" + +**专业 Skill 两种组织模式:** + +| 模式 | ant-skill-creator 对应 | 适用场景 | SKILL.md 内容 | references/ 用途 | +| ------------------------------ | ---------------------- | ------------------ | ----------------------- | ------------------ | +| **概念型**(如 `tegg-core/`) | Reference-Based | 概念解释、架构理解 | 自包含的深度内容 | 更深入的专题文档 | +| **索引型**(如 `controller/`) | Workflow-Based | 多种实现方式的选择 | 精简的决策树 + 快速参考 | 每种实现的详细指南 | + +**概念型 Skill 内容结构:** + +1. 概述(一段话说明覆盖范围) +2. 按概念分块,每个概念包含:定义 → 使用场景 → 装饰器/API 模式 → 代码示例 +3. 重要约束(反模式、限制) +4. 快速决策指南表(场景 → 推荐方式) +5. 最佳实践 +6. 参考资料链接 + +**索引型 Skill 内容结构:** + +1. 决策树(根据需求选择实现方式) +2. 每种类型的快速参考(装饰器、参数、特点) +3. 最佳实践 +4. 参考资料链接 + +#### Reference 文档编写要点 + +Reference 文档(`references/*.md`)**不需要 YAML frontmatter**,只有 `SKILL.md` 才需要。 + +**核心原则:Skill 不是文档的重新排版,而是填补"文档到生产代码"之间的缝隙。** + +Skill 的价值 = 文档 + 实践经验 - 重复内容。如果内容和 `site/docs/` 中的文档高度重复,说明 skill 写得不对。 + +| 内容类型 | 该放文档(site/docs/) | 该放 Skill(packages/skills/) | +| ------------------- | ---------------------- | ------------------------------ | +| API 签名、参数说明 | Yes | No(引用文档即可) | +| 易错点 / 常见错误 | 部分 | **Yes(重点)** | +| 完整端到端模式 | No | **Yes** | +| 跨模块集成知识 | 散落各处 | **Yes(聚合)** | +| 文件放置 / 命名约定 | 部分 | **Yes** | +| 场景化决策树 | No | **Yes** | + +**编写 Reference 文档的具体步骤:** + +1. **先读对应的文档**(如 `site/docs/zh-CN/basics/mcpcontroller.md`),理解已有内容 +2. **向维护者提问,收集文档未覆盖的知识**,重点关注: + - 导入路径、命名约定等易错点(AI 最容易犯的错) + - 文件应该放在哪个目录?命名规则是什么? + - 和其他模块(Module、Service、DI)的集成关系 + - 哪些配置参数实际开发中需要关心,哪些用默认值即可 + - 哪些是内部扩展机制不需要暴露给应用开发者 +3. **以常见错误表开头** — 把 AI 最容易写错的地方放在最醒目的位置(如错误的导入路径、错误的 API 用法) +4. **文件约定** — 目录结构、命名规则、配置文件位置,这些文档里通常不会详细说明 +5. **场景化决策树** — 从用户意图出发("让 AI 查数据"),而非从 API 出发("@MCPTool") +6. **端到端完整示例** — 从配置文件到控制器到 Service 到测试,展示所有相关文件和它们的关系 +7. **精简的装饰器对照表放末尾** — 仅作为速查,不展开 API 详解 + +#### 添加新 Skill + +1. 在 `packages/skills/` 下创建目录:`packages/skills//` +2. 创建 `SKILL.md`(格式规范参考 `/ant-skill-creator`,frontmatter 遵循上述 Egg 约定) +3. 创建 `references/` 目录(初始为空时放置 `.gitkeep`) +4. 按需在 `references/*.md` 中添加详细参考文档 +5. 更新入口 skill(`egg/SKILL.md`)的路由逻辑以包含新 skill +6. 如果 skill 涉及 controller 类型,同时更新 `controller/SKILL.md` 决策树 + +#### 添加新 Reference 文档 + +1. 在 skill 的 `references/` 目录中创建 `.md` 文件 +2. 遵循命名规范(kebab-case,描述性命名:`http-controller.md`、`mcp-controller.md`) +3. 包含完整的代码示例和决策树 +4. 更新父级 `SKILL.md` 引用新文档 +5. 如果 `references/` 中已有文件,移除 `.gitkeep` + ### Tool Packages Structure Tool packages (like egg-bin) should be placed in the `tools/` directory: diff --git a/packages/skills/controller/SKILL.md b/packages/skills/controller/SKILL.md index a45fb624b0..d3f74012ce 100644 --- a/packages/skills/controller/SKILL.md +++ b/packages/skills/controller/SKILL.md @@ -17,22 +17,26 @@ allowed-tools: Read 2. 定时任务,可以使用 Schedule,参考 `references/schedule.md` -3. AI集成 MCP,可以使用 MCPController,参考 `refercens/mcp-controller.md` +3. AI集成 MCP,可以使用 MCPController,参考 `references/mcp-controller.md` ``` + --- ## 控制器快速参考 ### HTTPController + - **装饰器**:`@HTTPController`、`@HTTPMethod` - **参数**:`@HTTPParam`、`@HTTPQuery`、`@HTTPBody`、`@HTTPHeaders`、`@Cookies`、`@Request`、`@Context` - **详细文档**:`references/httpcontroller.md` ### MCPController + - **装饰器**:`@MCPController`、`@MCPTool`、`@MCPPrompt`、`@MCPResource` - **特点**:集成 LLM、Zod 验证、登录态支持 ### Schedule + - **装饰器**:`@Schedule`、配置 - **模式**:Worker/All @@ -51,6 +55,7 @@ allowed-tools: Read ## 参考资料 详细的控制器开发文档: + - `references/http-controller.md` - HTTP 接口完整指南 - `references/mcp-controller.md` - MCP/LLM 集成 - `references/schedule.md` - 定时任务 diff --git a/packages/skills/controller/references/mcp-controller.md b/packages/skills/controller/references/mcp-controller.md new file mode 100644 index 0000000000..2868eb35b2 --- /dev/null +++ b/packages/skills/controller/references/mcp-controller.md @@ -0,0 +1,286 @@ +# MCPController 开发指南 + +## 常见错误 + +生成 MCPController 代码时,**必须**注意以下易错点: + +| 错误写法 | 正确写法 | 说明 | +| -------------------------------- | ------------------------------------- | ---------------------------------------- | +| `from 'egg'` | `from '@eggjs/tegg'` | 所有 MCP 装饰器和类型来自 `@eggjs/tegg` | +| `import z from 'zod'` | `import { z } from '@eggjs/tegg/zod'` | 框架内置 zod,必须使用具名导入 | +| `z.object({ name: z.string() })` | `{ name: z.string() }` | Schema 使用普通对象,不要用 `z.object()` | +| `args: ToolArgs` | `args: ToolArgs` | 类型参数必须用 `typeof` | +| `@MCPController` 不加括号 | `@MCPController()` | 装饰器必须带括号调用 | + +--- + +## 文件约定 + +### 文件位置与命名 + +MCPController 放在 module 的 `controller/` 目录下,命名规则为 `{Name}MCPController.ts`: + +``` +app/module-name/ +├── controller/ +│ ├── PackageMCPController.ts ← MCP 控制器 +│ └── PackageHTTPController.ts ← 同模块可共存 HTTP 控制器 +└── service/ + └── PackageService.ts +``` + +### 插件配置 + +在 `config/plugin.ts` 中启用: + +```typescript +plugin.mcpProxy = true; +``` + +### 路径配置 + +在 `config/config.default.ts` 中配置 MCP 路径(通常不需要修改,以下为默认值): + +```typescript +import { randomUUID } from 'node:crypto'; + +export default () => { + const config = { + mcp: { + sseInitPath: '/mcp/sse', + sseMessagePath: '/mcp/message', + streamPath: '/mcp/stream', + statelessStreamPath: '/mcp/stateless/stream', + sessionIdGenerator: randomUUID, + }, + }; + return config; +}; +``` + +当使用 `@MCPController({ name: 'myServer' })` 声明命名服务时,路径自动变为: + +- `/mcp/myServer/sse` +- `/mcp/myServer/message` +- `/mcp/myServer/stream` +- `/mcp/myServer/stateless/stream` + +### AccessLevel + +`@MCPController` 装饰器内部已默认设置 AccessLevel(PUBLIC),不需要再手动声明。 + +--- + +## 场景决策树 + +``` +用户需要什么? + +├─ "让 AI 能查数据 / 执行操作" +│ └─ → @MCPTool + @Inject Service 处理业务 +│ +├─ "给 AI 一个提示词模板" +│ └─ → @MCPPrompt +│ +├─ "让 AI 读取某类资源数据" +│ ├─ 资源地址固定 → @MCPResource({ uri: '...' }) +│ └─ 资源地址动态 → @MCPResource({ template: [...] }) +│ +└─ "Tool 执行中要推送进度" + └─ → @MCPTool + @Extra() 获取 sendNotification +``` + +--- + +## 端到端完整示例 + +以下展示一个完整的 MCP 功能从配置到测试的所有文件: + +### 1. 插件配置 — `config/plugin.ts` + +```typescript +plugin.mcpProxy = true; +``` + +### 2. 控制器 — `app/npm/controller/PackageMCPController.ts` + +```typescript +import { + MCPController, MCPTool, MCPToolResponse, + MCPPrompt, MCPPromptResponse, + MCPResource, MCPResourceResponse, + ToolArgs, ToolArgsSchema, + PromptArgs, PromptArgsSchema, + Inject, +} from '@eggjs/tegg'; +import { z } from '@eggjs/tegg/zod'; + +import { PackageService } from '../service/PackageService.ts'; + +const SearchSchema = { + name: z.string({ description: 'npm package name' }), +}; + +const SummarySchema = { + name: z.string(), +}; + +@MCPController() +export class PackageMCPController { + @Inject() + private readonly packageService: PackageService; + + @MCPTool({ description: 'Search npm package info' }) + async searchPackage( + @ToolArgsSchema(SearchSchema) args: ToolArgs, + ): Promise { + const pkg = await this.packageService.findByName(args.name); + if (!pkg) { + return { content: [{ type: 'text', text: `Package ${args.name} not found` }] }; + } + return { content: [{ type: 'text', text: JSON.stringify(pkg) }] }; + } + + @MCPPrompt({ description: 'Generate package summary' }) + async summarize( + @PromptArgsSchema(SummarySchema) args: PromptArgs, + ): Promise { + return { + messages: [{ + role: 'user', + content: { + type: 'text', + text: `Summarize the npm package: ${args.name}`, + }, + }], + }; + } + + @MCPResource({ + template: ['npm://{name}/{?version}', { list: undefined }], + }) + async getPackageReadme(uri: URL): Promise { + const name = uri.hostname; + const readme = await this.packageService.getReadme(name); + return { contents: [{ uri: uri.toString(), text: readme }] }; + } +} +``` + +### 3. Service — `app/npm/service/PackageService.ts` + +```typescript +import { SingletonProto, Inject } from '@eggjs/tegg'; + +@SingletonProto() +export class PackageService { + async findByName(name: string) { + // 业务逻辑 + } + + async getReadme(name: string): Promise { + // 业务逻辑 + } +} +``` + +### 4. 单元测试 — `test/npm/controller/PackageMCPController.test.ts` + +```typescript +import assert from 'node:assert'; +import { app } from 'egg-mock/bootstrap'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +describe('PackageMCPController', () => { + it('should search package via tool', async () => { + app.mockCsrf(); + const client: Client = await app.mcpClient(); + + const tools = await client.listTools(); + assert(tools.tools.some(t => t.name === 'searchPackage')); + + const res = await client.callTool({ + name: 'searchPackage', + arguments: { name: 'egg' }, + }); + assert(res.content[0].type === 'text'); + }); + + it('should get prompt', async () => { + app.mockCsrf(); + const client: Client = await app.mcpClient(); + + const res = await client.getPrompt({ + name: 'summarize', + arguments: { name: 'egg' }, + }); + assert(res.messages.length > 0); + }); + + it('should read resource', async () => { + app.mockCsrf(); + const client: Client = await app.mcpClient(); + + const res = await client.readResource({ + uri: 'npm://egg?version=4.0.0', + }); + assert(res.contents.length > 0); + }); +}); +``` + +--- + +## @Extra() 的使用场景 + +`@Extra()` 装饰器注入 `ToolExtra` 对象,提供两个能力: + +### 发送通知(长任务进度推送) + +```typescript +@MCPTool() +async longTask( + @ToolArgsSchema(Schema) args: ToolArgs, + @Extra() extra: ToolExtra, +): Promise { + const { sendNotification } = extra; + for (let i = 0; i < 10; i++) { + await sendNotification({ + method: 'notifications/message', + params: { level: 'info', data: `Step ${i + 1}/10` }, + }); + // ... 执行步骤 + } + return { content: [{ type: 'text', text: 'Done' }] }; +} +``` + +### 读取自定义请求头 + +```typescript +@MCPTool() +async myTool( + @ToolArgsSchema(Schema) args: ToolArgs, + @Extra() extra: ToolExtra, +): Promise { + const headers = extra.requestInfo?.headers; + // 处理自定义 header +} +``` + +--- + +## 装饰器参考 + +| 装饰器 | 用途 | 常用参数 | 返回类型 | +| --------------------- | ------------ | ------------------------------------------ | --------------------- | +| `@MCPController()` | 声明控制器 | `{ name?: string }` | - | +| `@MCPTool()` | 声明工具 | `{ name?: string, description?: string }` | `MCPToolResponse` | +| `@MCPPrompt()` | 声明提示词 | `{ name?: string, description?: string }` | `MCPPromptResponse` | +| `@MCPResource()` | 声明资源 | `{ uri: string }` 或 `{ template: [...] }` | `MCPResourceResponse` | +| `@ToolArgsSchema()` | Tool 参数 | Zod Schema 普通对象 | - | +| `@PromptArgsSchema()` | Prompt 参数 | Zod Schema 普通对象 | - | +| `@Extra()` | 额外上下文 | - | `ToolExtra` | +| `@Inject()` | 注入 Service | - | - | + +**注意**:`@MCPController` 的 `version`、`timeout` 等参数通常不需要配置。 From 2d4bb2444cff524d7b71fed1e2af9584c74492b4 Mon Sep 17 00:00:00 2001 From: killa Date: Sun, 15 Feb 2026 22:48:08 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(skills):=20add=20eval=20framework=20fo?= =?UTF-8?q?r=20skill=20routing=20and=20quality=20assess=E2=80=A6=20(#5788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …ment Introduces an evaluation system using Vitest to test skill routing decisions and answer quality via LLM-as-Judge. Includes PTY-based claude CLI wrapper (via macOS `script`) to bypass Vitest stdio capture, robust JSON extraction from LLM output, and static skill YAML validation tests. Co-authored-by: Claude Opus 4.5 --- packages/skills/PLAN.md | 396 ++++++++++++++++++ packages/skills/eval/.gitignore | 1 + packages/skills/eval/dynamic/quality.eval.ts | 54 +++ packages/skills/eval/dynamic/routing.eval.ts | 47 +++ .../skills/eval/fixtures/quality-cases.ts | 57 +++ .../skills/eval/fixtures/routing-cases.ts | 69 +++ packages/skills/eval/lib/claude-cli.ts | 117 ++++++ packages/skills/eval/lib/judge.ts | 47 +++ packages/skills/eval/lib/setup.ts | 78 ++++ packages/skills/eval/lib/skill-loader.ts | 87 ++++ packages/skills/eval/lib/types.ts | 63 +++ packages/skills/eval/static/validate.test.ts | 156 +++++++ packages/skills/package.json | 12 +- packages/skills/tsconfig.json | 3 + packages/skills/vitest.config.ts | 12 + pnpm-lock.yaml | 99 +++++ 16 files changed, 1296 insertions(+), 2 deletions(-) create mode 100644 packages/skills/PLAN.md create mode 100644 packages/skills/eval/.gitignore create mode 100644 packages/skills/eval/dynamic/quality.eval.ts create mode 100644 packages/skills/eval/dynamic/routing.eval.ts create mode 100644 packages/skills/eval/fixtures/quality-cases.ts create mode 100644 packages/skills/eval/fixtures/routing-cases.ts create mode 100644 packages/skills/eval/lib/claude-cli.ts create mode 100644 packages/skills/eval/lib/judge.ts create mode 100644 packages/skills/eval/lib/setup.ts create mode 100644 packages/skills/eval/lib/skill-loader.ts create mode 100644 packages/skills/eval/lib/types.ts create mode 100644 packages/skills/eval/static/validate.test.ts create mode 100644 packages/skills/tsconfig.json create mode 100644 packages/skills/vitest.config.ts diff --git a/packages/skills/PLAN.md b/packages/skills/PLAN.md new file mode 100644 index 0000000000..03084ac59e --- /dev/null +++ b/packages/skills/PLAN.md @@ -0,0 +1,396 @@ +# Skills 评测方案设计 + +## 目标 + +为 `packages/skills/` 设计一套评测体系,覆盖两个层面: + +1. **静态校验** — 验证 Skill 文件的结构正确性、引用完整性 +2. **动态评测** — 用 LLM-as-Judge 评估 AI 基于 Skill 生成回答的质量 + +全部手动触发运行,不集成 CI。 + +--- + +## 目录结构 + +``` +packages/skills/ +├── egg/ +├── controller/ +├── tegg-core/ +├── eval/ # 新增:评测目录 +│ ├── static/ +│ │ └── validate.test.ts # 静态校验测试 +│ ├── dynamic/ +│ │ ├── routing.eval.ts # 入口路由评测 +│ │ └── quality.eval.ts # 内容质量评测 +│ ├── fixtures/ +│ │ ├── routing-cases.ts # 路由测试用例 +│ │ └── quality-cases.ts # 质量测试用例 +│ └── lib/ +│ ├── skill-loader.ts # Skill 文件加载器 +│ ├── judge.ts # LLM-as-Judge 核心逻辑 +│ └── types.ts # 共享类型定义 +├── vitest.config.ts +├── package.json +└── tsconfig.json +``` + +--- + +## 第一部分:静态校验 + +### 校验项 + +| 校验项 | 说明 | +| -------------------- | ------------------------------------------------------------- | +| **Frontmatter 格式** | 每个 SKILL.md 必须包含 `name`、`description`、`allowed-tools` | +| **引用文件存在性** | SKILL.md 中提到的 `references/*.md` 文件必须存在 | +| **交叉引用一致性** | 入口 skill 提到的子 skill 目录必须存在且包含 SKILL.md | +| **Markdown 结构** | 标题层级合理(以 `# ` 开头,不跳级) | +| **决策表完整性** | 入口 skill 的路由表中每个 skill 都有对应目录 | + +### 实现方式 + +使用 vitest + node:assert 编写测试,通过 Node.js fs API 读取文件并解析: + +```typescript +// eval/static/validate.test.ts +import { describe, it } from 'vitest'; +import assert from 'node:assert/strict'; +import { loadAllSkills } from '../lib/skill-loader.ts'; + +describe('Skill 静态校验', () => { + describe('Frontmatter', () => { + it('每个 SKILL.md 包含必填字段: name, description, allowed-tools', ...); + }); + + describe('引用完整性', () => { + it('SKILL.md 中引用的 references/ 文件均存在', ...); + it('入口 skill 引用的子 skill 目录均存在', ...); + }); + + describe('Markdown 结构', () => { + it('标题层级不跳级', ...); + }); +}); +``` + +--- + +## 第二部分:动态评测(LLM-as-Judge) + +### 评测维度 + +动态评测分为两个子场景: + +#### 2.1 路由评测 — 入口 Skill 是否正确路由 + +测试 `egg/SKILL.md` 的决策逻辑:给定用户查询,判断 AI 是否路由到正确的子 skill。 + +**测试用例结构:** + +```typescript +// eval/fixtures/routing-cases.ts +export const routingCases: RoutingCase[] = [ + { + query: '如何创建 HTTP controller?', + expectedSkill: 'controller', + reason: '明确提到 controller,属于协议实现', + }, + { + query: '@SingletonProto 和 @ContextProto 有什么区别?', + expectedSkill: 'tegg-core', + reason: '关于对象生命周期,属于核心概念', + }, + { + query: '我需要创建一个可以被 HTTP 控制器使用的服务', + expectedSkill: 'tegg-core', + reason: '模糊意图,按规则 1(基础优先)应路由到 core', + }, + // ... 更多用例 +]; +``` + +**测试实现:** + +```typescript +// eval/dynamic/routing.eval.ts +import { describe, it } from 'vitest'; +import assert from 'node:assert/strict'; +import Anthropic from '@anthropic-ai/sdk'; +import { loadSkillContent } from '../lib/skill-loader.ts'; +import { routingCases } from '../fixtures/routing-cases.ts'; + +const client = new Anthropic(); +const AVAILABLE_SKILLS = ['controller', 'tegg-core']; + +describe('路由评测', () => { + // 加载入口 skill 作为 system prompt + const entrySkillContent = loadSkillContent('egg'); + + for (const { query, expectedSkill, reason } of routingCases) { + it(`"${query}" → ${expectedSkill}`, async () => { + // 1. 将 SKILL.md 作为 system prompt,发送用户查询 + const response = await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + system: [ + entrySkillContent, + // 约束输出格式,让 AI 只做路由决策 + `你是 EGG 框架技能路由器。根据上面的决策指南,分析用户查询并选择应该加载的技能。`, + `可选技能: ${AVAILABLE_SKILLS.join(', ')}`, + `只输出 JSON: {"skill": "<技能名>", "reason": "<简要理由>"}`, + ].join('\n\n'), + messages: [{ role: 'user', content: query }], + }); + + // 2. 解析 AI 回答中的路由选择 + const text = response.content[0].type === 'text' ? response.content[0].text : ''; + const parsed = JSON.parse(text); + + // 3. 断言路由正确性 + assert.equal(parsed.skill, expectedSkill, + `路由错误: 期望 "${expectedSkill}" 但得到 "${parsed.skill}"` + + `\n 用例理由: ${reason}` + + `\n AI 理由: ${parsed.reason}` + ); + }); + } +}); +``` + +#### 2.2 内容质量评测 — 子 Skill 回答质量 + +测试各子 skill 对领域问题的回答质量。 + +**测试用例结构:** + +```typescript +// eval/fixtures/quality-cases.ts +export const qualityCases: QualityCase[] = [ + { + skill: 'controller', + query: '如何创建一个 POST 接口接收 JSON body?', + criteria: [ + '使用 @HTTPController 装饰器', + '使用 @HTTPMethod 且 method 为 POST', + '使用 @HTTPBody() 获取请求体', + '包含完整可运行的代码示例', + ], + references: ['references/http-controller.md'], // 需要加载的参考文档 + }, + { + skill: 'tegg-core', + query: '如何让一个服务可以被其他模块访问?', + criteria: [ + '提到 AccessLevel.PUBLIC', + '使用 @SingletonProto 装饰器', + '解释跨模块访问机制', + ], + references: [], + }, + // ... 更多用例 +]; +``` + +**测试实现:** + +```typescript +// eval/dynamic/quality.eval.ts +import { describe, it } from 'vitest'; +import assert from 'node:assert/strict'; +import Anthropic from '@anthropic-ai/sdk'; +import { loadSkillContent, loadReference } from '../lib/skill-loader.ts'; +import { qualityCases } from '../fixtures/quality-cases.ts'; +import { judge } from '../lib/judge.ts'; + +const client = new Anthropic(); + +describe('内容质量评测', () => { + for (const testCase of qualityCases) { + describe(`[${testCase.skill}] ${testCase.query}`, () => { + let aiResponse: string; + + // Step 1: 加载 skill 内容作为 system prompt,向被测 LLM 提问 + it('生成回答', async () => { + const skillContent = loadSkillContent(testCase.skill); + const refContents = testCase.references + .map(ref => loadReference(testCase.skill, ref)); + + const systemPrompt = [skillContent, ...refContents].join('\n\n---\n\n'); + + const response = await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 2048, + system: systemPrompt, + messages: [{ role: 'user', content: testCase.query }], + }); + + aiResponse = response.content[0].type === 'text' ? response.content[0].text : ''; + assert.ok(aiResponse.length > 0, 'AI 应该返回非空回答'); + }); + + // Step 2: 用 Judge LLM 对回答逐项评分 + it('通过质量评审', async () => { + const result = await judge(client, { + query: testCase.query, + response: aiResponse, + criteria: testCase.criteria, + }); + + // 输出详细评分到 console 供人工查看 + console.log(` 得分: ${result.totalScore} (${result.passed}/${result.total})`); + for (const item of result.details) { + const icon = item.score === 1 ? '✓' : '✗'; + console.log(` ${icon} ${item.criterion}: ${item.reason}`); + } + + // 断言:所有 criteria 都应满足 + assert.ok(result.totalScore >= 0.8, + `质量不达标: ${result.totalScore} < 0.8\n` + + result.details + .filter(d => d.score === 0) + .map(d => ` ✗ ${d.criterion}: ${d.reason}`) + .join('\n') + ); + }); + }); + } +}); +``` + +### LLM-as-Judge 实现 + +```typescript +// eval/lib/judge.ts +import type Anthropic from '@anthropic-ai/sdk'; +import type { JudgeInput, JudgeResult, JudgeDetail } from './types.ts'; + +export async function judge( + client: Anthropic, + input: JudgeInput, +): Promise { + const criteriaList = input.criteria + .map((c, i) => `${i + 1}. ${c}`) + .join('\n'); + + const response = await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + system: '你是 AI 回答质量评估专家。严格按照 JSON 格式输出评分结果。', + messages: [{ + role: 'user', + content: `请根据评分标准,对以下 AI 回答逐项评分。 + +## 评分标准 +${criteriaList} + +## 用户问题 +${input.query} + +## AI 回答 +${input.response} + +## 输出格式(严格 JSON) +{ + "details": [ + { "criterion": "标准内容", "score": 0 或 1, "reason": "简要理由" } + ] +}`, + }], + }); + + const text = response.content[0].type === 'text' ? response.content[0].text : ''; + const parsed = JSON.parse(text); + const details: JudgeDetail[] = parsed.details; + const passed = details.filter(d => d.score === 1).length; + + return { + details, + passed, + total: details.length, + totalScore: passed / details.length, + }; +} +``` + +### 评测报告 + +运行评测后生成 JSON 报告: + +```json +{ + "timestamp": "2026-02-05T10:00:00Z", + "routing": { + "total": 10, + "correct": 9, + "accuracy": 0.9, + "failures": [ + { + "query": "...", + "expected": "tegg-core", + "actual": "controller", + "reason": "..." + } + ] + }, + "quality": { + "controller": { + "cases": 5, + "avg_score": 0.85, + "details": [...] + }, + "tegg-core": { + "cases": 5, + "avg_score": 0.90, + "details": [...] + } + } +} +``` + +--- + +## 第三部分:技术选型与依赖 + +| 组件 | 选型 | 理由 | +| --------------------- | ------------------ | ------------------------------ | +| 测试框架 | vitest | 遵循 monorepo 标准 | +| 断言库 | node:assert/strict | Node.js 内置,零依赖 | +| YAML frontmatter 解析 | gray-matter | 成熟的 frontmatter 解析库 | +| LLM 调用 | @anthropic-ai/sdk | 使用 Claude API 做评测和 Judge | +| 报告输出 | JSON 文件 | 简单可读,方便后续扩展为可视化 | + +### package.json scripts + +```json +{ + "scripts": { + "test": "vitest run --config vitest.config.ts eval/static/", + "eval": "vitest run --config vitest.config.ts eval/dynamic/", + "eval:routing": "vitest run --config vitest.config.ts eval/dynamic/routing.eval.ts", + "eval:quality": "vitest run --config vitest.config.ts eval/dynamic/quality.eval.ts" + } +} +``` + +- `test` — 运行静态校验(快速,无 API 调用) +- `eval` — 运行全部动态评测 +- `eval:routing` — 仅运行路由评测 +- `eval:quality` — 仅运行内容质量评测 + +动态评测需设置 `ANTHROPIC_API_KEY` 环境变量。 + +--- + +## 实施步骤 + +1. 在 worktree (`egg-skills-eval`) 中添加依赖:vitest、gray-matter、@anthropic-ai/sdk +2. 添加 `vitest.config.ts` 和更新 `package.json` scripts +3. 创建 `eval/lib/` 基础工具:skill-loader、types、judge +4. 实现 `eval/static/validate.test.ts` 静态校验 +5. 编写路由测试用例 `eval/fixtures/routing-cases.ts` +6. 实现 `eval/dynamic/routing.eval.ts` 路由评测 +7. 编写质量测试用例 `eval/fixtures/quality-cases.ts` +8. 实现 `eval/dynamic/quality.eval.ts` 内容质量评测 diff --git a/packages/skills/eval/.gitignore b/packages/skills/eval/.gitignore new file mode 100644 index 0000000000..e9ed58f77b --- /dev/null +++ b/packages/skills/eval/.gitignore @@ -0,0 +1 @@ +workspace/ diff --git a/packages/skills/eval/dynamic/quality.eval.ts b/packages/skills/eval/dynamic/quality.eval.ts new file mode 100644 index 0000000000..32d1ee6842 --- /dev/null +++ b/packages/skills/eval/dynamic/quality.eval.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; + +import { describe, it, beforeAll, afterAll } from 'vitest'; + +import { qualityCases } from '../fixtures/quality-cases.ts'; +import { claudeChat } from '../lib/claude-cli.ts'; +import { judge } from '../lib/judge.ts'; +import { setupWorkspace, cleanWorkspace } from '../lib/setup.ts'; + +describe('内容质量评测', () => { + beforeAll(() => { + setupWorkspace(); + }); + + afterAll(() => { + cleanWorkspace(); + }); + + for (const testCase of qualityCases) { + describe(`[${testCase.skill}] ${testCase.query}`, () => { + let aiResponse: string; + + it('生成回答', async () => { + // 直接发送用户查询,skills 自然加载 + aiResponse = await claudeChat(testCase.query); + assert.ok(aiResponse.length > 0, 'AI 应该返回非空回答'); + }); + + it('通过质量评审', async () => { + const result = await judge({ + query: testCase.query, + response: aiResponse, + criteria: testCase.criteria, + }); + + // 输出详细评分供人工查看 + console.log(` 得分: ${result.totalScore} (${result.passed}/${result.total})`); + for (const item of result.details) { + const icon = item.score === 1 ? ' ✓' : ' ✗'; + console.log(` ${icon} ${item.criterion}: ${item.reason}`); + } + + assert.ok( + result.totalScore >= 0.8, + `质量不达标: ${result.totalScore} < 0.8\n` + + result.details + .filter((d) => d.score === 0) + .map((d) => ` ✗ ${d.criterion}: ${d.reason}`) + .join('\n'), + ); + }); + }); + } +}); diff --git a/packages/skills/eval/dynamic/routing.eval.ts b/packages/skills/eval/dynamic/routing.eval.ts new file mode 100644 index 0000000000..701b0278e8 --- /dev/null +++ b/packages/skills/eval/dynamic/routing.eval.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; + +import { describe, it, beforeAll, afterAll } from 'vitest'; + +import { routingCases } from '../fixtures/routing-cases.ts'; +import { claudeChat } from '../lib/claude-cli.ts'; +import { setupWorkspace, cleanWorkspace } from '../lib/setup.ts'; + +const AVAILABLE_SKILLS = ['controller', 'tegg-core']; + +describe('路由评测', () => { + beforeAll(() => { + setupWorkspace(); + }); + + afterAll(() => { + cleanWorkspace(); + }); + + for (const { query, expectedSkill, reason } of routingCases) { + it(`"${query}" → ${expectedSkill}`, async () => { + // 通过 append-system-prompt 要求输出路由决策 JSON + // skills 会被 claude 自然发现和加载 + const text = await claudeChat(query, { + appendSystemPrompt: [ + '你需要根据已安装的 skills 决定使用哪个 skill 来回答用户问题。', + `可选技能: ${AVAILABLE_SKILLS.join(', ')}`, + '只输出 JSON: {"skill": "<技能名>", "reason": "<简要理由>"},不要输出其他内容。', + ].join('\n'), + }); + + // 从 LLM 输出中提取第一个 JSON 对象,兼容裸 JSON、markdown 代码块、夹杂解释文字等情况 + const match = text.match(/\{[\s\S]*\}/); + assert.ok(match, `未能从输出中提取 JSON:\n${text}`); + + const parsed = JSON.parse(match[0]) as { skill: string; reason: string }; + + assert.equal( + parsed.skill, + expectedSkill, + `路由错误: 期望 "${expectedSkill}" 但得到 "${parsed.skill}"` + + `\n 用例理由: ${reason}` + + `\n AI 理由: ${parsed.reason}`, + ); + }); + } +}); diff --git a/packages/skills/eval/fixtures/quality-cases.ts b/packages/skills/eval/fixtures/quality-cases.ts new file mode 100644 index 0000000000..bac338f921 --- /dev/null +++ b/packages/skills/eval/fixtures/quality-cases.ts @@ -0,0 +1,57 @@ +import type { QualityCase } from '../lib/types.ts'; + +export const qualityCases: QualityCase[] = [ + // === controller skill === + { + skill: 'controller', + query: '如何创建一个 POST 接口接收 JSON body?', + criteria: [ + '使用 @HTTPController 装饰器', + '使用 @HTTPMethod 且 method 为 POST', + '使用 @HTTPBody() 获取请求体', + '包含完整可运行的代码示例', + ], + references: ['references/http-controller.md'], + }, + { + skill: 'controller', + query: '如何从 URL 路径中获取参数?', + criteria: ['使用 @HTTPParam 装饰器', '展示路径中的参数定义(如 :id)', '包含代码示例'], + references: ['references/http-controller.md'], + }, + { + skill: 'controller', + query: '如何实现 SSE 流式响应?', + criteria: [ + '使用 HTTPController', + '涉及 Readable stream 或 async generator', + '设置正确的 content-type', + '包含代码示例', + ], + references: ['references/http-controller.md'], + }, + + // === tegg-core skill === + { + skill: 'tegg-core', + query: '如何让一个服务可以被其他模块访问?', + criteria: ['提到 AccessLevel.PUBLIC', '使用 @SingletonProto 装饰器', '解释跨模块访问机制', '包含代码示例'], + references: [], + }, + { + skill: 'tegg-core', + query: 'SingletonProto 和 ContextProto 应该怎么选?', + criteria: [ + '解释 SingletonProto 是全局单例', + '解释 ContextProto 是每请求创建', + '给出选择建议(默认用 Singleton,需要请求隔离用 Context)', + ], + references: [], + }, + { + skill: 'tegg-core', + query: '如何在 EGG 中定义一个模块?', + criteria: ['提到 package.json 中的 eggModule.name 字段', '说明模块名不能包含特殊字符', '包含 package.json 示例'], + references: [], + }, +]; diff --git a/packages/skills/eval/fixtures/routing-cases.ts b/packages/skills/eval/fixtures/routing-cases.ts new file mode 100644 index 0000000000..73d55d9613 --- /dev/null +++ b/packages/skills/eval/fixtures/routing-cases.ts @@ -0,0 +1,69 @@ +import type { RoutingCase } from '../lib/types.ts'; + +export const routingCases: RoutingCase[] = [ + // === 明确的 controller 意图 === + { + query: '如何创建 HTTP controller?', + expectedSkill: 'controller', + reason: '明确提到 controller,属于协议实现', + }, + { + query: '如何创建返回 JSON 的 HTTP controller?', + expectedSkill: 'controller', + reason: '明确的 HTTP 协议实现', + }, + { + query: '如何创建定时任务?', + expectedSkill: 'controller', + reason: 'schedule 属于控制器类型', + }, + { + query: '如何实现 MCP tool?', + expectedSkill: 'controller', + reason: 'MCP 属于控制器类型', + }, + { + query: 'How to create an API endpoint that accepts POST requests?', + expectedSkill: 'controller', + reason: '英文查询,API endpoint 属于 HTTP controller', + }, + + // === 明确的 core 意图 === + { + query: '@SingletonProto 和 @ContextProto 有什么区别?', + expectedSkill: 'tegg-core', + reason: '关于对象生命周期,属于核心概念', + }, + { + query: '如何在 EGG 中创建模块?', + expectedSkill: 'tegg-core', + reason: '模块架构是核心概念', + }, + { + query: '如何注入服务?', + expectedSkill: 'tegg-core', + reason: '依赖注入是核心概念', + }, + { + query: '如何访问其他模块的对象?', + expectedSkill: 'tegg-core', + reason: '跨模块访问(AccessLevel)是核心概念', + }, + { + query: 'What is AccessLevel in TEGG?', + expectedSkill: 'tegg-core', + reason: '英文查询,AccessLevel 是核心概念', + }, + + // === 模糊意图(按规则应路由到 core) === + { + query: '我需要创建一个可以被 HTTP 控制器使用的服务', + expectedSkill: 'tegg-core', + reason: '模糊意图,按规则 1(基础优先)应路由到 core', + }, + { + query: '如何实现一个需要跨模块访问的服务?', + expectedSkill: 'tegg-core', + reason: '跨模块访问是核心概念,基础优先', + }, +]; diff --git a/packages/skills/eval/lib/claude-cli.ts b/packages/skills/eval/lib/claude-cli.ts new file mode 100644 index 0000000000..98963ee8af --- /dev/null +++ b/packages/skills/eval/lib/claude-cli.ts @@ -0,0 +1,117 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EVAL_WORKSPACE } from './setup.ts'; + +export interface ClaudeOptions { + /** 工作目录,默认使用评测工作区(有 skills 安装) */ + cwd?: string; + /** 追加 system prompt(不覆盖默认 + skills) */ + appendSystemPrompt?: string; + /** 指定模型 */ + model?: string; + /** 最大输出 tokens */ + maxTokens?: number; +} + +function shellQuote(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +function spawnClaude(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const tempDir = mkdtempSync(join(tmpdir(), 'claude-eval-')); + const cleanup = (): void => { + try { + rmSync(tempDir, { recursive: true }); + } catch { + /* ignore */ + } + }; + + // 写一个 wrapper 脚本,避免 script 命令的 shell 转义问题 + const cmdFile = join(tempDir, 'cmd.sh'); + const escapedArgs = args.map(shellQuote).join(' '); + writeFileSync(cmdFile, `#!/bin/sh\nexec claude ${escapedArgs}\n`, { mode: 0o755 }); + + // 用 macOS `script -q` 分配真正的 PTY,让 claude 看到 isTTY=true + // 不能直接传 /dev/tty FD 给 stdio(Bun kqueue EINVAL),也不能纯 pipe(claude 需要 TTY) + const child = spawn('script', ['-q', '/dev/null', cmdFile], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let buffer = ''; + + child.stdout!.on('data', (chunk: Buffer) => { + // console.log(chunk.toString()); + buffer += chunk.toString('utf-8'); + }); + + const timer = setTimeout(() => { + child.kill('SIGTERM'); + cleanup(); + reject(new Error('claude process timed out after 300s')); + }, 300_000); + + child.on('error', (err) => { + clearTimeout(timer); + cleanup(); + reject(err); + }); + + child.on('close', () => { + clearTimeout(timer); + cleanup(); + // script 退出时 PTY 会回显 ^D(EOF),过滤掉控制字符 + // eslint-disable-next-line no-control-regex -- 需要过滤 PTY EOF 控制字符 + const result = buffer.replace(/\x04/g, '').replace(/\^D/g, '').trim(); + if (result) { + resolve(result); + } else { + reject(new Error('No result received from claude')); + } + }); + }); +} + +/** + * 在评测工作区中调用 claude -p + * skills 通过 .claude/skills/ 目录自然发现和加载 + */ +export async function claudeChat(userMessage: string, options: ClaudeOptions = {}): Promise { + const args = ['-p']; + + if (options.model) { + args.push('--model', options.model); + } + + if (options.maxTokens) { + args.push('--max-tokens', String(options.maxTokens)); + } + + if (options.appendSystemPrompt) { + args.push('--append-system-prompt', options.appendSystemPrompt); + } + + args.push(userMessage); + + return spawnClaude(args, options.cwd ?? EVAL_WORKSPACE); +} + +/** + * 纯净模式调用 claude -p(禁用 skills,用于 Judge 评分) + */ +export async function claudePlain(userMessage: string, systemPrompt?: string): Promise { + const args = ['-p', '--disable-slash-commands']; + + if (systemPrompt) { + args.push('--system-prompt', systemPrompt); + } + + args.push(userMessage); + + return spawnClaude(args, EVAL_WORKSPACE); +} diff --git a/packages/skills/eval/lib/judge.ts b/packages/skills/eval/lib/judge.ts new file mode 100644 index 0000000000..b94cdbb85a --- /dev/null +++ b/packages/skills/eval/lib/judge.ts @@ -0,0 +1,47 @@ +import { claudePlain } from './claude-cli.ts'; +import type { JudgeInput, JudgeResult, JudgeDetail } from './types.ts'; + +/** + * 使用 LLM-as-Judge 对 AI 回答进行逐项评分 + * 通过 claudePlain(禁用 skills)调用,避免 skill 干扰评分 + */ +export async function judge(input: JudgeInput): Promise { + const criteriaList = input.criteria.map((c, i) => `${i + 1}. ${c}`).join('\n'); + + const text = await claudePlain( + `请根据评分标准,对以下 AI 回答逐项评分。 + +## 评分标准 +${criteriaList} + +## 用户问题 +${input.query} + +## AI 回答 +${input.response} + +## 输出格式(严格 JSON,不要包含 markdown 代码块标记) +{ + "details": [ + { "criterion": "标准内容", "score": 0, "reason": "简要理由" } + ] +}`, + '你是 AI 回答质量评估专家。严格按照 JSON 格式输出评分结果,不要输出其他内容。', + ); + + // 从 LLM 输出中提取第一个 JSON 对象,兼容裸 JSON、markdown 代码块、夹杂解释文字等情况 + const match = text.match(/\{[\s\S]*\}/); + if (!match) { + throw new Error(`未能从 Judge 输出中提取 JSON:\n${text}`); + } + const parsed = JSON.parse(match[0]) as { details: JudgeDetail[] }; + const details = parsed.details; + const passed = details.filter((d) => d.score === 1).length; + + return { + details, + passed, + total: details.length, + totalScore: details.length > 0 ? passed / details.length : 0, + }; +} diff --git a/packages/skills/eval/lib/setup.ts b/packages/skills/eval/lib/setup.ts new file mode 100644 index 0000000000..62837ed0d5 --- /dev/null +++ b/packages/skills/eval/lib/setup.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** skills 源目录(packages/skills/) */ +const SKILLS_SRC = path.resolve(import.meta.dirname, '..', '..'); + +/** 评测工作区目录 */ +export const EVAL_WORKSPACE = path.resolve(import.meta.dirname, '..', 'workspace'); + +/** 工作区内的 skills 安装目录 */ +const SKILLS_INSTALL_DIR = path.join(EVAL_WORKSPACE, '.claude', 'skills'); + +/** 需要排除的非 skill 目录 */ +const EXCLUDE_DIRS = new Set(['eval', 'node_modules']); + +/** + * 发现 packages/skills/ 下的所有 skill 目录 + */ +function discoverSkillSrcDirs(): string[] { + const entries = fs.readdirSync(SKILLS_SRC, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && !EXCLUDE_DIRS.has(e.name)) + .filter((e) => fs.existsSync(path.join(SKILLS_SRC, e.name, 'SKILL.md'))) + .map((e) => e.name); +} + +/** + * 初始化评测工作区:将 skills 通过 symlink 安装到 .claude/skills/ 下 + * + * 结构: + * eval/workspace/ + * └── .claude/ + * └── skills/ + * ├── egg/ → symlink → packages/skills/egg/ + * ├── controller/ → symlink → packages/skills/controller/ + * └── tegg-core/ → symlink → packages/skills/tegg-core/ + */ +export function setupWorkspace(): void { + // 清理旧的安装目录 + if (fs.existsSync(SKILLS_INSTALL_DIR)) { + fs.rmSync(SKILLS_INSTALL_DIR, { recursive: true }); + } + fs.mkdirSync(SKILLS_INSTALL_DIR, { recursive: true }); + + const skillDirs = discoverSkillSrcDirs(); + for (const dir of skillDirs) { + const src = path.join(SKILLS_SRC, dir); + const dest = path.join(SKILLS_INSTALL_DIR, dir); + fs.symlinkSync(src, dest, 'dir'); + } +} + +/** + * 安装指定的 skills 到工作区(用于单独测试特定 skill) + */ +export function setupWorkspaceWith(skillNames: string[]): void { + if (fs.existsSync(SKILLS_INSTALL_DIR)) { + fs.rmSync(SKILLS_INSTALL_DIR, { recursive: true }); + } + fs.mkdirSync(SKILLS_INSTALL_DIR, { recursive: true }); + + for (const dir of skillNames) { + const src = path.join(SKILLS_SRC, dir); + const dest = path.join(SKILLS_INSTALL_DIR, dir); + if (fs.existsSync(src)) { + fs.symlinkSync(src, dest, 'dir'); + } + } +} + +/** + * 清理评测工作区 + */ +export function cleanWorkspace(): void { + if (fs.existsSync(EVAL_WORKSPACE)) { + fs.rmSync(EVAL_WORKSPACE, { recursive: true }); + } +} diff --git a/packages/skills/eval/lib/skill-loader.ts b/packages/skills/eval/lib/skill-loader.ts new file mode 100644 index 0000000000..7f57ade6ea --- /dev/null +++ b/packages/skills/eval/lib/skill-loader.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import matter from 'gray-matter'; + +import type { LoadedSkill, SkillMeta } from './types.ts'; + +/** skills 根目录 */ +const SKILLS_ROOT = path.resolve(import.meta.dirname, '..', '..'); + +/** 需要排除的非 skill 目录 */ +const EXCLUDE_DIRS = new Set(['eval', 'node_modules']); + +/** + * 获取所有 skill 目录名(包含 SKILL.md 的子目录) + */ +export function discoverSkillDirs(): string[] { + const entries = fs.readdirSync(SKILLS_ROOT, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && !EXCLUDE_DIRS.has(e.name)) + .filter((e) => fs.existsSync(path.join(SKILLS_ROOT, e.name, 'SKILL.md'))) + .map((e) => e.name); +} + +/** + * 加载单个 skill 的 SKILL.md 内容 + */ +export function loadSkill(skillDir: string): LoadedSkill { + const skillPath = path.join(SKILLS_ROOT, skillDir); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + const raw = fs.readFileSync(skillMdPath, 'utf-8'); + const { data, content } = matter(raw); + + return { + dir: skillDir, + meta: data as SkillMeta, + content: content.trim(), + path: skillPath, + }; +} + +/** + * 加载所有 skills + */ +export function loadAllSkills(): LoadedSkill[] { + return discoverSkillDirs().map((dir) => loadSkill(dir)); +} + +/** + * 加载 skill 的 SKILL.md 内容(纯文本,含 frontmatter)用作 system prompt + */ +export function loadSkillContent(skillDir: string): string { + const skillMdPath = path.join(SKILLS_ROOT, skillDir, 'SKILL.md'); + return fs.readFileSync(skillMdPath, 'utf-8'); +} + +/** + * 加载 skill 下的 reference 文件内容 + */ +export function loadReference(skillDir: string, refPath: string): string { + const fullPath = path.join(SKILLS_ROOT, skillDir, refPath); + return fs.readFileSync(fullPath, 'utf-8'); +} + +/** + * 列出 skill 目录下 references/ 中的所有 .md 文件 + */ +export function listReferences(skillDir: string): string[] { + const refsDir = path.join(SKILLS_ROOT, skillDir, 'references'); + if (!fs.existsSync(refsDir)) return []; + + return fs.readdirSync(refsDir).filter((f) => f.endsWith('.md')); +} + +/** + * 从 SKILL.md 内容中提取引用的 references 路径 + * 匹配 `references/*.md` 模式 + */ +export function extractReferencePaths(content: string): string[] { + const regex = /`references\/([^`]+\.md)`/g; + const paths: string[] = []; + let match; + while ((match = regex.exec(content)) !== null) { + paths.push(match[1]); + } + return [...new Set(paths)]; +} diff --git a/packages/skills/eval/lib/types.ts b/packages/skills/eval/lib/types.ts new file mode 100644 index 0000000000..3bb48256a1 --- /dev/null +++ b/packages/skills/eval/lib/types.ts @@ -0,0 +1,63 @@ +/** 路由评测用例 */ +export interface RoutingCase { + /** 用户查询 */ + query: string; + /** 期望路由到的 skill 名称 */ + expectedSkill: string; + /** 用例理由说明 */ + reason: string; +} + +/** 质量评测用例 */ +export interface QualityCase { + /** 目标 skill 名称 */ + skill: string; + /** 用户查询 */ + query: string; + /** 评分标准列表 */ + criteria: string[]; + /** 需要额外加载的 reference 文件路径(相对于 skill 目录) */ + references: string[]; +} + +/** Judge 输入 */ +export interface JudgeInput { + query: string; + response: string; + criteria: string[]; +} + +/** Judge 单项评分 */ +export interface JudgeDetail { + criterion: string; + score: 0 | 1; + reason: string; +} + +/** Judge 评分结果 */ +export interface JudgeResult { + details: JudgeDetail[]; + passed: number; + total: number; + totalScore: number; +} + +/** Skill 元数据(SKILL.md frontmatter) */ +export interface SkillMeta { + name: string; + description: string; + 'allowed-tools': string; + [key: string]: unknown; +} + +/** 加载后的 Skill */ +export interface LoadedSkill { + /** skill 目录名 */ + dir: string; + /** frontmatter 元数据 */ + meta: SkillMeta; + /** SKILL.md body 内容(去除 frontmatter) */ + content: string; + /** skill 目录的绝对路径 */ + path: string; +} diff --git a/packages/skills/eval/static/validate.test.ts b/packages/skills/eval/static/validate.test.ts new file mode 100644 index 0000000000..d39b948ed4 --- /dev/null +++ b/packages/skills/eval/static/validate.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, it } from 'vitest'; + +import { + loadAllSkills, + loadSkill, + discoverSkillDirs, + extractReferencePaths, + listReferences, +} from '../lib/skill-loader.ts'; + +const skills = loadAllSkills(); +const skillDirs = discoverSkillDirs(); + +describe('Skill 静态校验', () => { + describe('Frontmatter 格式', () => { + for (const skill of skills) { + describe(`[${skill.dir}]`, () => { + it('包含 name 字段', () => { + assert.ok(skill.meta.name, `SKILL.md 缺少 name 字段`); + assert.equal(typeof skill.meta.name, 'string'); + }); + + it('包含 description 字段', () => { + assert.ok(skill.meta.description, `SKILL.md 缺少 description 字段`); + assert.equal(typeof skill.meta.description, 'string'); + }); + + it('包含 allowed-tools 字段', () => { + assert.ok(skill.meta['allowed-tools'], `SKILL.md 缺少 allowed-tools 字段`); + assert.equal(typeof skill.meta['allowed-tools'], 'string'); + }); + }); + } + }); + + describe('引用文件存在性', () => { + for (const skill of skills) { + const referencedPaths = extractReferencePaths(skill.content); + if (referencedPaths.length === 0) continue; + + describe(`[${skill.dir}]`, () => { + for (const refFile of referencedPaths) { + it(`references/${refFile} 文件存在`, () => { + const fullPath = path.join(skill.path, 'references', refFile); + assert.ok(fs.existsSync(fullPath), `引用的文件不存在: references/${refFile}`); + }); + } + }); + } + }); + + describe('交叉引用一致性', () => { + // 入口 skill(egg/)引用的子 skill 必须存在 + const entrySkill = loadSkill('egg'); + + it('入口 skill 存在', () => { + assert.ok(entrySkill, 'egg/SKILL.md 不存在'); + }); + + // 从入口 skill 中提取提到的 skills-xxx 引用 + const skillRefRegex = /@eggjs\/skills-(\w+)/g; + const referencedSkills: string[] = []; + let match; + while ((match = skillRefRegex.exec(entrySkill.content)) !== null) { + referencedSkills.push(match[1]); + } + + for (const refSkill of new Set(referencedSkills)) { + it(`入口 skill 引用的 @eggjs/skills-${refSkill} 有对应的 skill 目录`, () => { + // skills-core → tegg-core, skills-controller → controller + const possibleDirs = [refSkill, `tegg-${refSkill}`, refSkill.replace(/^tegg-/, '')]; + const found = possibleDirs.some((d) => skillDirs.includes(d)); + assert.ok( + found, + `入口 skill 引用了 @eggjs/skills-${refSkill},但未找到对应目录。` + + `\n 已有的 skill 目录: ${skillDirs.join(', ')}`, + ); + }); + } + }); + + describe('Markdown 结构', () => { + for (const skill of skills) { + describe(`[${skill.dir}]`, () => { + it('以一级标题开头', () => { + const firstHeading = skill.content.match(/^(#{1,6})\s/m); + assert.ok(firstHeading, 'SKILL.md 内容中没有标题'); + assert.equal(firstHeading[1], '#', '第一个标题应为一级标题'); + }); + + it('标题层级不跳级', () => { + const headings = [...skill.content.matchAll(/^(#{1,6})\s+(.+)$/gm)]; + let lastLevel = 0; + + for (const heading of headings) { + const level = heading[1].length; + if (lastLevel > 0 && level > lastLevel + 1) { + assert.fail( + `标题跳级: "${heading[2]}" 是 h${level},但上一个标题是 h${lastLevel}` + + `(最多应为 h${lastLevel + 1})`, + ); + } + lastLevel = level; + } + }); + }); + } + + // 同样校验 references/ 下的 md 文件 + for (const skillDir of skillDirs) { + const refs = listReferences(skillDir); + for (const ref of refs) { + describe(`[${skillDir}/references/${ref}]`, () => { + it('标题层级不跳级', () => { + const content = fs.readFileSync( + path.join(skills.find((s) => s.dir === skillDir)!.path, 'references', ref), + 'utf-8', + ); + const headings = [...content.matchAll(/^(#{1,6})\s+(.+)$/gm)]; + let lastLevel = 0; + + for (const heading of headings) { + const level = heading[1].length; + if (lastLevel > 0 && level > lastLevel + 1) { + assert.fail(`标题跳级: "${heading[2]}" 是 h${level},但上一个标题是 h${lastLevel}`); + } + lastLevel = level; + } + }); + }); + } + } + }); + + describe('决策表完整性', () => { + const entrySkill = loadSkill('egg'); + + it('入口 skill 包含快速参考表', () => { + assert.ok( + entrySkill.content.includes('快速参考表') || entrySkill.content.includes('快速决策'), + '入口 skill 应包含快速参考/决策表', + ); + }); + + it('入口 skill 包含决策框架', () => { + assert.ok( + entrySkill.content.includes('决策框架') || entrySkill.content.includes('决策树'), + '入口 skill 应包含决策框架或决策树', + ); + }); + }); +}); diff --git a/packages/skills/package.json b/packages/skills/package.json index 990bea0304..665f979439 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -35,9 +35,17 @@ "./package.json": "./package.json" } }, - "scripts": {}, + "scripts": { + "test": "vitest run eval/static/", + "eval": "vitest run eval/dynamic/", + "eval:routing": "vitest run eval/dynamic/routing.eval.ts", + "eval:quality": "vitest run eval/dynamic/quality.eval.ts" + }, "dependencies": {}, - "devDependencies": {}, + "devDependencies": { + "gray-matter": "^4.0.3", + "vitest": "catalog:" + }, "engines": { "node": ">=22.18.0" } diff --git a/packages/skills/tsconfig.json b/packages/skills/tsconfig.json new file mode 100644 index 0000000000..4082f16a5d --- /dev/null +++ b/packages/skills/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/skills/vitest.config.ts b/packages/skills/vitest.config.ts new file mode 100644 index 0000000000..a62bf3b142 --- /dev/null +++ b/packages/skills/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, type UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineProject({ + test: { + include: ['eval/**/*.{test,eval}.ts'], + testTimeout: 300_000, + // 用 child_process.fork 代替 worker_threads, cc 会用到 tty + pool: 'forks', + }, +}); + +export default config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a136b90d5..b1462974a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,6 +1341,18 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/skills: + devDependencies: + '@anthropic-ai/sdk': + specifier: ^0.39.0 + version: 0.39.0(encoding@0.1.13) + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + vitest: + specifier: 'catalog:' + version: 4.0.15(@types/node@24.10.2)(@vitest/ui@4.0.15)(esbuild@0.27.0)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.2) + packages/supertest: dependencies: '@types/superagent': @@ -3373,6 +3385,9 @@ importers: packages: + '@anthropic-ai/sdk@0.39.0': + resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -4852,9 +4867,15 @@ packages: '@types/mustache@4.2.6': resolution: {integrity: sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.10.2': resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} @@ -5123,6 +5144,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -6088,6 +6113,10 @@ packages: event-stream@4.0.1: resolution: {integrity: sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -6196,6 +6225,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data-encoder@1.9.0: resolution: {integrity: sha512-rahaRMkN8P8d/tgK/BLPX+WBVM27NbvdXBxqQujBtkDAIFspaRqN7Od7lfdGQA6KAD+f82fYCLBq1ipvcu8qLw==} @@ -7474,6 +7506,15 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -8619,6 +8660,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -8733,6 +8777,9 @@ packages: unconfig-core@7.4.2: resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -8948,6 +8995,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -8960,6 +9010,9 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -9103,6 +9156,18 @@ packages: snapshots: + '@anthropic-ai/sdk@0.39.0(encoding@0.1.13)': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -10265,8 +10330,17 @@ snapshots: '@types/mustache@4.2.6': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 24.10.2 + form-data: 4.0.4 + '@types/node@10.17.60': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.10.2': dependencies: undici-types: 7.16.0 @@ -10551,6 +10625,10 @@ snapshots: abbrev@2.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -11621,6 +11699,8 @@ snapshots: stream-combiner: 0.2.2 through: 2.3.8 + event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} execa@5.1.1: @@ -11779,6 +11859,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} + form-data-encoder@1.9.0: {} form-data@4.0.4: @@ -13185,6 +13267,12 @@ snapshots: node-domexception@1.0.0: {} + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -14480,6 +14568,8 @@ snapshots: totalist@3.0.1: {} + tr46@0.0.3: {} + tree-kill@1.2.2: {} treeverse@3.0.0: {} @@ -14600,6 +14690,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@5.26.5: {} + undici-types@7.16.0: {} undici@5.29.0: @@ -14898,6 +14990,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} + webpack-virtual-modules@0.6.2: optional: true @@ -14907,6 +15001,11 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0