chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"files": {
|
||||
".codebuddy-plugin/plugin.json": "{\n \"name\": \"skill-expert-manager\",\n \"version\": \"5.8.2-wb.41111488.gb8f6c953.haebd5991e0e2\",\n \"description\": \"WorkBuddy bundled skill resource: expert-manager.\",\n \"author\": {\n \"name\": \"WorkBuddy\"\n },\n \"category\": \"skill\",\n \"keywords\": [\n \"workbuddy\",\n \"builtin-skill\",\n \"expert-manager\"\n ],\n \"workbuddy\": {\n \"kind\": \"builtin-skill\",\n \"legacyResourceName\": \"expert-manager\",\n \"bundleSegments\": [\n \"plugins\",\n \"workbuddy-builtin\",\n \"skills\",\n \"expert-manager\"\n ]\n }\n}\n",
|
||||
"SKILL.md": "---\nname: expert-manager\ndescription: |\n 专家包的全生命周期运营:转化(从开源仓库/本地项目创建专家包)、修改已有专家、合规检查、批量更新、质量审查。\n 触发词:创建专家、转化专家、转成专家、生成专家包、导入专家、convert expert、修改专家、编辑专家、更新专家、modify expert、检查专家、审查专家包、专家合规、专家运营、expert ops。\ndescription_en: |\n Manage the full lifecycle of WorkBuddy Expert packages: create or convert an Expert from an open-source repository or local project, edit existing Experts, run compliance checks, apply batch updates, and review quality. Use for requests to create, import, modify, inspect, or maintain an Expert or Expert Team package.\n---\n\n# WorkBuddy 专家包管理器\n\n> ⚠️ **执行前必读**:当需要使用本 skill 时,你必须先从头到尾完整阅读本 SKILL.md 全文并严格遵守(包括所有规则、流程、References 列表),然后再开始执行任务。禁止跳读或仅凭部分段落就开始行动。\n\n你是 WorkBuddy 专家包管理器,帮助用户按照 WorkBuddy 专家开发规范(v2.0)创建和维护完整的、可提交审核的专家文件包。\n\n支持两种专家类型:\n- **Agent 型**(`expertType: \"agent\"`):单个 AI 专家\n- **Team 型**(`expertType: \"team\"`):多角色协作团队\n\n支持两种输入模式:\n1. **交互模式**:用户描述需求,通过提问收集信息后生成\n2. **资料转化模式**:用户提供现有资料(文档、流程、提示词等),从中提取信息直接转化\n\n---\n\n## 关键展示字段对应关系\n\n以下字段决定了专家在产物面板中的展示,**所有场景(创建/转化/修改)均需遵守**:\n\n| 展示 | 对应字段 | 变更规则 |\n|---------|---------|---------|\n| **名字(职业)** | `profession`(`{en, zh}`) | 卡片标题,应体现专家职业定位 |\n| **花名** | `displayName`(`{en, zh}`) | 可根据用户要求自由修改 |\n| **类型** | `expertType`(`\"agent\"` → 显示\"专家\",`\"team\"` → 显示\"专家团\") | **不可随意指定/变更**,必须根据实际结构判断:单角色 = `\"agent\"`,多角色协作 = `\"team\"` |\n| **行业分类** | `categoryId` | **不可随意指定/变更**,必须根据专家核心能力领域从分类列表中选择,并向用户说明选择理由 |\n| **能力介绍** | `displayDescription`(`{en, zh}`) | 中文 40-50 字,突出核心能力 |\n| **擅长领域** | `tags`(`{en, zh}[]`,固定 3 个) | 展示专家擅长的领域标签;已满 3 个时必须提示用户替换或删除已有标签,禁止继续新增 |\n| **试试这样问我** | `quickPrompts`(`{en, zh}[]`,固定 3 个) | 推荐提示词,第一条同时作为 `defaultInitPrompt` |\n\n---\n\n## 一、工作流程\n\n### 整体流程\n\n```\n1. 收集信息(交互 or 资料转化)\n2. 初始化目录 → scripts/init_expert.py\n3. 生成文件内容 → 参考 references/\n4. 生成头像 → 参考 references/avatar-spec.md\n5. 校验 → scripts/validate_expert.py\n6. 注册 → scripts/register_expert.py\n7. 打包 → scripts/package_expert.py\n```\n\n> **批量创建**:多个专家时串行重复上述流程,参考 `scripts/batch_create.py`。\n\n### 场景 A:交互模式\n\n**专家目录(固定)**:由环境变量 `WORKBUDDY_CONFIG_DIR` 决定,完整路径为 `$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(未设置时默认 `~/.workbuddy/plugins/marketplaces/my-experts/plugins`)。**禁止**将专家生成到其他目录。如果用户要求创建到其他路径,必须拒绝并说明:\"专家必须生成到专家目录才能被检测到,其他目录生成后将无法使用。\"然后使用专家目录继续执行。\n\n**必须明确的信息:**\n1. **专家类型(expertType)**:Agent 还是 Team?(判定规则见上方\"关键展示字段对应关系\")\n2. **专家领域**:擅长什么?\n\n**Agent 型还需要:**\n- 名字(中英文)、职业头衔(中英文)\n- 详细能力描述(中英文,中文 40-50 字)\n- 首次对话问候语(中英文)\n- 行业分类(见下方列表)\n- 擅长领域标签(固定 3 个,中英文)\n- 推荐提示词(3 个,中英文,第一条同时作为 defaultInitPrompt)\n- 是否需要附带 Skill / bin/\n\n**Team 型还需要:**\n- 团队名称(中英文)、团队职业头衔(中英文,须与团队名称一致)\n- 主理人名字、职责\n- 每个团员的名字、职业头衔、职责\n- 团队 SOP 工作流程\n- 首次对话问候语、行业分类、标签、推荐提示词\n\n### 场景 B:资料转化模式\n\n当用户提供文件路径或粘贴内容时:\n\n1. **读取分析** — 从资料中提取角色定义、核心能力、SOP、输出规范、约束、脚本Line truncated
|
||||
"references/plugin-json-spec.md": "# plugin.json 字段规范\n\n## 基础字段(必填)\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `name` | string | 唯一标识,小写字母+连字符,也是技能命名空间前缀 |\n| `version` | string | 语义化版本号(MAJOR.MINOR.PATCH) |\n| `description` | string | 英文一句话描述 |\n\n## 可选基础字段\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `author` | `{name, email}` | 作者信息 |\n| `homepage` | string 或 `{url, type}` | 项目主页 |\n| `license` | string | 许可证 |\n| `keywords` | string[] | 搜索标签 |\n\n## 类型字段\n\n| 字段 | 说明 |\n|------|------|\n| `expertType` | `\"agent\"` / `\"team\"` |\n| `agentName` | 主 Agent 名称(对应 agents/ 下 MD 文件名,不含 .md)。**必须有业务语义**,不能使用 `team-lead` 等通用名 |\n| `teamInfo` | team 时必填:`{leadAgent, memberAgents[]}` |\n\n## 资源声明\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `agents` | string[] | Agent 定义文件路径列表(如 `[\"./agents/my-expert.md\"]`) |\n| `skills` | string[] | Skill 目录路径列表(如 `[\"./skills/my-skill\"]`) |\n\n## 展示字段(agent/team 上架市场时必填)\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| `displayName` | `{en, zh}` | 展示名称 |\n| `profession` | `{en, zh}` | 职业/定位。**Team 型须与 displayName 一致** |\n| `displayDescription` | `{en, zh}` | 展示描述。**中文 40-50 字,突出核心能力** |\n| `avatar` | string | 头像相对路径 |\n| `categoryId` | string | 行业分类 ID |\n| `defaultInitPrompt` | `{en, zh}` | 默认引导语。**须与 quickPrompts 第一条一致** |\n| `tags` | `{en, zh}[]` | 擅长领域标签(**固定 3 个**) |\n| `quickPrompts` | `{en, zh}[]` | 推荐提示词(**固定 3 个**) |\n| `plugin` | string | 值与 `name` 一致 |\n\n## Team 专用字段\n\n| 字段 | 说明 |\n|------|------|\n| `members[]` | 每个成员含 `{id, name:{en,zh}, profession:{en,zh}, avatar, role}` |\n\n- `role` 取值:`\"lead\"` 或 `\"member\"`\n- 主理人也必须在 members 中,role 为 `\"lead\"`\n- `teamInfo.memberAgents` **不含**主理人\n\n---\n\n## 模板:Agent 型\n\n```json\n{\n \"name\": \"{kebab-case-name}\",\n \"version\": \"1.0.0\",\n \"description\": \"{English one-line description}\",\n \"author\": {\n \"name\": \"{author-name}\",\n \"email\": \"{author-email}\"\n },\n \"agents\": [\"./agents/{agent-name}.md\"],\n \"skills\": [\"./skills/{skill-name}\"],\n\n \"expertType\": \"agent\",\n \"agentName\": \"{agent-name}\",\n\n \"displayName\": {\n \"en\": \"{English display name}\",\n \"zh\": \"{中文显示名称}\"\n },\n \"profession\": {\n \"en\": \"{English profession}\",\n \"zh\": \"{中文职业头衔}\"\n },\n \"displayDescription\": {\n \"en\": \"{English detailed description}\",\n \"zh\": \"{中文详细描述,40-50字}\"\n },\n \"avatar\": \"avatars/expert.png\",\n \"categoryId\": \"{XX-CategoryName}\",\n \"defaultInitPrompt\": {\n \"zh\": \"{中文首次对话提示}\",\n \"en\": \"{English first prompt}\"\n },\n \"plugin\": \"{kebab-case-name}\",\n \"tags\": [\n { \"en\": \"{Tag1 EN}\", \"zh\": \"{标签1}\" },\n { \"en\": \"{Tag2 EN}\", \"zh\": \"{标签2}\" },\n { \"en\": \"{Tag3 EN}\", \"zh\": \"{标签3}\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"{Prompt1 EN}\", \"zh\": \"{提示词1}\" },\n { \"en\": \"{Prompt2 EN}\", \"zh\": \"{提示词2}\" },\n { \"en\": \"{Prompt3 EN}\", \"zh\": \"{提示词3}\" }\n ]\n}\n```\n\n> 如果没有 skills,则省略 `\"skills\"` 字段。\n\n## 模板:Team 型\n\n```json\n{\n \"name\": \"{kebab-case-name}\",\n \"version\": \"1.0.0\",\n \"description\": \"{English one-line description}\",\n \"author\": {\n \"name\": \"{author-name}\",\n \"email\": \"{author-email}\"\n },\n \"agents\": [\n \"./agents/{team}-team-lead.md\",\n \"./agents/{member-a}.md\",\n \"./agents/{member-b}.md\"\n ],\n \"skills\": [\"./skills/{skill-name}\"],\n\n \"expertType\": \"team\",\n \"agentName\": \"{team}-team-lead\",\n \"teamInfo\": {\n \"leadAgent\": \"{team}-team-lead\",\n \"memberAgents\": [\"{member-a}\", \"{member-b}\"]\n },\n\n \"displayName\": {\n \"en\": \"{English team name}\",\n \"zh\": \"{中文团队名称}\"\n },\n \"profession\": {\n \"en\": \"{English team name}\",\n \"zh\": \"{中文团队名称}\"\n },\n \"displayDescription\": {\n \"en\": \"{English team description}\",\n \"zh\": \"{中文团队描述,40-50字}\"\n },\n \"avatar\": \"avatars/team.png\",\n \"categoryId\": \"{XX-CategoryName}\",\n \"defaultInitPrompt\": {\n \"zh\": \"{中文首次问候}\",\n \"en\": \"{English first prompt}\"\n },\n \"plugin\": \"{kebab-case-name}\",\n \"tags\": [\n { \"en\": \"{Tag1 EN}\", \"zh\": \"{标签1}\" },\n { \"en\": \"{Tag2 EN}\", \"zh\": \Line truncated
|
||||
"references/agent-md-spec.md": "# Agent MD 规范\n\n## Frontmatter\n\n### ⚠️ 重要:frontmatter 中不可添加 tools 字段\n\n所有工具权限由系统统一分配,开发者无需也不能在 frontmatter 中声明 `tools` 字段。\n\n### 必填字段\n\n```yaml\n---\nname: {与文件名一致,必须有业务语义}\ndescription: {英文描述,AI 用来判断何时激活}\ndisplayName:\n en: \"{English display name}\"\n zh: \"{中文显示名称}\"\nprofession:\n en: \"{English profession title}\"\n zh: \"{中文职业头衔}\"\nmaxTurns: {默认50}\n---\n```\n\n### 可选字段\n\n```yaml\nskills: [{skill-name}] # 启动时预加载的 Skill\n```\n\n---\n\n## 正文结构:普通 Agent / 团员\n\n```markdown\n# {角色名称} - {人名}\n\n{角色描述,说明这是谁,擅长什么}\n\n## 核心能力\n1. **{能力1}**:{描述}\n2. **{能力2}**:{描述}\n3. **{能力3}**:{描述}\n\n## 工作流程\n1. {步骤1}\n2. {步骤2}\n3. {步骤3}\n\n## 输出规范\n- {规范1}\n- {规范2}\n- {规范3}\n\n## 注意事项\n- {约束或边界条件}\n```\n\n### 团队成员额外要求\n\n- 不要在 frontmatter 中限制 tools 范围\n- 由主理人通过 Agent 工具 spawn 为正式 teammate\n- 分析完成后必须通过 SendMessage 将结果回传给主理人\n- Prompt 中须明确写出 SendMessage 回传要求\n\n### 成员 Prompt 必备结构\n\n1. **角色定义**:一句话说清\"你是谁\"\n2. **擅长领域**:3-5 个具体能力点\n3. **分析框架**:内嵌的分析能力,分步骤流程\n4. **数据获取方式**:具体的查询命令或工具调用\n5. **结构化输出模板**:表格/分段格式\n6. **SendMessage 回传要求**\n\n---\n\n## 正文结构:主理人\n\n> ⚠️ 文件名不可使用通用的 `team-lead.md`,必须加上专家团前缀,如 `trading-team-lead.md`\n\n### Frontmatter\n\n```yaml\n---\nname: {team}-team-lead\ndescription: {English description}\ndisplayName:\n en: \"{English display name}\"\n zh: \"{中文显示名称}\"\nprofession:\n en: \"{English profession title}\"\n zh: \"{中文职业头衔}\"\nmaxTurns: {turns, 建议150-200}\n---\n```\n\n### 正文模板\n\n```markdown\n# {团队名称} - 主理人\n\n{主理人角色描述,负责协调团队完成什么任务}\n\n## 团队成员\n\n### {分组名称}\n| 成员 | 名字 | 职责 |\n|------|------|------|\n| {member-id} | {name} | {职责描述} |\n\n## 标准工作流程(SOP)\n\n### Phase 1: {阶段名}\n{调用哪些成员、输入输出说明}\n\n### Phase 2: {阶段名}\n{...}\n\n### Phase N: 最终报告\n综合所有分析结果,生成最终报告返回用户。\n\n## 团队协作机制(铁律)\n\n你必须走正式的**团队协作流程**,严禁简化或跳过:\n\n1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**\n2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写\n3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连\n4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编\n\n### 严禁行为\n- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容\n- ❌ 禁止自己代写任何团队成员的专业产出\n- ❌ 禁止未完成前序阶段就跳到后续阶段\n- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转\n- ❌ 禁止 spawn 主理人自己\n\n## 协作规则\n1. 所有成员调度必须经过\"建立团队 → 调度成员 → 成员回传\"流程\n2. 每阶段结束后,将完整产出原文传递给下一阶段成员\n3. 每完成一个阶段向用户简要通报\n4. 所有输出使用与用户原始需求相同的语言\n5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称\n```\n\n---\n\n## 主理人额外要求\n\n### 成员能力清单\n\n主理人 prompt 中必须列出每个成员的:\n- Agent ID\n- 擅长领域(3-5 个具体能力点)\n- 典型问法(什么问题该调它)\n\n### 预设 Workflow\n\n针对高频综合性问题设计预设 Workflow,每个 Workflow 写明:\n- **触发条件**:什么类型的问法匹配\n- **Phase 编排**:分 Phase 的串并行调度\n- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁\n\n### 单 agent 直调路由表\n\n| 问法类型 | 直接调谁 |\n|---------|---------|\n| 单一维度问题 | 对应成员 |\n| 综合性问题 | 走预设 Workflow |\n\n---\n\n## 能独立成 agent 的标准\n\n判断标准:Line truncated
|
||||
"references/team-spec.md": "# Team 型专用规范\n\n## 成员命名规范\n\nTeam 型专家团中,每个成员有三个外露字段:`name`(名字)、`profession`(职业头衔)、`nickname`(昵称,可选)。三者不应重复。\n\n### name:谐音花名风格\n\n| 规则 | 说明 |\n|------|------|\n| 建议三个字 | 姓+名,建议三个字,两个字也可 |\n| 先是正常人名 | 姓+名结构,不解释也能当名字用 |\n| 暗含职能巧思 | 谐音、拆字、典故均可,但不刻意 |\n| 不与 profession 重复 | name 是\"谁\",profession 是\"做什么\" |\n| 中英文都自然 | 中文用花名,英文用拼音姓氏 |\n\n**示例(软件开发团队):**\n\n| profession | name(zh) | 巧思 | name(en) |\n|-----------|----------|------|----------|\n| 交付总监 | 齐活林 | 齐活了(交付完成) | Qi |\n| 产品经理 | 许清楚 | 需求要说清楚 | Xu |\n| 架构师 | 高见远 | 高见(架构视野) | Gao |\n| 项目经理 | 毕达成 | 必达(交付必达) | Bi |\n| 工程师 | 寇豆码 | code(代码) | Kou |\n| QA工程师 | 严过关 | 严格过关 | Yan |\n\n**禁止:**\n- 叠字谐音(领码码、需求求、蓝图图)\n- 一个字的 name 或纯职能词\n- 和 profession 重复\n- 无意义随机名(张三、John Doe)\n- 英文 name 用 Agent ID\n- 读起来是短语而非人名\n\n### 主理人 profession 规范\n\n主理人的 `profession` 不能用通用 title(团长、主理人、Team Lead),应体现该团队的调度风格和业务定位。\n\n**示例:**\n\n| 专家团 | 主理人 name | profession(zh) | profession(en) |\n|--------|-----------|---------------|----------------|\n| 软件开发团队 | 齐活林 | 交付总监 | Delivery Director |\n| 交易分析团队 | 何执舟 | 首席策略官 | Chief Strategist |\n| 营销战役团队 | 营销总监 | 增长操盘手 | Growth Operator |\n| 深度研究团队 | 顾全之 | 研究主编 | Research Editor |\n\n---\n\n## 协作铁律\n\n### 4 条正则\n\n1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**\n2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写\n3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连\n4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编\n\n### 5 条红线\n\n- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容\n- ❌ 禁止自己代写任何团队成员的专业产出\n- ❌ 禁止未完成前序阶段就跳到后续阶段\n- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转\n- ❌ 禁止 spawn 主理人自己(编排、汇总、决策由主理人亲自完成)\n\n### 协作规则\n\n1. 所有成员调度必须经过\"TeamCreate → Agent spawn → SendMessage 回传\"正式流程\n2. 每阶段结束后,将完整产出原文传递给下一阶段成员\n3. 调度成员时,在 Agent 工具的 `name` 参数中传入该成员的 **Agent ID**(agents/ 下的 MD 文件名,不含 .md),`subagent_type` 也传入相同值。**禁止**使用中文名或自创名称\n4. 裁决型角色(如研究主管、风险主管)必须给出明确结论,不得回避决策\n5. 每完成一个阶段向用户简要通报进度\n\n---\n\n## SOP 工作流编排\n\n### 并行 vs 串行\n\n- **并行 Phase**:同一条消息中 spawn 多个成员(使用 TeamCreate),适用于成员间无数据依赖\n- **串行 Phase**:等前一 Phase 全部回传后,将结论传入下一 Phase 的成员 prompt\n\n### 示例 SOP\n\n```\nPhase 1(并行):\n stock-researcher → 基本面+财务\n valuation-pricer → 估值判断\n money-tracker → 资金态度\n\nPhase 2(串行,Phase 1 结论传入):\n risk-doctor → 综合风险诊断\n\n主理人汇编 → 输出\n```\n\n### 预设 Workflow 设计原则\n\n每个 Workflow 写明:\n- **触发条件**:什么类型的问法匹配此 Workflow\n- **Phase 编排**:分 Phase 的串并行调度\n- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁\n\nWorkflow 数量根据用户实际高频场景设计,不是越多越好。\n\n---\n\n## settings.json\n\nTeam 型必须有 `settings.json`:\n\n```json\n{\n \"agent\": \"{team}-team-lead\"\n}\n```\n\n值必须与 plugin.json 的 `agentName` 和主理人 MD 文件的 `name` 一致。\n",
|
||||
"references/avatar-spec.md": "# 头像生成规范\n\n## 基本要求\n\n| 项目 | 要求 |\n|------|------|\n| 格式 | PNG(推荐)或 JPG |\n| 尺寸 | 512×512 px(正方形) |\n| 大小 | 单张不超过 500KB |\n| 风格 | 统一漫画/插画风格,专业自然 |\n| 内容 | 符合角色定位,不含违规内容 |\n\n> 使用 `ImageGen` 工具生成,`size` 参数设为 `\"1024x1024\"`(高清大图,市场缩放为 512×512 展示)。\n\n## 生成策略\n\n**Agent 型**:1 张头像\n- `avatars/expert.png`\n\n**Team 型**:N+1 张头像\n- `avatars/team.png` — 团队整体头像\n- `avatars/{team}-team-lead.png` — 主理人头像\n- `avatars/{member-name}.png` — 每个团员头像\n\n---\n\n## Prompt 构建核心原则\n\n**每个头像的 prompt 必须从对应的 MD 文件描述中提取角色特征,不使用通用模板硬编码。**\n\n### 提取步骤\n\n1. **读取 Agent MD 文件**\n2. **提取角色身份**:从标题和首段提取\n3. **提取专业特征**:从\"核心能力\"章节提取关键词,转化为视觉元素\n4. **推断工作风格**:从\"工作流程\"和\"注意事项\"推断性格气质\n5. **推断人物属性**:从 name 字段推断性别和风格基调\n\n---\n\n## 个人头像 Prompt 组装\n\n```\n[风格前缀] + [角色身份] + [外观特征] + [表情气质] + [背景元素] + [质量后缀]\n```\n\n| 部分 | 说明 | 示例 |\n|------|------|------|\n| 风格前缀 | 统一漫画/插画风格 | `Professional cartoon-style illustration avatar,` |\n| 角色身份 | 从 MD 标题/首段提取 | `a female design system document architect` |\n| 外观特征 | 从核心能力推断穿着/配饰 | `wearing stylish glasses, holding a design specification document` |\n| 表情气质 | 从工作风格推断 | `confident and meticulous expression` |\n| 背景元素 | 从专业领域提取视觉符号 | `subtle design tokens and color palette swatches in background` |\n| 质量后缀 | 固定 | `Bust shot, facing forward. Clean simple background. High quality, professional, natural.` |\n\n### 示例 1:设计系统架构师\n\nMD 核心内容:角色=设计系统文档架构师,能力=9大标准章节、AI可读格式,输出=Markdown+HEX+CSS\n\n```\nProfessional cartoon-style illustration avatar, a female design system document architect,\nwearing stylish glasses, holding a design guideline document, modern creative smart casual attire,\nconfident and meticulous expression with a creative yet precise aura,\nsubtle color palette swatches, typography samples and design token symbols in the background.\nBust shot, facing forward. Clean simple warm-toned background. High quality, professional, natural.\n```\n\n### 示例 2:技术分析师\n\nMD 核心内容:角色=技术分析师,能力=K线形态、均线分析、MACD/RSI/KDJ\n\n```\nProfessional cartoon-style illustration avatar, a male technical stock market analyst named Marco,\nwearing a sharp vest over dress shirt, looking at holographic candlestick charts,\nfocused and analytical expression with sharp observant eyes,\nK-line charts, moving average lines and MACD indicators floating in the background.\nBust shot, facing forward. Clean simple blue-toned background. High quality, professional, natural.\n```\n\n---\n\n## 团队头像 Prompt 构建\n\n**输入来源**:plugin.json 的 `displayDescription` + 主理人 MD 的团队描述\n\n### 提取步骤\n\n1. **团队定位**:从 displayDescription 提取团队做什么\n2. **协作模式**:从主理人 MD 的 SOP 提取工作阶段\n3. **成员构成**:从成员列表提取角色类型的多样性\n4. **视觉表达**:将以上信息转化为体现团队协作的场景\n\n### Prompt 组装\n\n```\n[风格前缀] + [团队场景描述] + [协作元素] + [成员象征] + [质量后缀]\n```\n\n### 示例:交易分析团队\n\n```\nProfessional cartoon-style illustration, a dynamic stock trading analysis team scene,\nmultiple diverse analysts gathered around a central holographic display showing candlestick charts,\na bull figure and a bear figure debating on opposite sides symbolizing bull-bear debate,\nrisk gauges and decision dashboards floating around,\nwarm collaborative atmosphere with focused professional energy.\nClean simple multi-tone gradient background. High quality, professional, team composition.\n```\n\n---\n\n## 同一团队风格统一规则\n\nTeam 型的所有头像必须在 prompt 中保持一致的**风格锚定词**:\n\n**固定风格前缀(每个 prompt 开头):**\n```\nProfessional cartoon-style illustration avatar, consistent art style with warm lighting and soft shadows,\n```\n\n**固定质量后缀(每个 prompt 结尾):**\n```\nBust shot, facing forward. Clean simple {color}-toned background. High quality, professional, natural.\n```\n\n### 背景色调映射\n\n| categoryId | 背景色调 |\n|------------|---------|\n| 01-ProductDesign | warm orange-coral |\n| 02-EngineerinLine truncated
|
||||
"scripts/register_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Register - Validates and registers an expert into marketplace.json\n\nThis script should be called AFTER the expert package is fully created and validated.\nOnly registered experts will be visible in the WorkBuddy expert center.\n\nUsage:\n register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]\n\nExamples:\n python3 register_expert.py <expert-dir>\n python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>\n\nThe creating session id is read from CODEBUDDY_SESSION_ID when present.\n\"\"\"\n\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple\n\nSESSION_ID_ENV_VAR = 'CODEBUDDY_SESSION_ID'\nUNEXPANDED_SESSION_ID_ARGS = {'$CODEBUDDY_SESSION_ID', '${CODEBUDDY_SESSION_ID}'}\n\n\ndef get_expert_base_dir():\n \"\"\"获取专家 marketplace 根目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。\"\"\"\n config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()\n if not config_dir:\n config_dir = str(Path.home() / '.workbuddy')\n return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts'\n\n\ndef find_plugin_json(expert_dir: Path) -> Optional[Path]:\n \"\"\"Find plugin.json in the expert directory.\"\"\"\n for meta_dir in ['.codebuddy-plugin', '.workbuddy-plugin']:\n candidate = expert_dir / meta_dir / 'plugin.json'\n if candidate.exists():\n return candidate\n return None\n\n\ndef validate_expert_completeness(expert_dir: Path) -> Tuple[bool, List[str]]:\n \"\"\"\n Validate that the expert package is complete and ready for registration.\n Returns (is_valid, list_of_errors).\n \"\"\"\n errors = []\n\n # 1. Check plugin.json exists\n plugin_json_path = find_plugin_json(expert_dir)\n if not plugin_json_path:\n errors.append(\"Missing plugin.json (checked .codebuddy-plugin/ and .workbuddy-plugin/)\")\n return False, errors\n\n # 2. Parse plugin.json\n try:\n with open(plugin_json_path, 'r', encoding='utf-8') as f:\n plugin_data = json.load(f)\n except json.JSONDecodeError as e:\n errors.append(f\"plugin.json is not valid JSON: {e}\")\n return False, errors\n\n # 3. Check required fields are not [TODO]\n required_fields = ['name', 'description', 'expertType']\n for field in required_fields:\n value = plugin_data.get(field, '')\n if not value or '[TODO' in str(value):\n errors.append(f\"Field '{field}' is missing or contains [TODO] placeholder\")\n\n # 4. Check displayName is filled\n display_name = plugin_data.get('displayName', {})\n if isinstance(display_name, dict):\n zh_name = display_name.get('zh', '')\n en_name = display_name.get('en', '')\n if (not zh_name or '[TODO' in zh_name) and (not en_name or '[TODO' in en_name):\n errors.append(\"displayName (zh or en) is missing or contains [TODO]\")\n elif not display_name or '[TODO' in str(display_name):\n errors.append(\"displayName is missing or contains [TODO]\")\n\n # 5. Check agents directory has at least one non-template .md file\n agents_dir = expert_dir / 'agents'\n if agents_dir.exists():\n md_files = list(agents_dir.glob('*.md'))\n if not md_files:\n errors.append(\"No .md files found in agents/ directory\")\n else:\n # Check at least one agent file has real content (not all [TODO])\n has_real_content = False\n for md_file in md_files:\n content = md_file.read_text(encoding='utf-8')\n # Count [TODO] occurrences - if more than 3, likely still a template\n todo_count = content.count('[TODO')\n if todo_count <= 2:\n has_real_content = True\n break\n if not has_real_content:\n errors.append(\"All agent .md files still contain many [TODO] placeholders\")\n else:\n errors.append(\"Missing agents/ directory\")\n\n return len(errors) == 0, errors\n\n\ndef _write_session_marker(expert_dir: Path, session_id: str) -> None:\n \"\"\"Write .created-by-session marker file into the expert directory.\"\"\"\n marker_path = expert_dir / '.created-by-session'\n try:\n marker_path.write_text(session_id, encoding='utf-8')\n except OSError as e:\n print(f\"⚠️ Warning: could not write session marker: {e}\")\n\n\ndef normalize_session_id(value: Optional[str]) -> Optional[str]:\n \"\"\"Return a usable session id, ignoring empty or unexpanded shell placeholders.\"\"\"\n session_id = (value or '').strip()\n if not session_id or session_id in UNEXPANDED_SESSION_ID_ARGS:\n return None\n return session_id\n\n\ndef get_session_id(explicit_session_id: Optional[str]) -> Optional[str]:\n \"\"\"Resolve the creating session id from CLI arg oLine truncated
|
||||
"scripts/init_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Initializer - Creates a new expert package from template\n\nUsage:\n init_expert.py <expert-name> --type agent|team --path <output-dir>\n\nExamples:\n init_expert.py my-expert --type agent --path ./output\n init_expert.py my-team --type team --path ./output\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport re\nfrom pathlib import Path\n\n\ndef get_expert_plugins_dir():\n \"\"\"获取专家目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。\"\"\"\n config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()\n if not config_dir:\n config_dir = str(Path.home() / '.workbuddy')\n return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'\n\n\ndef title_case(name):\n \"\"\"Convert kebab-case to Title Case.\"\"\"\n return ' '.join(word.capitalize() for word in name.split('-'))\n\n\nAGENT_PLUGIN_JSON = \"\"\"{\n \"name\": \"%(name)s\",\n \"version\": \"1.0.0\",\n \"description\": \"[TODO: English one-line description]\",\n \"author\": {\n \"name\": \"[TODO: author name]\",\n \"email\": \"[TODO: author email]\"\n },\n \"agents\": [\"./agents/%(agent_name)s.md\"],\n\n \"expertType\": \"agent\",\n \"agentName\": \"%(agent_name)s\",\n\n \"displayName\": {\n \"en\": \"[TODO: English display name]\",\n \"zh\": \"[TODO: 中文显示名称]\"\n },\n \"profession\": {\n \"en\": \"[TODO: English profession]\",\n \"zh\": \"[TODO: 中文职业头衔]\"\n },\n \"displayDescription\": {\n \"en\": \"[TODO: English detailed description]\",\n \"zh\": \"[TODO: 中文详细描述,40-50字]\"\n },\n \"avatar\": \"avatars/expert.png\",\n \"categoryId\": \"[TODO: XX-CategoryName]\",\n \"defaultInitPrompt\": {\n \"zh\": \"[TODO: 中文首次对话提示]\",\n \"en\": \"[TODO: English first prompt]\"\n },\n \"plugin\": \"%(name)s\",\n \"tags\": [\n { \"en\": \"[TODO: Tag1]\", \"zh\": \"[TODO: 标签1]\" },\n { \"en\": \"[TODO: Tag2]\", \"zh\": \"[TODO: 标签2]\" },\n { \"en\": \"[TODO: Tag3]\", \"zh\": \"[TODO: 标签3]\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"[TODO: Prompt1, same as defaultInitPrompt]\", \"zh\": \"[TODO: 提示词1,同defaultInitPrompt]\" },\n { \"en\": \"[TODO: Prompt2]\", \"zh\": \"[TODO: 提示词2]\" },\n { \"en\": \"[TODO: Prompt3]\", \"zh\": \"[TODO: 提示词3]\" }\n ]\n}\n\"\"\"\n\nTEAM_PLUGIN_JSON = \"\"\"{\n \"name\": \"%(name)s\",\n \"version\": \"1.0.0\",\n \"description\": \"[TODO: English one-line description]\",\n \"author\": {\n \"name\": \"[TODO: author name]\",\n \"email\": \"[TODO: author email]\"\n },\n \"agents\": [\n \"./agents/%(team)s-team-lead.md\",\n \"./agents/[TODO: member-a].md\"\n ],\n\n \"expertType\": \"team\",\n \"agentName\": \"%(team)s-team-lead\",\n \"teamInfo\": {\n \"leadAgent\": \"%(team)s-team-lead\",\n \"memberAgents\": [\"[TODO: member-a]\"]\n },\n\n \"displayName\": {\n \"en\": \"[TODO: English team name]\",\n \"zh\": \"[TODO: 中文团队名称]\"\n },\n \"profession\": {\n \"en\": \"[TODO: same as displayName.en]\",\n \"zh\": \"[TODO: 同displayName.zh]\"\n },\n \"displayDescription\": {\n \"en\": \"[TODO: English team description]\",\n \"zh\": \"[TODO: 中文团队描述,40-50字]\"\n },\n \"avatar\": \"avatars/team.png\",\n \"categoryId\": \"[TODO: XX-CategoryName]\",\n \"defaultInitPrompt\": {\n \"zh\": \"[TODO: 中文首次问候]\",\n \"en\": \"[TODO: English first prompt]\"\n },\n \"plugin\": \"%(name)s\",\n \"tags\": [\n { \"en\": \"[TODO: Tag1]\", \"zh\": \"[TODO: 标签1]\" },\n { \"en\": \"[TODO: Tag2]\", \"zh\": \"[TODO: 标签2]\" },\n { \"en\": \"[TODO: Tag3]\", \"zh\": \"[TODO: 标签3]\" }\n ],\n \"quickPrompts\": [\n { \"en\": \"[TODO: Prompt1, same as defaultInitPrompt]\", \"zh\": \"[TODO: 提示词1,同defaultInitPrompt]\" },\n { \"en\": \"[TODO: Prompt2]\", \"zh\": \"[TODO: 提示词2]\" },\n { \"en\": \"[TODO: Prompt3]\", \"zh\": \"[TODO: 提示词3]\" }\n ],\n \"members\": [\n {\n \"id\": \"%(team)s-team-lead\",\n \"displayName\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"profession\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"avatar\": \"avatars/%(team)s-team-lead.png\",\n \"role\": \"lead\"\n },\n {\n \"id\": \"[TODO: member-a]\",\n \"displayName\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"profession\": { \"en\": \"[TODO]\", \"zh\": \"[TODO]\" },\n \"avatar\": \"avatars/[TODO: member-a].png\",\n \"role\": \"member\"\n }\n ]\n}\n\"\"\"\n\nAGENT_MD_TEMPLATE = \"\"\"---\nname: %(agent_name)s\ndescription: \"[TODO: English description for AI to determine when to activate]\"\ndisplayName:\n en: \"[TODO: English display name]\"\n zh: \"[TODO: 中文显示名称]\"\nprofession:\n en: \"[TODO: English profession title]\"\n zh: \"[TODO: 中文职业头衔]\"\nmaxTurns: 50\Line truncated
|
||||
"scripts/validate_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Validator - Validates an expert package against the WorkBuddy specification.\n\nUsage:\n validate_expert.py <path/to/expert-dir>\n\nExample:\n python3 validate_expert.py plugins/my-expert\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport re\nfrom pathlib import Path\n\n\nVALID_CATEGORY_IDS = {\n '01-ProductDesign', '02-Engineering', '03-GameSpatial', '04-DataAI',\n '05-MarketingGrowth', '06-ContentCreative', '07-SalesCommerce',\n '08-FinanceInvestment', '09-OperationsHR', '10-ProjectQuality',\n '11-SecurityCompliance', '12-IndustryConsultant',\n}\n\nVALID_EXPERT_TYPES = {'agent', 'team'}\n\n\nclass ValidationResult:\n def __init__(self):\n self.errors = []\n self.warnings = []\n\n def error(self, msg):\n self.errors.append(msg)\n\n def warn(self, msg):\n self.warnings.append(msg)\n\n @property\n def is_valid(self):\n return len(self.errors) == 0\n\n def summary(self):\n lines = []\n if self.errors:\n lines.append(f\"❌ {len(self.errors)} error(s):\")\n for e in self.errors:\n lines.append(f\" • {e}\")\n if self.warnings:\n lines.append(f\"⚠️ {len(self.warnings)} warning(s):\")\n for w in self.warnings:\n lines.append(f\" • {w}\")\n if self.is_valid:\n lines.append(\"✅ Expert package is valid!\")\n return '\\n'.join(lines)\n\n\ndef parse_md_frontmatter(md_path):\n \"\"\"Parse YAML frontmatter from a Markdown file. Returns dict or None.\"\"\"\n try:\n content = md_path.read_text(encoding='utf-8')\n except Exception as e:\n return None, f\"Cannot read {md_path}: {e}\"\n\n if not content.startswith('---'):\n return None, f\"{md_path.name}: No YAML frontmatter found\"\n\n match = re.match(r'^---\\n(.*?)\\n---', content, re.DOTALL)\n if not match:\n return None, f\"{md_path.name}: Invalid frontmatter format\"\n\n # LIMITATION: Only parses simple top-level \"key: value\" pairs.\n # Does NOT handle nested objects, lists, or multi-line values.\n # Sufficient for extracting 'name', 'description', and detecting 'tools'.\n fm_text = match.group(1)\n result = {}\n\n # Extract simple key-value pairs\n for line in fm_text.split('\\n'):\n line = line.strip()\n if ':' in line and not line.startswith('-') and not line.startswith('#'):\n key, _, value = line.partition(':')\n key = key.strip()\n value = value.strip().strip('\"').strip(\"'\")\n if value:\n result[key] = value\n\n return result, None\n\n\ndef check_i18n_field(obj, field_name, result, context=\"plugin.json\"):\n \"\"\"Check that a field has both 'en' and 'zh' values.\"\"\"\n if field_name not in obj:\n result.error(f\"{context}: missing '{field_name}'\")\n return False\n\n val = obj[field_name]\n if not isinstance(val, dict):\n result.error(f\"{context}: '{field_name}' must be an object with 'en' and 'zh'\")\n return False\n\n if not val.get('en'):\n result.error(f\"{context}: '{field_name}.en' is empty\")\n if not val.get('zh'):\n result.error(f\"{context}: '{field_name}.zh' is empty\")\n\n return True\n\n\ndef validate_plugin_json(plugin_json, expert_dir, result):\n \"\"\"Validate plugin.json fields.\"\"\"\n # Required fields\n for field in ('name', 'version', 'description'):\n if field not in plugin_json:\n result.error(f\"plugin.json: missing required field '{field}'\")\n\n name = plugin_json.get('name', '')\n if name and (len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name)):\n result.error(f\"plugin.json: 'name' must be kebab-case (at least 2 chars), got '{name}'\")\n\n expert_type = plugin_json.get('expertType', '')\n if expert_type not in VALID_EXPERT_TYPES:\n result.error(f\"plugin.json: 'expertType' must be one of {VALID_EXPERT_TYPES}, got '{expert_type}'\")\n return expert_type\n\n # plugin field consistency\n plugin_field = plugin_json.get('plugin', '')\n if plugin_field and plugin_field != name:\n result.error(f\"plugin.json: 'plugin' ({plugin_field}) must equal 'name' ({name})\")\n\n # Agent/Team specific\n if expert_type in ('agent', 'team'):\n agent_name = plugin_json.get('agentName', '')\n if not agent_name:\n result.error(\"plugin.json: missing 'agentName'\")\n else:\n # Check agentName matches an MD file\n md_file = expert_dir / 'agents' / f'{agent_name}.md'\n if not md_file.exists():\n result.error(f\"plugin.json: agentName '{agent_name}' has no matching file agents/{agent_name}.md\")\n\n # agentName must not be generic \"team-lead\"\n if agent_name == 'team-lead':Line truncated
|
||||
"scripts/package_expert.py": "#!/usr/bin/env python3\n\"\"\"\nExpert Packager - Validates and packages an expert directory into a zip file.\n\nUsage:\n package_expert.py <path/to/expert-dir> [output-dir]\n\nExample:\n python3 package_expert.py plugins/my-expert\n python3 package_expert.py plugins/my-expert ./dist\n\"\"\"\n\nimport sys\nimport zipfile\nfrom pathlib import Path\n\n# Import validate_expert from sibling script\nscript_dir = Path(__file__).parent\nsys.path.insert(0, str(script_dir))\nfrom validate_expert import validate_expert\n\n\ndef package_expert(expert_path, output_dir=None):\n \"\"\"\n Validate and package an expert directory into a .zip file.\n\n Args:\n expert_path: Path to the expert directory\n output_dir: Optional output directory (defaults to current directory)\n\n Returns:\n Path to the created zip file, or None if error\n \"\"\"\n expert_path = Path(expert_path).resolve()\n\n if not expert_path.exists():\n print(f\"❌ Error: Expert directory not found: {expert_path}\")\n return None\n\n if not expert_path.is_dir():\n print(f\"❌ Error: Path is not a directory: {expert_path}\")\n return None\n\n # Validate first\n print(\"🔍 Validating expert package...\\n\")\n result = validate_expert(expert_path)\n print(result.summary())\n\n if not result.is_valid:\n print(\"\\n❌ Packaging aborted. Please fix validation errors first.\")\n return None\n\n print()\n\n # Determine output location\n expert_name = expert_path.name\n if output_dir:\n out = Path(output_dir).resolve()\n out.mkdir(parents=True, exist_ok=True)\n else:\n out = Path.cwd()\n\n zip_filename = out / f\"{expert_name}.zip\"\n\n # Create the zip file\n try:\n file_count = 0\n with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:\n for file_path in sorted(expert_path.rglob('*')):\n if file_path.is_file():\n # Skip hidden files except .codebuddy-plugin\n rel = file_path.relative_to(expert_path)\n parts = rel.parts\n if any(p.startswith('.') and p != '.codebuddy-plugin' for p in parts):\n continue\n # Skip common junk files/directories\n if any(p in ('__pycache__', 'node_modules') for p in parts):\n continue\n if file_path.name in ('.gitkeep', '.DS_Store', 'Thumbs.db'):\n continue\n\n arcname = str(Path(expert_name) / rel)\n zipf.write(file_path, arcname)\n print(f\" 📄 {arcname}\")\n file_count += 1\n\n print(f\"\\n✅ Packaged {file_count} files to: {zip_filename}\")\n print(f\" Size: {zip_filename.stat().st_size / 1024:.1f} KB\")\n return zip_filename\n\n except Exception as e:\n print(f\"❌ Error creating zip: {e}\")\n return None\n\n\ndef main():\n if len(sys.argv) < 2:\n print(\"Usage: python3 package_expert.py <path/to/expert-dir> [output-dir]\")\n print(\"\\nExample:\")\n print(\" python3 package_expert.py plugins/my-expert\")\n print(\" python3 package_expert.py plugins/my-expert ./dist\")\n sys.exit(1)\n\n expert_path = sys.argv[1]\n output_dir = sys.argv[2] if len(sys.argv) > 2 else None\n\n print(f\"📦 Packaging expert: {expert_path}\")\n if output_dir:\n print(f\" Output directory: {output_dir}\")\n print()\n\n result = package_expert(expert_path, output_dir)\n\n if result:\n sys.exit(0)\n else:\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n",
|
||||
"scripts/batch_create.py": "#!/usr/bin/env python3\n\"\"\"\n批量创建专家 — 每个专家串行走完整标准流程(init → validate → register)。\n\nUsage:\n python3 scripts/batch_create.py <batch-config.json> [--session-id <id>]\n\nbatch-config.json:\n {\n \"path\": \"<expert-plugins-dir>\",\n \"experts\": [\n { \"name\": \"my-expert\", \"type\": \"agent\" },\n { \"name\": \"my-team\", \"type\": \"team\" }\n ]\n }\n\"\"\"\n\nimport sys\nimport json\nimport os\nimport subprocess\nimport time\nfrom pathlib import Path\n\nSCRIPT_DIR = Path(__file__).parent.resolve()\nREGISTER_MAX_RETRIES = 2\nREGISTER_RETRY_DELAY = 1 # seconds\n\n\ndef run_step(script_name: str, args: list) -> bool:\n \"\"\"运行单个标准流程脚本,返回是否成功。\"\"\"\n cmd = [sys.executable, str(SCRIPT_DIR / script_name)] + args\n result = subprocess.run(cmd, capture_output=True, text=True)\n if result.stdout.strip():\n print(result.stdout.strip())\n if result.returncode != 0 and result.stderr.strip():\n print(result.stderr.strip())\n return result.returncode == 0\n\n\ndef process_one_expert(name: str, expert_type: str, output_dir: Path, session_id: str = None) -> bool:\n \"\"\"\n 单个专家的完整标准流程。\n 每个专家必须串行经过:init → validate → register,缺一不可。\n \"\"\"\n expert_dir = output_dir / name\n expert_path = str(expert_dir)\n\n # Step 1: 初始化目录\n print(f\"\\n{'─'*40}\")\n print(f\"📦 [{name}] Step 1/3: 初始化\")\n if not run_step('init_expert.py', [name, '--type', expert_type, '--path', str(output_dir)]):\n print(f\" ❌ [{name}] 初始化失败,跳过该专家\")\n return False\n\n # ═══════════════════════════════════════════════════════\n # Step 2: AI 填充内容(在实际使用中,这里由 AI 写入文件)\n # 本脚本仅做流程示例,不包含内容生成逻辑。\n # AI 在此步骤应写入 plugin.json、agents/*.md、头像等文件。\n # ═══════════════════════════════════════════════════════\n\n # Step 3: 校验(失败提醒修复后重试)\n print(f\"📋 [{name}] Step 2/3: 校验\")\n if not run_step('validate_expert.py', [expert_path]):\n print(f\" ❌ [{name}] 校验失败,请修复内容后重新校验: python3 scripts/validate_expert.py {expert_path}\")\n return False\n\n # Step 4: 注册(含重试,失败则提醒)\n print(f\"📝 [{name}] Step 3/3: 注册\")\n register_args = [expert_path]\n if session_id:\n register_args.extend(['--session-id', session_id])\n\n for attempt in range(1, REGISTER_MAX_RETRIES + 1):\n if run_step('register_expert.py', register_args):\n print(f\" ✅ [{name}] 完成\")\n return True\n if attempt < REGISTER_MAX_RETRIES:\n print(f\" ⚠️ 注册失败,{REGISTER_RETRY_DELAY}s 后重试 ({attempt}/{REGISTER_MAX_RETRIES})...\")\n time.sleep(REGISTER_RETRY_DELAY)\n\n # 重试耗尽:保留目录(内容已生成),提醒手动重试注册\n print(f\" ❌ [{name}] 注册失败(已重试 {REGISTER_MAX_RETRIES} 次),目录已保留: {expert_path}\")\n print(f\" 💡 请稍后手动重试: python3 scripts/register_expert.py {expert_path} --session-id <session-id>\")\n return False\n\n\ndef main():\n if len(sys.argv) < 2:\n print(__doc__)\n sys.exit(1)\n\n config_path = Path(sys.argv[1]).resolve()\n session_id = None\n\n i = 2\n while i < len(sys.argv):\n if sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):\n session_id = sys.argv[i + 1]\n i += 2\n else:\n i += 1\n\n with open(config_path, 'r', encoding='utf-8') as f:\n config = json.load(f)\n\n default_config = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')\n default_plugins_path = str(Path(default_config) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins')\n output_dir = Path(config.get('path', default_plugins_path)).expanduser().resolve()\n experts = config.get('experts', [])\n\n if not experts:\n print(\"❌ 配置中无专家列表\")\n sys.exit(1)\n\n print(f\"🚀 批量创建 {len(experts)} 个专家 → {output_dir}\\n\")\n\n passed = []\n failed = []\n\n # ⚠️ 串行执行:逐个专家依次走完整流程,禁止并行/异步。\n for expert in experts:\n name = expert.get('name', '')\n expert_type = expert.get('type', 'agent')\n if not name:\n continue\n if process_one_expert(name, expert_type, output_dir, session_Line truncated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "skill-expert-manager",
|
||||
"version": "5.8.2-wb.41111488.gb8f6c953.haebd5991e0e2",
|
||||
"description": "WorkBuddy bundled skill resource: expert-manager.",
|
||||
"author": {
|
||||
"name": "WorkBuddy"
|
||||
},
|
||||
"category": "skill",
|
||||
"keywords": [
|
||||
"workbuddy",
|
||||
"builtin-skill",
|
||||
"expert-manager"
|
||||
],
|
||||
"workbuddy": {
|
||||
"kind": "builtin-skill",
|
||||
"legacyResourceName": "expert-manager",
|
||||
"bundleSegments": [
|
||||
"plugins",
|
||||
"workbuddy-builtin",
|
||||
"skills",
|
||||
"expert-manager"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
name: expert-manager
|
||||
description: |
|
||||
专家包的全生命周期运营:转化(从开源仓库/本地项目创建专家包)、修改已有专家、合规检查、批量更新、质量审查。
|
||||
触发词:创建专家、转化专家、转成专家、生成专家包、导入专家、convert expert、修改专家、编辑专家、更新专家、modify expert、检查专家、审查专家包、专家合规、专家运营、expert ops。
|
||||
description_en: |
|
||||
Manage the full lifecycle of WorkBuddy Expert packages: create or convert an Expert from an open-source repository or local project, edit existing Experts, run compliance checks, apply batch updates, and review quality. Use for requests to create, import, modify, inspect, or maintain an Expert or Expert Team package.
|
||||
---
|
||||
|
||||
# WorkBuddy 专家包管理器
|
||||
|
||||
> ⚠️ **执行前必读**:当需要使用本 skill 时,你必须先从头到尾完整阅读本 SKILL.md 全文并严格遵守(包括所有规则、流程、References 列表),然后再开始执行任务。禁止跳读或仅凭部分段落就开始行动。
|
||||
|
||||
你是 WorkBuddy 专家包管理器,帮助用户按照 WorkBuddy 专家开发规范(v2.0)创建和维护完整的、可提交审核的专家文件包。
|
||||
|
||||
支持两种专家类型:
|
||||
- **Agent 型**(`expertType: "agent"`):单个 AI 专家
|
||||
- **Team 型**(`expertType: "team"`):多角色协作团队
|
||||
|
||||
支持两种输入模式:
|
||||
1. **交互模式**:用户描述需求,通过提问收集信息后生成
|
||||
2. **资料转化模式**:用户提供现有资料(文档、流程、提示词等),从中提取信息直接转化
|
||||
|
||||
---
|
||||
|
||||
## 关键展示字段对应关系
|
||||
|
||||
以下字段决定了专家在产物面板中的展示,**所有场景(创建/转化/修改)均需遵守**:
|
||||
|
||||
| 展示 | 对应字段 | 变更规则 |
|
||||
|---------|---------|---------|
|
||||
| **名字(职业)** | `profession`(`{en, zh}`) | 卡片标题,应体现专家职业定位 |
|
||||
| **花名** | `displayName`(`{en, zh}`) | 可根据用户要求自由修改 |
|
||||
| **类型** | `expertType`(`"agent"` → 显示"专家",`"team"` → 显示"专家团") | **不可随意指定/变更**,必须根据实际结构判断:单角色 = `"agent"`,多角色协作 = `"team"` |
|
||||
| **行业分类** | `categoryId` | **不可随意指定/变更**,必须根据专家核心能力领域从分类列表中选择,并向用户说明选择理由 |
|
||||
| **能力介绍** | `displayDescription`(`{en, zh}`) | 中文 40-50 字,突出核心能力 |
|
||||
| **擅长领域** | `tags`(`{en, zh}[]`,固定 3 个) | 展示专家擅长的领域标签;已满 3 个时必须提示用户替换或删除已有标签,禁止继续新增 |
|
||||
| **试试这样问我** | `quickPrompts`(`{en, zh}[]`,固定 3 个) | 推荐提示词,第一条同时作为 `defaultInitPrompt` |
|
||||
|
||||
---
|
||||
|
||||
## 一、工作流程
|
||||
|
||||
### 整体流程
|
||||
|
||||
```
|
||||
1. 收集信息(交互 or 资料转化)
|
||||
2. 初始化目录 → scripts/init_expert.py
|
||||
3. 生成文件内容 → 参考 references/
|
||||
4. 生成头像 → 参考 references/avatar-spec.md
|
||||
5. 校验 → scripts/validate_expert.py
|
||||
6. 注册 → scripts/register_expert.py
|
||||
7. 打包 → scripts/package_expert.py
|
||||
```
|
||||
|
||||
> **批量创建**:多个专家时串行重复上述流程,参考 `scripts/batch_create.py`。
|
||||
|
||||
### 场景 A:交互模式
|
||||
|
||||
**专家目录(固定)**:由环境变量 `WORKBUDDY_CONFIG_DIR` 决定,完整路径为 `$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(未设置时默认 `~/.workbuddy/plugins/marketplaces/my-experts/plugins`)。**禁止**将专家生成到其他目录。如果用户要求创建到其他路径,必须拒绝并说明:"专家必须生成到专家目录才能被检测到,其他目录生成后将无法使用。"然后使用专家目录继续执行。
|
||||
|
||||
**必须明确的信息:**
|
||||
1. **专家类型(expertType)**:Agent 还是 Team?(判定规则见上方"关键展示字段对应关系")
|
||||
2. **专家领域**:擅长什么?
|
||||
|
||||
**Agent 型还需要:**
|
||||
- 名字(中英文)、职业头衔(中英文)
|
||||
- 详细能力描述(中英文,中文 40-50 字)
|
||||
- 首次对话问候语(中英文)
|
||||
- 行业分类(见下方列表)
|
||||
- 擅长领域标签(固定 3 个,中英文)
|
||||
- 推荐提示词(3 个,中英文,第一条同时作为 defaultInitPrompt)
|
||||
- 是否需要附带 Skill / bin/
|
||||
|
||||
**Team 型还需要:**
|
||||
- 团队名称(中英文)、团队职业头衔(中英文,须与团队名称一致)
|
||||
- 主理人名字、职责
|
||||
- 每个团员的名字、职业头衔、职责
|
||||
- 团队 SOP 工作流程
|
||||
- 首次对话问候语、行业分类、标签、推荐提示词
|
||||
|
||||
### 场景 B:资料转化模式
|
||||
|
||||
当用户提供文件路径或粘贴内容时:
|
||||
|
||||
1. **读取分析** — 从资料中提取角色定义、核心能力、SOP、输出规范、约束、脚本、参考资料、角色分工
|
||||
2. **推断 expertType 和 categoryId** — 按"关键展示字段对应关系"中的规则判断,向用户说明理由
|
||||
3. **确认补全** — 向用户确认推断结果(displayName、expertType、categoryId),补全展示信息(名称、头衔、描述、标签等)
|
||||
4. **生成** — 执行后续的初始化 + 文件生成流程
|
||||
|
||||
### 场景 C:修改已有专家
|
||||
|
||||
当用户要求修改/编辑/更新某个专家时:
|
||||
|
||||
**专家目录**:`$WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins`(默认 `~/.workbuddy/...`)
|
||||
|
||||
**流程:**
|
||||
|
||||
1. **定位专家** — 在专家目录下找到用户指定名称的专家目录(如 `electron-dev`),读取 `.codebuddy-plugin/plugin.json` 和相关文件了解现有内容
|
||||
2. **确认修改范围** — 向用户确认要修改什么(如:提示词、描述、能力、标签、头像、新增/删除团队成员等)
|
||||
3. **执行修改** — 直接编辑对应文件,保持与现有内容风格一致
|
||||
4. **校验** — `python3 scripts/validate_expert.py <expert-dir>`
|
||||
5. **重新注册** — 无论修改了什么字段,都必须重新运行注册脚本:`python3 scripts/register_expert.py <expert-dir>`
|
||||
|
||||
**注意事项:**
|
||||
- 修改前先完整读取现有文件,避免丢失已有内容
|
||||
- 仅修改用户要求变更的部分,不要重写整个文件
|
||||
- 如果用户要修改的专家不存在,提示用户确认【专家中心-我的专家】已有该专家
|
||||
- 修改 `displayName`、`expertType`、`categoryId` 时须遵守"关键展示字段对应关系"中的变更规则
|
||||
- **可以修改的字段**:`displayName`、`profession`、`displayDescription`、`description`、`tags`、`quickPrompts`、`defaultInitPrompt`、`avatar`、`categoryId`(需说明理由)、`members` 的展示信息、Agent MD 的正文内容等
|
||||
- **严禁修改以下字段和文件名**(它们是专家的唯一标识,修改会导致专家丢失):
|
||||
- `plugin.json` 中的 `name` 字段(kebab-case 标识符)
|
||||
- `plugin.json` 中的 `agentName` 字段
|
||||
- 专家目录名(如 `react-dev/`)
|
||||
- `agents/` 目录下的 `.md` 文件名(因为 `agentName` = MD 文件名)
|
||||
- 如果用户要求改 name/目录名,应告知:改名需要重新创建专家,不支持原地改名
|
||||
|
||||
### 第二步:初始化目录
|
||||
|
||||
```bash
|
||||
python3 scripts/init_expert.py <expert-name> --type agent|team --path $WORKBUDDY_CONFIG_DIR/plugins/marketplaces/my-experts/plugins
|
||||
```
|
||||
|
||||
> `--path` 固定为专家目录(由 `WORKBUDDY_CONFIG_DIR` 环境变量决定),禁止指定其他路径。
|
||||
|
||||
生成的模板文件带 `[TODO]` 占位符,后续由 AI 填充实际内容。
|
||||
|
||||
### 第三步:生成文件内容
|
||||
|
||||
参考以下 references 编写各文件:
|
||||
- `@references/plugin-json-spec.md` — plugin.json 字段规范和模板
|
||||
- `@references/agent-md-spec.md` — Agent MD frontmatter 和正文结构
|
||||
- `@references/team-spec.md` — Team 型协作铁律、成员命名、SOP 编排
|
||||
|
||||
### 第四步:生成头像
|
||||
|
||||
参考 `@references/avatar-spec.md` 使用 `ImageGen` 工具为每个角色自动生成头像。
|
||||
|
||||
### 第五步:校验
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_expert.py <path/to/expert-dir>
|
||||
```
|
||||
|
||||
### 第六步:注册
|
||||
|
||||
校验通过后,将专家注册到 `marketplace.json`:
|
||||
|
||||
```bash
|
||||
python3 scripts/register_expert.py <path/to/expert-dir>
|
||||
```
|
||||
|
||||
> 此脚本会:1) 再次校验关键字段不含 `[TODO]`;2) 将专家信息写入 `marketplace.json`;3) 自动从 Bash runtime 注入的 `CODEBUDDY_SESSION_ID` 读取当前会话并写入 `.created-by-session`。如果校验不通过会报错并拒绝注册。
|
||||
|
||||
### 第七步:打包
|
||||
|
||||
```bash
|
||||
python3 scripts/package_expert.py <path/to/expert-dir> [output-dir]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、行业分类(categoryId)
|
||||
|
||||
**判定规则:** 根据专家的核心能力和主要服务领域来选择分类,不可凭直觉或用户随意指定。判定优先级:
|
||||
1. 专家的 **主要输出物** 属于哪个领域(如:输出代码/架构设计 → 技术工程;输出营销方案 → 营销增长)
|
||||
2. 专家的 **服务对象** 是谁(如:服务开发者 → 技术工程;服务投资者 → 金融投资)
|
||||
3. 如有跨领域情况,选择 **最核心** 的一个分类
|
||||
|
||||
| categoryId | 分类名称 | 适用场景举例 |
|
||||
|---|---|---|
|
||||
| 01-ProductDesign | 产品设计 | UI/UX 设计、产品规划、原型设计、交互设计 |
|
||||
| 02-Engineering | 技术工程 | 编程开发、架构设计、DevOps、技术选型 |
|
||||
| 03-GameSpatial | 游戏空间 | 游戏开发、3D 建模、虚拟现实、游戏设计 |
|
||||
| 04-DataAI | 数据智能 | 数据分析、机器学习、大模型应用、BI |
|
||||
| 05-MarketingGrowth | 营销增长 | 品牌营销、用户增长、广告投放、SEO |
|
||||
| 06-ContentCreative | 内容创作 | 文案写作、视频脚本、创意策划、翻译 |
|
||||
| 07-SalesCommerce | 销售商务 | 销售策略、商务谈判、客户管理、电商 |
|
||||
| 08-FinanceInvestment | 金融投资 | 投资分析、财务管理、风控、量化交易 |
|
||||
| 09-OperationsHR | 运营人力 | 项目运营、人力资源、组织管理、培训 |
|
||||
| 10-ProjectQuality | 项目质量 | 项目管理、质量保障、测试、流程优化 |
|
||||
| 11-SecurityCompliance | 法务安全 | 信息安全、合规审查、法务咨询、隐私保护 |
|
||||
| 12-IndustryConsultant | 行业顾问 | 跨行业咨询、战略规划、不属于以上明确分类的 |
|
||||
|
||||
---
|
||||
|
||||
## 三、资料转化策略
|
||||
|
||||
| 资料中的内容 | 转化为 | 放在哪里 |
|
||||
|-------------|--------|---------|
|
||||
| 角色描述、专家人设 | Agent MD 的角色定义和核心能力 | `agents/{name}.md` |
|
||||
| 工作流程、操作步骤 | Agent MD 的工作流程章节 | `agents/{name}.md` |
|
||||
| 输出格式要求 | Agent MD 的输出规范章节 | `agents/{name}.md` |
|
||||
| API 文档、字段定义 | SKILL.md + references/ | `skills/{name}/references/` |
|
||||
| 可执行脚本代码 | scripts/ | `skills/{name}/scripts/` |
|
||||
| 流程模板、报告模板 | templates/ | `skills/{name}/templates/` |
|
||||
| 通用 CLI 工具 | bin/ | `bin/` |
|
||||
| 多角色分工描述 | Team 型主理人 + 各团员 MD | `agents/` 多个 MD |
|
||||
| SOP/阶段性流程 | 主理人 MD 的 SOP 章节 | `agents/{team}-team-lead.md` |
|
||||
| 示例对话 | quickPrompts + defaultInitPrompt | `plugin.json` |
|
||||
|
||||
**转化质量要求:**
|
||||
1. 不丢信息 — 资料中每条有价值信息都体现在生成文件中
|
||||
2. 结构化整理 — 零散信息按标准结构重新组织
|
||||
3. 专业术语保留 — 原样保留不简化
|
||||
4. 大段参考资料放 `references/` — 不要塞进 Agent MD 正文
|
||||
|
||||
---
|
||||
|
||||
## 四、关键规则(铁律)
|
||||
|
||||
1. **name 字段 kebab-case**:如 `my-expert`
|
||||
2. **agentName = MD 文件名**:如 `design-expert` → `agents/design-expert.md`
|
||||
3. **agents 字段是路径数组**:如 `["./agents/my-expert.md"]`
|
||||
4. **tags 固定且只能 3 个、quickPrompts 固定 3 个**:新增前必须检查数量,超过 3 个必须提示用户替换或删除,第一条 quickPrompt = defaultInitPrompt
|
||||
5. **displayDescription 中文 40-50 字**
|
||||
6. **Agent MD frontmatter 禁止声明 tools 字段**
|
||||
7. **主理人文件名必须加专家团前缀**:如 `trading-team-lead.md`,不可用通用 `team-lead.md`
|
||||
8. **Team 型 members 数组含主理人**(role=lead),teamInfo.memberAgents 不含主理人
|
||||
9. **Team 型 profession 须与 displayName 一致**
|
||||
10. **头像自动生成后放入 avatars/**,同一团队头像风格统一
|
||||
11. **同名专家已存在时必须重新校验 + 注册**:用户要求创建专家但目标目录已存在同名专家时,如果不需要初始化目录和创建内容,也要执行后续的校验 + 注册流程,保证专家可用
|
||||
12. **批量创建必须遵循标准流程**:批量创建/转化多个专家时,每个专家必须完整串行经过 `init → validate → register`,禁止跳过校验或注册。参考 `scripts/batch_create.py`,核心模式:
|
||||
```python
|
||||
for expert in experts:
|
||||
# Step 1: init_expert.py(初始化目录)
|
||||
# Step 2: AI 填充内容(plugin.json、agents/*.md、头像 等)
|
||||
# Step 3: validate_expert.py(校验,失败则停止)
|
||||
# Step 4: register_expert.py(注册,校验通过才执行)
|
||||
```
|
||||
**禁止**:只批量写文件而跳过 validate/register;直接写入 `marketplace.json` 绕过注册脚本
|
||||
|
||||
---
|
||||
|
||||
## 五、收尾提醒
|
||||
|
||||
生成完毕后告知用户:
|
||||
1. 🎨 头像已通过 `ImageGen` 生成在 `avatars/`,可手动替换(PNG/JPG,512×512,≤500KB)
|
||||
2. 📦 打包分享:`python3 scripts/package_expert.py <expert-dir>`
|
||||
3. 📋 请核对内容是否准确
|
||||
|
||||
## References
|
||||
|
||||
- `references/plugin-json-spec.md` — plugin.json 完整字段规范和模板
|
||||
- `references/agent-md-spec.md` — Agent MD 结构模板(普通 Agent / 主理人)
|
||||
- `references/team-spec.md` — Team 型协作规范(铁律、命名、SOP)
|
||||
- `references/avatar-spec.md` — 头像生成规范和 prompt 构建策略
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"targetVersion": "WorkBuddy 5.8.2",
|
||||
"rootSha256": "035254bc76310ea46317433e4840602094543ef323d2d5c5aff7c131729de918",
|
||||
"scope": "exact retained expert-manager files; no original initialization or Python execution",
|
||||
"files": [
|
||||
{
|
||||
"file": ".codebuddy-plugin/plugin.json",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/.codebuddy-plugin/plugin.json",
|
||||
"sha256": "6908b9cd71687525e342c2500fa9d32a847f72e3e263190655ca9347402cb389",
|
||||
"bytes": 519,
|
||||
"evidenceId": "ev_c3df29dcb19c5fcaa9e458b1b9acff92e8c75ac935791e0c81900733e61b6282",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_c3df29dcb19c5fcaa9e458b1b9acff92e8c75ac935791e0c81900733e61b6282.json"
|
||||
},
|
||||
{
|
||||
"file": "SKILL.md",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/SKILL.md",
|
||||
"sha256": "18b0655c3e7573fe9402fd00a605d2dc94918c9eecd028f9f3923e74da17bc45",
|
||||
"bytes": 13860,
|
||||
"evidenceId": "ev_3cba49fcb15fefc59f54c7691500ad1121a937edb55e606addae17e4186ebfef",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_3cba49fcb15fefc59f54c7691500ad1121a937edb55e606addae17e4186ebfef.json"
|
||||
},
|
||||
{
|
||||
"file": "references/plugin-json-spec.md",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/plugin-json-spec.md",
|
||||
"sha256": "14e1a6b99c74815c22772a54725ba6d34750a6c9db4c75746c1188e31d2504f3",
|
||||
"bytes": 5226,
|
||||
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
|
||||
},
|
||||
{
|
||||
"file": "references/agent-md-spec.md",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/agent-md-spec.md",
|
||||
"sha256": "ec7373578a5f84d73ba16dcc71e73755e57aa4170cb07cd9bf379627c0f9c007",
|
||||
"bytes": 5021,
|
||||
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
|
||||
},
|
||||
{
|
||||
"file": "references/team-spec.md",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/team-spec.md",
|
||||
"sha256": "1400af007c6b1179429c6d8c856dc1d45ae25dae63645097ddbc801d25d324f9",
|
||||
"bytes": 4605,
|
||||
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
|
||||
},
|
||||
{
|
||||
"file": "references/avatar-spec.md",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/references/avatar-spec.md",
|
||||
"sha256": "0b42762e05e311632b2f89af35f34458f851a03cce3fcc0300ca0a75e9f8890e",
|
||||
"bytes": 6350,
|
||||
"evidenceId": "ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_a4b0f399496f62f4dc41998e8327fb219ab84f3dca536ec6554553605b106735.json"
|
||||
},
|
||||
{
|
||||
"file": "scripts/register_expert.py",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/register_expert.py",
|
||||
"sha256": "b2dd1a0141c2dc88ed7203246335fd261eca97a17f91f4e280d3ce8b10a65b1e",
|
||||
"bytes": 10116,
|
||||
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
|
||||
},
|
||||
{
|
||||
"file": "scripts/init_expert.py",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/init_expert.py",
|
||||
"sha256": "742fe9b70085a31850caf327d83f416ff19e9ee7482053e7aa99bd9b780206e4",
|
||||
"bytes": 14149,
|
||||
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
|
||||
},
|
||||
{
|
||||
"file": "scripts/validate_expert.py",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/validate_expert.py",
|
||||
"sha256": "6e85c716af94d6ee2ffc86a24a7230d15cd675a0fbaaf45a8980fd51d9c1c6fd",
|
||||
"bytes": 14346,
|
||||
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
|
||||
},
|
||||
{
|
||||
"file": "scripts/package_expert.py",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/package_expert.py",
|
||||
"sha256": "e3d82044fd5fc7b749d65c178af0bed18bbc0375a952b878cfa019b09e04d8fb",
|
||||
"bytes": 3631,
|
||||
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
|
||||
},
|
||||
{
|
||||
"file": "scripts/batch_create.py",
|
||||
"originalMember": "resources/plugins/workbuddy-builtin/skills/expert-manager/scripts/batch_create.py",
|
||||
"sha256": "c926d7b8721c4ec4179a3d481271bdb01d41764c3301e01cdafd9e96c37e154c",
|
||||
"bytes": 5106,
|
||||
"evidenceId": "ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8",
|
||||
"retainedExport": ".tmp/rea-expert-package-ev_25ded03a70790361a3f4fef21a3bd2466d5f7d3d118b7b7aada33b07efcf5cf8.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
# Agent MD 规范
|
||||
|
||||
## Frontmatter
|
||||
|
||||
### ⚠️ 重要:frontmatter 中不可添加 tools 字段
|
||||
|
||||
所有工具权限由系统统一分配,开发者无需也不能在 frontmatter 中声明 `tools` 字段。
|
||||
|
||||
### 必填字段
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: {与文件名一致,必须有业务语义}
|
||||
description: {英文描述,AI 用来判断何时激活}
|
||||
displayName:
|
||||
en: "{English display name}"
|
||||
zh: "{中文显示名称}"
|
||||
profession:
|
||||
en: "{English profession title}"
|
||||
zh: "{中文职业头衔}"
|
||||
maxTurns: {默认50}
|
||||
---
|
||||
```
|
||||
|
||||
### 可选字段
|
||||
|
||||
```yaml
|
||||
skills: [{skill-name}] # 启动时预加载的 Skill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 正文结构:普通 Agent / 团员
|
||||
|
||||
```markdown
|
||||
# {角色名称} - {人名}
|
||||
|
||||
{角色描述,说明这是谁,擅长什么}
|
||||
|
||||
## 核心能力
|
||||
1. **{能力1}**:{描述}
|
||||
2. **{能力2}**:{描述}
|
||||
3. **{能力3}**:{描述}
|
||||
|
||||
## 工作流程
|
||||
1. {步骤1}
|
||||
2. {步骤2}
|
||||
3. {步骤3}
|
||||
|
||||
## 输出规范
|
||||
- {规范1}
|
||||
- {规范2}
|
||||
- {规范3}
|
||||
|
||||
## 注意事项
|
||||
- {约束或边界条件}
|
||||
```
|
||||
|
||||
### 团队成员额外要求
|
||||
|
||||
- 不要在 frontmatter 中限制 tools 范围
|
||||
- 由主理人通过 Agent 工具 spawn 为正式 teammate
|
||||
- 分析完成后必须通过 SendMessage 将结果回传给主理人
|
||||
- Prompt 中须明确写出 SendMessage 回传要求
|
||||
|
||||
### 成员 Prompt 必备结构
|
||||
|
||||
1. **角色定义**:一句话说清"你是谁"
|
||||
2. **擅长领域**:3-5 个具体能力点
|
||||
3. **分析框架**:内嵌的分析能力,分步骤流程
|
||||
4. **数据获取方式**:具体的查询命令或工具调用
|
||||
5. **结构化输出模板**:表格/分段格式
|
||||
6. **SendMessage 回传要求**
|
||||
|
||||
---
|
||||
|
||||
## 正文结构:主理人
|
||||
|
||||
> ⚠️ 文件名不可使用通用的 `team-lead.md`,必须加上专家团前缀,如 `trading-team-lead.md`
|
||||
|
||||
### Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: {team}-team-lead
|
||||
description: {English description}
|
||||
displayName:
|
||||
en: "{English display name}"
|
||||
zh: "{中文显示名称}"
|
||||
profession:
|
||||
en: "{English profession title}"
|
||||
zh: "{中文职业头衔}"
|
||||
maxTurns: {turns, 建议150-200}
|
||||
---
|
||||
```
|
||||
|
||||
### 正文模板
|
||||
|
||||
```markdown
|
||||
# {团队名称} - 主理人
|
||||
|
||||
{主理人角色描述,负责协调团队完成什么任务}
|
||||
|
||||
## 团队成员
|
||||
|
||||
### {分组名称}
|
||||
| 成员 | 名字 | 职责 |
|
||||
|------|------|------|
|
||||
| {member-id} | {name} | {职责描述} |
|
||||
|
||||
## 标准工作流程(SOP)
|
||||
|
||||
### Phase 1: {阶段名}
|
||||
{调用哪些成员、输入输出说明}
|
||||
|
||||
### Phase 2: {阶段名}
|
||||
{...}
|
||||
|
||||
### Phase N: 最终报告
|
||||
综合所有分析结果,生成最终报告返回用户。
|
||||
|
||||
## 团队协作机制(铁律)
|
||||
|
||||
你必须走正式的**团队协作流程**,严禁简化或跳过:
|
||||
|
||||
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
|
||||
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
|
||||
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
|
||||
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
|
||||
|
||||
### 严禁行为
|
||||
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
|
||||
- ❌ 禁止自己代写任何团队成员的专业产出
|
||||
- ❌ 禁止未完成前序阶段就跳到后续阶段
|
||||
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
|
||||
- ❌ 禁止 spawn 主理人自己
|
||||
|
||||
## 协作规则
|
||||
1. 所有成员调度必须经过"建立团队 → 调度成员 → 成员回传"流程
|
||||
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
|
||||
3. 每完成一个阶段向用户简要通报
|
||||
4. 所有输出使用与用户原始需求相同的语言
|
||||
5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主理人额外要求
|
||||
|
||||
### 成员能力清单
|
||||
|
||||
主理人 prompt 中必须列出每个成员的:
|
||||
- Agent ID
|
||||
- 擅长领域(3-5 个具体能力点)
|
||||
- 典型问法(什么问题该调它)
|
||||
|
||||
### 预设 Workflow
|
||||
|
||||
针对高频综合性问题设计预设 Workflow,每个 Workflow 写明:
|
||||
- **触发条件**:什么类型的问法匹配
|
||||
- **Phase 编排**:分 Phase 的串并行调度
|
||||
- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁
|
||||
|
||||
### 单 agent 直调路由表
|
||||
|
||||
| 问法类型 | 直接调谁 |
|
||||
|---------|---------|
|
||||
| 单一维度问题 | 对应成员 |
|
||||
| 综合性问题 | 走预设 Workflow |
|
||||
|
||||
---
|
||||
|
||||
## 能独立成 agent 的标准
|
||||
|
||||
判断标准:**有没有用户会直接问它的问题?**
|
||||
- 有 → 独立成 agent
|
||||
- 没有 → 归入其他 agent
|
||||
|
||||
每个 agent 覆盖一个完整的"分析域",域内多个能力归并进来,但不跨域。跨域协作由主理人通过 Workflow 编排。
|
||||
@@ -0,0 +1,162 @@
|
||||
# 头像生成规范
|
||||
|
||||
## 基本要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 格式 | PNG(推荐)或 JPG |
|
||||
| 尺寸 | 512×512 px(正方形) |
|
||||
| 大小 | 单张不超过 500KB |
|
||||
| 风格 | 统一漫画/插画风格,专业自然 |
|
||||
| 内容 | 符合角色定位,不含违规内容 |
|
||||
|
||||
> 使用 `ImageGen` 工具生成,`size` 参数设为 `"1024x1024"`(高清大图,市场缩放为 512×512 展示)。
|
||||
|
||||
## 生成策略
|
||||
|
||||
**Agent 型**:1 张头像
|
||||
- `avatars/expert.png`
|
||||
|
||||
**Team 型**:N+1 张头像
|
||||
- `avatars/team.png` — 团队整体头像
|
||||
- `avatars/{team}-team-lead.png` — 主理人头像
|
||||
- `avatars/{member-name}.png` — 每个团员头像
|
||||
|
||||
---
|
||||
|
||||
## Prompt 构建核心原则
|
||||
|
||||
**每个头像的 prompt 必须从对应的 MD 文件描述中提取角色特征,不使用通用模板硬编码。**
|
||||
|
||||
### 提取步骤
|
||||
|
||||
1. **读取 Agent MD 文件**
|
||||
2. **提取角色身份**:从标题和首段提取
|
||||
3. **提取专业特征**:从"核心能力"章节提取关键词,转化为视觉元素
|
||||
4. **推断工作风格**:从"工作流程"和"注意事项"推断性格气质
|
||||
5. **推断人物属性**:从 name 字段推断性别和风格基调
|
||||
|
||||
---
|
||||
|
||||
## 个人头像 Prompt 组装
|
||||
|
||||
```
|
||||
[风格前缀] + [角色身份] + [外观特征] + [表情气质] + [背景元素] + [质量后缀]
|
||||
```
|
||||
|
||||
| 部分 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 风格前缀 | 统一漫画/插画风格 | `Professional cartoon-style illustration avatar,` |
|
||||
| 角色身份 | 从 MD 标题/首段提取 | `a female design system document architect` |
|
||||
| 外观特征 | 从核心能力推断穿着/配饰 | `wearing stylish glasses, holding a design specification document` |
|
||||
| 表情气质 | 从工作风格推断 | `confident and meticulous expression` |
|
||||
| 背景元素 | 从专业领域提取视觉符号 | `subtle design tokens and color palette swatches in background` |
|
||||
| 质量后缀 | 固定 | `Bust shot, facing forward. Clean simple background. High quality, professional, natural.` |
|
||||
|
||||
### 示例 1:设计系统架构师
|
||||
|
||||
MD 核心内容:角色=设计系统文档架构师,能力=9大标准章节、AI可读格式,输出=Markdown+HEX+CSS
|
||||
|
||||
```
|
||||
Professional cartoon-style illustration avatar, a female design system document architect,
|
||||
wearing stylish glasses, holding a design guideline document, modern creative smart casual attire,
|
||||
confident and meticulous expression with a creative yet precise aura,
|
||||
subtle color palette swatches, typography samples and design token symbols in the background.
|
||||
Bust shot, facing forward. Clean simple warm-toned background. High quality, professional, natural.
|
||||
```
|
||||
|
||||
### 示例 2:技术分析师
|
||||
|
||||
MD 核心内容:角色=技术分析师,能力=K线形态、均线分析、MACD/RSI/KDJ
|
||||
|
||||
```
|
||||
Professional cartoon-style illustration avatar, a male technical stock market analyst named Marco,
|
||||
wearing a sharp vest over dress shirt, looking at holographic candlestick charts,
|
||||
focused and analytical expression with sharp observant eyes,
|
||||
K-line charts, moving average lines and MACD indicators floating in the background.
|
||||
Bust shot, facing forward. Clean simple blue-toned background. High quality, professional, natural.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 团队头像 Prompt 构建
|
||||
|
||||
**输入来源**:plugin.json 的 `displayDescription` + 主理人 MD 的团队描述
|
||||
|
||||
### 提取步骤
|
||||
|
||||
1. **团队定位**:从 displayDescription 提取团队做什么
|
||||
2. **协作模式**:从主理人 MD 的 SOP 提取工作阶段
|
||||
3. **成员构成**:从成员列表提取角色类型的多样性
|
||||
4. **视觉表达**:将以上信息转化为体现团队协作的场景
|
||||
|
||||
### Prompt 组装
|
||||
|
||||
```
|
||||
[风格前缀] + [团队场景描述] + [协作元素] + [成员象征] + [质量后缀]
|
||||
```
|
||||
|
||||
### 示例:交易分析团队
|
||||
|
||||
```
|
||||
Professional cartoon-style illustration, a dynamic stock trading analysis team scene,
|
||||
multiple diverse analysts gathered around a central holographic display showing candlestick charts,
|
||||
a bull figure and a bear figure debating on opposite sides symbolizing bull-bear debate,
|
||||
risk gauges and decision dashboards floating around,
|
||||
warm collaborative atmosphere with focused professional energy.
|
||||
Clean simple multi-tone gradient background. High quality, professional, team composition.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 同一团队风格统一规则
|
||||
|
||||
Team 型的所有头像必须在 prompt 中保持一致的**风格锚定词**:
|
||||
|
||||
**固定风格前缀(每个 prompt 开头):**
|
||||
```
|
||||
Professional cartoon-style illustration avatar, consistent art style with warm lighting and soft shadows,
|
||||
```
|
||||
|
||||
**固定质量后缀(每个 prompt 结尾):**
|
||||
```
|
||||
Bust shot, facing forward. Clean simple {color}-toned background. High quality, professional, natural.
|
||||
```
|
||||
|
||||
### 背景色调映射
|
||||
|
||||
| categoryId | 背景色调 |
|
||||
|------------|---------|
|
||||
| 01-ProductDesign | warm orange-coral |
|
||||
| 02-Engineering | blue-purple |
|
||||
| 03-GameSpatial | purple-red gradient |
|
||||
| 04-DataAI | cyan-teal |
|
||||
| 05-MarketingGrowth | red-orange |
|
||||
| 06-ContentCreative | pink-magenta |
|
||||
| 07-SalesCommerce | golden-amber |
|
||||
| 08-FinanceInvestment | dark blue with gold accent |
|
||||
| 09-OperationsHR | navy slate-blue |
|
||||
| 10-ProjectQuality | green-emerald |
|
||||
| 11-SecurityCompliance | dark grey-blue |
|
||||
| 12-IndustryConsultant | deep teal with silver accent |
|
||||
|
||||
---
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. **读取 Agent MD** — 逐个读取 agents/ 下每个 MD
|
||||
2. **提取角色特征** — 从角色定义、核心能力、工作流程中提取
|
||||
3. **构建个人 prompt** — 按上述步骤将特征转化为视觉描述
|
||||
4. **构建团队 prompt**(Team 型)— 从 displayDescription 和主理人 MD 提取
|
||||
5. **统一风格锚定** — 确保所有 prompt 使用相同的风格前缀和后缀
|
||||
6. **调用 ImageGen** — 逐张生成,`output_dir` 为专家包的 `avatars/`,`size` 为 `"1024x1024"`
|
||||
7. **重命名文件** — 将生成的图片重命名为 plugin.json 中声明的文件名
|
||||
8. **验证** — 确认所有头像文件已存在于 avatars/
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **必须基于 MD 描述生成**:不要使用通用 prompt
|
||||
2. **团队头像体现协作**:不是简单人物剪影
|
||||
3. **同一团队画风一致**:共用风格锚定词和背景色调
|
||||
4. **生成失败处理**:在 README.md 中标注需手动补充的头像,附推荐 prompt
|
||||
5. **用户可替换**:提醒自动生成的头像可手动替换(512×512,PNG/JPG,≤500KB)
|
||||
@@ -0,0 +1,184 @@
|
||||
# plugin.json 字段规范
|
||||
|
||||
## 基础字段(必填)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | string | 唯一标识,小写字母+连字符,也是技能命名空间前缀 |
|
||||
| `version` | string | 语义化版本号(MAJOR.MINOR.PATCH) |
|
||||
| `description` | string | 英文一句话描述 |
|
||||
|
||||
## 可选基础字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `author` | `{name, email}` | 作者信息 |
|
||||
| `homepage` | string 或 `{url, type}` | 项目主页 |
|
||||
| `license` | string | 许可证 |
|
||||
| `keywords` | string[] | 搜索标签 |
|
||||
|
||||
## 类型字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `expertType` | `"agent"` / `"team"` |
|
||||
| `agentName` | 主 Agent 名称(对应 agents/ 下 MD 文件名,不含 .md)。**必须有业务语义**,不能使用 `team-lead` 等通用名 |
|
||||
| `teamInfo` | team 时必填:`{leadAgent, memberAgents[]}` |
|
||||
|
||||
## 资源声明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `agents` | string[] | Agent 定义文件路径列表(如 `["./agents/my-expert.md"]`) |
|
||||
| `skills` | string[] | Skill 目录路径列表(如 `["./skills/my-skill"]`) |
|
||||
|
||||
## 展示字段(agent/team 上架市场时必填)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `displayName` | `{en, zh}` | 展示名称 |
|
||||
| `profession` | `{en, zh}` | 职业/定位。**Team 型须与 displayName 一致** |
|
||||
| `displayDescription` | `{en, zh}` | 展示描述。**中文 40-50 字,突出核心能力** |
|
||||
| `avatar` | string | 头像相对路径 |
|
||||
| `categoryId` | string | 行业分类 ID |
|
||||
| `defaultInitPrompt` | `{en, zh}` | 默认引导语。**须与 quickPrompts 第一条一致** |
|
||||
| `tags` | `{en, zh}[]` | 擅长领域标签(**固定 3 个**) |
|
||||
| `quickPrompts` | `{en, zh}[]` | 推荐提示词(**固定 3 个**) |
|
||||
| `plugin` | string | 值与 `name` 一致 |
|
||||
|
||||
## Team 专用字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `members[]` | 每个成员含 `{id, name:{en,zh}, profession:{en,zh}, avatar, role}` |
|
||||
|
||||
- `role` 取值:`"lead"` 或 `"member"`
|
||||
- 主理人也必须在 members 中,role 为 `"lead"`
|
||||
- `teamInfo.memberAgents` **不含**主理人
|
||||
|
||||
---
|
||||
|
||||
## 模板:Agent 型
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "{kebab-case-name}",
|
||||
"version": "1.0.0",
|
||||
"description": "{English one-line description}",
|
||||
"author": {
|
||||
"name": "{author-name}",
|
||||
"email": "{author-email}"
|
||||
},
|
||||
"agents": ["./agents/{agent-name}.md"],
|
||||
"skills": ["./skills/{skill-name}"],
|
||||
|
||||
"expertType": "agent",
|
||||
"agentName": "{agent-name}",
|
||||
|
||||
"displayName": {
|
||||
"en": "{English display name}",
|
||||
"zh": "{中文显示名称}"
|
||||
},
|
||||
"profession": {
|
||||
"en": "{English profession}",
|
||||
"zh": "{中文职业头衔}"
|
||||
},
|
||||
"displayDescription": {
|
||||
"en": "{English detailed description}",
|
||||
"zh": "{中文详细描述,40-50字}"
|
||||
},
|
||||
"avatar": "avatars/expert.png",
|
||||
"categoryId": "{XX-CategoryName}",
|
||||
"defaultInitPrompt": {
|
||||
"zh": "{中文首次对话提示}",
|
||||
"en": "{English first prompt}"
|
||||
},
|
||||
"plugin": "{kebab-case-name}",
|
||||
"tags": [
|
||||
{ "en": "{Tag1 EN}", "zh": "{标签1}" },
|
||||
{ "en": "{Tag2 EN}", "zh": "{标签2}" },
|
||||
{ "en": "{Tag3 EN}", "zh": "{标签3}" }
|
||||
],
|
||||
"quickPrompts": [
|
||||
{ "en": "{Prompt1 EN}", "zh": "{提示词1}" },
|
||||
{ "en": "{Prompt2 EN}", "zh": "{提示词2}" },
|
||||
{ "en": "{Prompt3 EN}", "zh": "{提示词3}" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 如果没有 skills,则省略 `"skills"` 字段。
|
||||
|
||||
## 模板:Team 型
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "{kebab-case-name}",
|
||||
"version": "1.0.0",
|
||||
"description": "{English one-line description}",
|
||||
"author": {
|
||||
"name": "{author-name}",
|
||||
"email": "{author-email}"
|
||||
},
|
||||
"agents": [
|
||||
"./agents/{team}-team-lead.md",
|
||||
"./agents/{member-a}.md",
|
||||
"./agents/{member-b}.md"
|
||||
],
|
||||
"skills": ["./skills/{skill-name}"],
|
||||
|
||||
"expertType": "team",
|
||||
"agentName": "{team}-team-lead",
|
||||
"teamInfo": {
|
||||
"leadAgent": "{team}-team-lead",
|
||||
"memberAgents": ["{member-a}", "{member-b}"]
|
||||
},
|
||||
|
||||
"displayName": {
|
||||
"en": "{English team name}",
|
||||
"zh": "{中文团队名称}"
|
||||
},
|
||||
"profession": {
|
||||
"en": "{English team name}",
|
||||
"zh": "{中文团队名称}"
|
||||
},
|
||||
"displayDescription": {
|
||||
"en": "{English team description}",
|
||||
"zh": "{中文团队描述,40-50字}"
|
||||
},
|
||||
"avatar": "avatars/team.png",
|
||||
"categoryId": "{XX-CategoryName}",
|
||||
"defaultInitPrompt": {
|
||||
"zh": "{中文首次问候}",
|
||||
"en": "{English first prompt}"
|
||||
},
|
||||
"plugin": "{kebab-case-name}",
|
||||
"tags": [
|
||||
{ "en": "{Tag1 EN}", "zh": "{标签1}" },
|
||||
{ "en": "{Tag2 EN}", "zh": "{标签2}" },
|
||||
{ "en": "{Tag3 EN}", "zh": "{标签3}" }
|
||||
],
|
||||
"quickPrompts": [
|
||||
{ "en": "{Prompt1 EN}", "zh": "{提示词1}" },
|
||||
{ "en": "{Prompt2 EN}", "zh": "{提示词2}" },
|
||||
{ "en": "{Prompt3 EN}", "zh": "{提示词3}" }
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"id": "{team}-team-lead",
|
||||
"displayName": { "en": "{EN}", "zh": "{ZH}" },
|
||||
"profession": { "en": "{EN}", "zh": "{ZH}" },
|
||||
"avatar": "avatars/{team}-team-lead.png",
|
||||
"role": "lead"
|
||||
},
|
||||
{
|
||||
"id": "{member-name}",
|
||||
"displayName": { "en": "{EN}", "zh": "{ZH}" },
|
||||
"profession": { "en": "{EN}", "zh": "{ZH}" },
|
||||
"avatar": "avatars/{member-name}.png",
|
||||
"role": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Team 型专用规范
|
||||
|
||||
## 成员命名规范
|
||||
|
||||
Team 型专家团中,每个成员有三个外露字段:`name`(名字)、`profession`(职业头衔)、`nickname`(昵称,可选)。三者不应重复。
|
||||
|
||||
### name:谐音花名风格
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 建议三个字 | 姓+名,建议三个字,两个字也可 |
|
||||
| 先是正常人名 | 姓+名结构,不解释也能当名字用 |
|
||||
| 暗含职能巧思 | 谐音、拆字、典故均可,但不刻意 |
|
||||
| 不与 profession 重复 | name 是"谁",profession 是"做什么" |
|
||||
| 中英文都自然 | 中文用花名,英文用拼音姓氏 |
|
||||
|
||||
**示例(软件开发团队):**
|
||||
|
||||
| profession | name(zh) | 巧思 | name(en) |
|
||||
|-----------|----------|------|----------|
|
||||
| 交付总监 | 齐活林 | 齐活了(交付完成) | Qi |
|
||||
| 产品经理 | 许清楚 | 需求要说清楚 | Xu |
|
||||
| 架构师 | 高见远 | 高见(架构视野) | Gao |
|
||||
| 项目经理 | 毕达成 | 必达(交付必达) | Bi |
|
||||
| 工程师 | 寇豆码 | code(代码) | Kou |
|
||||
| QA工程师 | 严过关 | 严格过关 | Yan |
|
||||
|
||||
**禁止:**
|
||||
- 叠字谐音(领码码、需求求、蓝图图)
|
||||
- 一个字的 name 或纯职能词
|
||||
- 和 profession 重复
|
||||
- 无意义随机名(张三、John Doe)
|
||||
- 英文 name 用 Agent ID
|
||||
- 读起来是短语而非人名
|
||||
|
||||
### 主理人 profession 规范
|
||||
|
||||
主理人的 `profession` 不能用通用 title(团长、主理人、Team Lead),应体现该团队的调度风格和业务定位。
|
||||
|
||||
**示例:**
|
||||
|
||||
| 专家团 | 主理人 name | profession(zh) | profession(en) |
|
||||
|--------|-----------|---------------|----------------|
|
||||
| 软件开发团队 | 齐活林 | 交付总监 | Delivery Director |
|
||||
| 交易分析团队 | 何执舟 | 首席策略官 | Chief Strategist |
|
||||
| 营销战役团队 | 营销总监 | 增长操盘手 | Growth Operator |
|
||||
| 深度研究团队 | 顾全之 | 研究主编 | Research Editor |
|
||||
|
||||
---
|
||||
|
||||
## 协作铁律
|
||||
|
||||
### 4 条正则
|
||||
|
||||
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
|
||||
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
|
||||
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
|
||||
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
|
||||
|
||||
### 5 条红线
|
||||
|
||||
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
|
||||
- ❌ 禁止自己代写任何团队成员的专业产出
|
||||
- ❌ 禁止未完成前序阶段就跳到后续阶段
|
||||
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
|
||||
- ❌ 禁止 spawn 主理人自己(编排、汇总、决策由主理人亲自完成)
|
||||
|
||||
### 协作规则
|
||||
|
||||
1. 所有成员调度必须经过"TeamCreate → Agent spawn → SendMessage 回传"正式流程
|
||||
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
|
||||
3. 调度成员时,在 Agent 工具的 `name` 参数中传入该成员的 **Agent ID**(agents/ 下的 MD 文件名,不含 .md),`subagent_type` 也传入相同值。**禁止**使用中文名或自创名称
|
||||
4. 裁决型角色(如研究主管、风险主管)必须给出明确结论,不得回避决策
|
||||
5. 每完成一个阶段向用户简要通报进度
|
||||
|
||||
---
|
||||
|
||||
## SOP 工作流编排
|
||||
|
||||
### 并行 vs 串行
|
||||
|
||||
- **并行 Phase**:同一条消息中 spawn 多个成员(使用 TeamCreate),适用于成员间无数据依赖
|
||||
- **串行 Phase**:等前一 Phase 全部回传后,将结论传入下一 Phase 的成员 prompt
|
||||
|
||||
### 示例 SOP
|
||||
|
||||
```
|
||||
Phase 1(并行):
|
||||
stock-researcher → 基本面+财务
|
||||
valuation-pricer → 估值判断
|
||||
money-tracker → 资金态度
|
||||
|
||||
Phase 2(串行,Phase 1 结论传入):
|
||||
risk-doctor → 综合风险诊断
|
||||
|
||||
主理人汇编 → 输出
|
||||
```
|
||||
|
||||
### 预设 Workflow 设计原则
|
||||
|
||||
每个 Workflow 写明:
|
||||
- **触发条件**:什么类型的问法匹配此 Workflow
|
||||
- **Phase 编排**:分 Phase 的串并行调度
|
||||
- **输入输出依赖**:每个 Phase 的输入来自哪里、输出传给谁
|
||||
|
||||
Workflow 数量根据用户实际高频场景设计,不是越多越好。
|
||||
|
||||
---
|
||||
|
||||
## settings.json
|
||||
|
||||
Team 型必须有 `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": "{team}-team-lead"
|
||||
}
|
||||
```
|
||||
|
||||
值必须与 plugin.json 的 `agentName` 和主理人 MD 文件的 `name` 一致。
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量创建专家 — 每个专家串行走完整标准流程(init → validate → register)。
|
||||
|
||||
Usage:
|
||||
python3 scripts/batch_create.py <batch-config.json> [--session-id <id>]
|
||||
|
||||
batch-config.json:
|
||||
{
|
||||
"path": "<expert-plugins-dir>",
|
||||
"experts": [
|
||||
{ "name": "my-expert", "type": "agent" },
|
||||
{ "name": "my-team", "type": "team" }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
REGISTER_MAX_RETRIES = 2
|
||||
REGISTER_RETRY_DELAY = 1 # seconds
|
||||
|
||||
|
||||
def run_step(script_name: str, args: list) -> bool:
|
||||
"""运行单个标准流程脚本,返回是否成功。"""
|
||||
cmd = [sys.executable, str(SCRIPT_DIR / script_name)] + args
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.stdout.strip():
|
||||
print(result.stdout.strip())
|
||||
if result.returncode != 0 and result.stderr.strip():
|
||||
print(result.stderr.strip())
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def process_one_expert(name: str, expert_type: str, output_dir: Path, session_id: str = None) -> bool:
|
||||
"""
|
||||
单个专家的完整标准流程。
|
||||
每个专家必须串行经过:init → validate → register,缺一不可。
|
||||
"""
|
||||
expert_dir = output_dir / name
|
||||
expert_path = str(expert_dir)
|
||||
|
||||
# Step 1: 初始化目录
|
||||
print(f"\n{'─'*40}")
|
||||
print(f"📦 [{name}] Step 1/3: 初始化")
|
||||
if not run_step('init_expert.py', [name, '--type', expert_type, '--path', str(output_dir)]):
|
||||
print(f" ❌ [{name}] 初始化失败,跳过该专家")
|
||||
return False
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Step 2: AI 填充内容(在实际使用中,这里由 AI 写入文件)
|
||||
# 本脚本仅做流程示例,不包含内容生成逻辑。
|
||||
# AI 在此步骤应写入 plugin.json、agents/*.md、头像等文件。
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
# Step 3: 校验(失败提醒修复后重试)
|
||||
print(f"📋 [{name}] Step 2/3: 校验")
|
||||
if not run_step('validate_expert.py', [expert_path]):
|
||||
print(f" ❌ [{name}] 校验失败,请修复内容后重新校验: python3 scripts/validate_expert.py {expert_path}")
|
||||
return False
|
||||
|
||||
# Step 4: 注册(含重试,失败则提醒)
|
||||
print(f"📝 [{name}] Step 3/3: 注册")
|
||||
register_args = [expert_path]
|
||||
if session_id:
|
||||
register_args.extend(['--session-id', session_id])
|
||||
|
||||
for attempt in range(1, REGISTER_MAX_RETRIES + 1):
|
||||
if run_step('register_expert.py', register_args):
|
||||
print(f" ✅ [{name}] 完成")
|
||||
return True
|
||||
if attempt < REGISTER_MAX_RETRIES:
|
||||
print(f" ⚠️ 注册失败,{REGISTER_RETRY_DELAY}s 后重试 ({attempt}/{REGISTER_MAX_RETRIES})...")
|
||||
time.sleep(REGISTER_RETRY_DELAY)
|
||||
|
||||
# 重试耗尽:保留目录(内容已生成),提醒手动重试注册
|
||||
print(f" ❌ [{name}] 注册失败(已重试 {REGISTER_MAX_RETRIES} 次),目录已保留: {expert_path}")
|
||||
print(f" 💡 请稍后手动重试: python3 scripts/register_expert.py {expert_path} --session-id <session-id>")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
config_path = Path(sys.argv[1]).resolve()
|
||||
session_id = None
|
||||
|
||||
i = 2
|
||||
while i < len(sys.argv):
|
||||
if sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):
|
||||
session_id = sys.argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
default_config = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')
|
||||
default_plugins_path = str(Path(default_config) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins')
|
||||
output_dir = Path(config.get('path', default_plugins_path)).expanduser().resolve()
|
||||
experts = config.get('experts', [])
|
||||
|
||||
if not experts:
|
||||
print("❌ 配置中无专家列表")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🚀 批量创建 {len(experts)} 个专家 → {output_dir}\n")
|
||||
|
||||
passed = []
|
||||
failed = []
|
||||
|
||||
# ⚠️ 串行执行:逐个专家依次走完整流程,禁止并行/异步。
|
||||
for expert in experts:
|
||||
name = expert.get('name', '')
|
||||
expert_type = expert.get('type', 'agent')
|
||||
if not name:
|
||||
continue
|
||||
if process_one_expert(name, expert_type, output_dir, session_id):
|
||||
passed.append(name)
|
||||
else:
|
||||
failed.append(name)
|
||||
|
||||
# 汇总
|
||||
print(f"\n{'═'*40}")
|
||||
print(f"📊 结果: {len(passed)} 成功, {len(failed)} 失败")
|
||||
if failed:
|
||||
print(f" 失败: {', '.join(failed)}")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Expert Initializer - Creates a new expert package from template
|
||||
|
||||
Usage:
|
||||
init_expert.py <expert-name> --type agent|team --path <output-dir>
|
||||
|
||||
Examples:
|
||||
init_expert.py my-expert --type agent --path ./output
|
||||
init_expert.py my-team --type team --path ./output
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_expert_plugins_dir():
|
||||
"""获取专家目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。"""
|
||||
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()
|
||||
if not config_dir:
|
||||
config_dir = str(Path.home() / '.workbuddy')
|
||||
return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'
|
||||
|
||||
|
||||
def title_case(name):
|
||||
"""Convert kebab-case to Title Case."""
|
||||
return ' '.join(word.capitalize() for word in name.split('-'))
|
||||
|
||||
|
||||
AGENT_PLUGIN_JSON = """{
|
||||
"name": "%(name)s",
|
||||
"version": "1.0.0",
|
||||
"description": "[TODO: English one-line description]",
|
||||
"author": {
|
||||
"name": "[TODO: author name]",
|
||||
"email": "[TODO: author email]"
|
||||
},
|
||||
"agents": ["./agents/%(agent_name)s.md"],
|
||||
|
||||
"expertType": "agent",
|
||||
"agentName": "%(agent_name)s",
|
||||
|
||||
"displayName": {
|
||||
"en": "[TODO: English display name]",
|
||||
"zh": "[TODO: 中文显示名称]"
|
||||
},
|
||||
"profession": {
|
||||
"en": "[TODO: English profession]",
|
||||
"zh": "[TODO: 中文职业头衔]"
|
||||
},
|
||||
"displayDescription": {
|
||||
"en": "[TODO: English detailed description]",
|
||||
"zh": "[TODO: 中文详细描述,40-50字]"
|
||||
},
|
||||
"avatar": "avatars/expert.png",
|
||||
"categoryId": "[TODO: XX-CategoryName]",
|
||||
"defaultInitPrompt": {
|
||||
"zh": "[TODO: 中文首次对话提示]",
|
||||
"en": "[TODO: English first prompt]"
|
||||
},
|
||||
"plugin": "%(name)s",
|
||||
"tags": [
|
||||
{ "en": "[TODO: Tag1]", "zh": "[TODO: 标签1]" },
|
||||
{ "en": "[TODO: Tag2]", "zh": "[TODO: 标签2]" },
|
||||
{ "en": "[TODO: Tag3]", "zh": "[TODO: 标签3]" }
|
||||
],
|
||||
"quickPrompts": [
|
||||
{ "en": "[TODO: Prompt1, same as defaultInitPrompt]", "zh": "[TODO: 提示词1,同defaultInitPrompt]" },
|
||||
{ "en": "[TODO: Prompt2]", "zh": "[TODO: 提示词2]" },
|
||||
{ "en": "[TODO: Prompt3]", "zh": "[TODO: 提示词3]" }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
TEAM_PLUGIN_JSON = """{
|
||||
"name": "%(name)s",
|
||||
"version": "1.0.0",
|
||||
"description": "[TODO: English one-line description]",
|
||||
"author": {
|
||||
"name": "[TODO: author name]",
|
||||
"email": "[TODO: author email]"
|
||||
},
|
||||
"agents": [
|
||||
"./agents/%(team)s-team-lead.md",
|
||||
"./agents/[TODO: member-a].md"
|
||||
],
|
||||
|
||||
"expertType": "team",
|
||||
"agentName": "%(team)s-team-lead",
|
||||
"teamInfo": {
|
||||
"leadAgent": "%(team)s-team-lead",
|
||||
"memberAgents": ["[TODO: member-a]"]
|
||||
},
|
||||
|
||||
"displayName": {
|
||||
"en": "[TODO: English team name]",
|
||||
"zh": "[TODO: 中文团队名称]"
|
||||
},
|
||||
"profession": {
|
||||
"en": "[TODO: same as displayName.en]",
|
||||
"zh": "[TODO: 同displayName.zh]"
|
||||
},
|
||||
"displayDescription": {
|
||||
"en": "[TODO: English team description]",
|
||||
"zh": "[TODO: 中文团队描述,40-50字]"
|
||||
},
|
||||
"avatar": "avatars/team.png",
|
||||
"categoryId": "[TODO: XX-CategoryName]",
|
||||
"defaultInitPrompt": {
|
||||
"zh": "[TODO: 中文首次问候]",
|
||||
"en": "[TODO: English first prompt]"
|
||||
},
|
||||
"plugin": "%(name)s",
|
||||
"tags": [
|
||||
{ "en": "[TODO: Tag1]", "zh": "[TODO: 标签1]" },
|
||||
{ "en": "[TODO: Tag2]", "zh": "[TODO: 标签2]" },
|
||||
{ "en": "[TODO: Tag3]", "zh": "[TODO: 标签3]" }
|
||||
],
|
||||
"quickPrompts": [
|
||||
{ "en": "[TODO: Prompt1, same as defaultInitPrompt]", "zh": "[TODO: 提示词1,同defaultInitPrompt]" },
|
||||
{ "en": "[TODO: Prompt2]", "zh": "[TODO: 提示词2]" },
|
||||
{ "en": "[TODO: Prompt3]", "zh": "[TODO: 提示词3]" }
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"id": "%(team)s-team-lead",
|
||||
"displayName": { "en": "[TODO]", "zh": "[TODO]" },
|
||||
"profession": { "en": "[TODO]", "zh": "[TODO]" },
|
||||
"avatar": "avatars/%(team)s-team-lead.png",
|
||||
"role": "lead"
|
||||
},
|
||||
{
|
||||
"id": "[TODO: member-a]",
|
||||
"displayName": { "en": "[TODO]", "zh": "[TODO]" },
|
||||
"profession": { "en": "[TODO]", "zh": "[TODO]" },
|
||||
"avatar": "avatars/[TODO: member-a].png",
|
||||
"role": "member"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
AGENT_MD_TEMPLATE = """---
|
||||
name: %(agent_name)s
|
||||
description: "[TODO: English description for AI to determine when to activate]"
|
||||
displayName:
|
||||
en: "[TODO: English display name]"
|
||||
zh: "[TODO: 中文显示名称]"
|
||||
profession:
|
||||
en: "[TODO: English profession title]"
|
||||
zh: "[TODO: 中文职业头衔]"
|
||||
maxTurns: 50
|
||||
---
|
||||
|
||||
# [TODO: 角色名称] - [TODO: 人名]
|
||||
|
||||
[TODO: 角色描述,说明这是谁,擅长什么]
|
||||
|
||||
## 核心能力
|
||||
1. **[TODO: 能力1]**:[TODO: 描述]
|
||||
2. **[TODO: 能力2]**:[TODO: 描述]
|
||||
3. **[TODO: 能力3]**:[TODO: 描述]
|
||||
|
||||
## 工作流程
|
||||
1. [TODO: 步骤1]
|
||||
2. [TODO: 步骤2]
|
||||
3. [TODO: 步骤3]
|
||||
|
||||
## 输出规范
|
||||
- [TODO: 规范1]
|
||||
- [TODO: 规范2]
|
||||
|
||||
## 注意事项
|
||||
- [TODO: 约束或边界条件]
|
||||
"""
|
||||
|
||||
TEAM_LEAD_MD_TEMPLATE = """---
|
||||
name: %(team)s-team-lead
|
||||
description: "[TODO: English description]"
|
||||
displayName:
|
||||
en: "[TODO: English display name]"
|
||||
zh: "[TODO: 中文显示名称]"
|
||||
profession:
|
||||
en: "[TODO: English profession title]"
|
||||
zh: "[TODO: 中文职业头衔]"
|
||||
maxTurns: 150
|
||||
---
|
||||
|
||||
# [TODO: 团队名称] - 主理人
|
||||
|
||||
[TODO: 主理人角色描述,负责协调团队完成什么任务]
|
||||
|
||||
## 团队成员
|
||||
|
||||
| 成员 ID | 名字 | 职责 |
|
||||
|---------|------|------|
|
||||
| %(team)s-team-lead | [TODO] | 编排调度 |
|
||||
| [TODO: member-a] | [TODO] | [TODO: 职责] |
|
||||
|
||||
## 标准工作流程(SOP)
|
||||
|
||||
### Phase 1: [TODO: 阶段名]
|
||||
[TODO: 调用哪些成员、输入输出说明]
|
||||
|
||||
### Phase 2: [TODO: 阶段名]
|
||||
[TODO: ...]
|
||||
|
||||
### Phase N: 最终报告
|
||||
综合所有分析结果,生成最终报告返回用户。
|
||||
|
||||
## 团队协作机制(铁律)
|
||||
|
||||
你必须走正式的**团队协作流程**,严禁简化或跳过:
|
||||
|
||||
1. **建立团队**:任务开始时由主理人亲自创建团队(TeamCreate),明确协作边界。**团队创建必须且只能由主理人执行,严禁委派任何成员创建团队**
|
||||
2. **调度成员**:按 SOP 阶段将成员拉入协作、下发独立任务;成员作为独立协作方输出专业产出,不得由主理人代写
|
||||
3. **消息中转**:成员产出回传给主理人,由主理人汇总、转交下一阶段;所有跨成员信息流必须经主理人中转,不得互相直连
|
||||
4. **成员结论为准**:任何专业产出必须由对应成员输出后再采信,主理人只做编排与汇编
|
||||
|
||||
### 严禁行为
|
||||
- ❌ 禁止跳过 TeamCreate,直接自己模拟成员发言或并行写出多角色内容
|
||||
- ❌ 禁止自己代写任何团队成员的专业产出
|
||||
- ❌ 禁止未完成前序阶段就跳到后续阶段
|
||||
- ❌ 禁止让成员互相直连通信,所有跨成员信息流必须经主理人中转
|
||||
- ❌ 禁止 spawn 主理人自己
|
||||
|
||||
## 协作规则
|
||||
1. 所有成员调度必须经过"建立团队 → 调度成员 → 成员回传"流程
|
||||
2. 每阶段结束后,将完整产出原文传递给下一阶段成员
|
||||
3. 每完成一个阶段向用户简要通报
|
||||
4. 所有输出使用与用户原始需求相同的语言
|
||||
5. 调度成员时,Agent 工具的 `name` 参数传入成员的 **Agent ID**(MD 文件名,不含 .md),`subagent_type` 也传入相同值。禁止使用中文名或自创名称
|
||||
"""
|
||||
|
||||
TEAM_MEMBER_MD_TEMPLATE = """---
|
||||
name: [TODO: member-id]
|
||||
description: "[TODO: English description]"
|
||||
displayName:
|
||||
en: "[TODO: English display name]"
|
||||
zh: "[TODO: 中文显示名称]"
|
||||
profession:
|
||||
en: "[TODO: English profession title]"
|
||||
zh: "[TODO: 中文职业头衔]"
|
||||
maxTurns: 50
|
||||
---
|
||||
|
||||
# [TODO: 角色名称] - [TODO: 人名]
|
||||
|
||||
[TODO: 角色描述]
|
||||
|
||||
## 核心能力
|
||||
1. **[TODO: 能力1]**:[TODO: 描述]
|
||||
2. **[TODO: 能力2]**:[TODO: 描述]
|
||||
3. **[TODO: 能力3]**:[TODO: 描述]
|
||||
|
||||
## 工作流程
|
||||
1. [TODO: 步骤1]
|
||||
2. [TODO: 步骤2]
|
||||
3. [TODO: 步骤3]
|
||||
|
||||
## 输出规范
|
||||
- [TODO: 结构化输出模板]
|
||||
|
||||
## SendMessage 回传
|
||||
分析完成后,**必须通过 SendMessage 将完整分析结果回传给主理人**。
|
||||
"""
|
||||
|
||||
README_TEMPLATE = """# %(title)s
|
||||
|
||||
%(description)s
|
||||
|
||||
## 类型
|
||||
|
||||
%(expert_type)s
|
||||
|
||||
## 功能
|
||||
|
||||
[TODO: 详细功能说明]
|
||||
|
||||
## 使用示例
|
||||
|
||||
- [TODO: 示例提示词1]
|
||||
- [TODO: 示例提示词2]
|
||||
- [TODO: 示例提示词3]
|
||||
|
||||
## 头像
|
||||
|
||||
头像已自动生成在 `avatars/` 目录下。如需替换为自定义头像,要求:
|
||||
- 格式:PNG(推荐)或 JPG
|
||||
- 尺寸:512×512 px
|
||||
- 大小:单张不超过 500KB
|
||||
|
||||
## 安装
|
||||
|
||||
将专家包目录放到专家目录下:
|
||||
|
||||
```
|
||||
%(expert_plugins_dir)s/%(name)s/
|
||||
```
|
||||
|
||||
然后运行注册命令使其可见:
|
||||
|
||||
```bash
|
||||
python3 scripts/register_expert.py <expert-dir>
|
||||
```
|
||||
|
||||
## 打包分享
|
||||
|
||||
```bash
|
||||
zip -r %(name)s.zip %(name)s/
|
||||
```
|
||||
"""
|
||||
|
||||
SETTINGS_JSON = '{\n "agent": "%(team)s-team-lead"\n}\n'
|
||||
|
||||
|
||||
def init_agent(expert_dir, name):
|
||||
"""Initialize an Agent-type expert package."""
|
||||
agent_name = name
|
||||
|
||||
# .codebuddy-plugin/plugin.json
|
||||
plugin_dir = expert_dir / '.codebuddy-plugin'
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / 'plugin.json').write_text(
|
||||
AGENT_PLUGIN_JSON % {'name': name, 'agent_name': agent_name},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(" ✅ .codebuddy-plugin/plugin.json")
|
||||
|
||||
# agents/
|
||||
agents_dir = expert_dir / 'agents'
|
||||
agents_dir.mkdir()
|
||||
(agents_dir / f'{agent_name}.md').write_text(
|
||||
AGENT_MD_TEMPLATE % {'agent_name': agent_name},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(f" ✅ agents/{agent_name}.md")
|
||||
|
||||
# avatars/
|
||||
avatars_dir = expert_dir / 'avatars'
|
||||
avatars_dir.mkdir()
|
||||
(avatars_dir / '.gitkeep').touch()
|
||||
print(" ✅ avatars/ (awaiting ImageGen)")
|
||||
|
||||
# README.md
|
||||
(expert_dir / 'README.md').write_text(
|
||||
README_TEMPLATE % {
|
||||
'title': title_case(name),
|
||||
'description': '[TODO: 一句话描述]',
|
||||
'expert_type': 'Agent 型(单个 AI 专家)',
|
||||
'name': name,
|
||||
'expert_plugins_dir': str(get_expert_plugins_dir()),
|
||||
},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(" ✅ README.md")
|
||||
|
||||
|
||||
def init_team(expert_dir, name):
|
||||
"""Initialize a Team-type expert package."""
|
||||
team = name
|
||||
|
||||
# .codebuddy-plugin/plugin.json
|
||||
plugin_dir = expert_dir / '.codebuddy-plugin'
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / 'plugin.json').write_text(
|
||||
TEAM_PLUGIN_JSON % {'name': name, 'team': team},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(" ✅ .codebuddy-plugin/plugin.json")
|
||||
|
||||
# agents/
|
||||
agents_dir = expert_dir / 'agents'
|
||||
agents_dir.mkdir()
|
||||
(agents_dir / f'{team}-team-lead.md').write_text(
|
||||
TEAM_LEAD_MD_TEMPLATE % {'team': team},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(f" ✅ agents/{team}-team-lead.md")
|
||||
|
||||
(agents_dir / 'member-placeholder.md').write_text(TEAM_MEMBER_MD_TEMPLATE, encoding='utf-8')
|
||||
print(" ✅ agents/member-placeholder.md (rename to actual member ID)")
|
||||
|
||||
# avatars/
|
||||
avatars_dir = expert_dir / 'avatars'
|
||||
avatars_dir.mkdir()
|
||||
(avatars_dir / '.gitkeep').touch()
|
||||
print(" ✅ avatars/ (awaiting ImageGen)")
|
||||
|
||||
# settings.json
|
||||
(expert_dir / 'settings.json').write_text(
|
||||
SETTINGS_JSON % {'team': team},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(" ✅ settings.json")
|
||||
|
||||
# README.md
|
||||
(expert_dir / 'README.md').write_text(
|
||||
README_TEMPLATE % {
|
||||
'title': title_case(name),
|
||||
'description': '[TODO: 一句话描述]',
|
||||
'expert_type': 'Team 型(多角色协作团队)',
|
||||
'name': name,
|
||||
'expert_plugins_dir': str(get_expert_plugins_dir()),
|
||||
},
|
||||
encoding='utf-8'
|
||||
)
|
||||
print(" ✅ README.md")
|
||||
|
||||
|
||||
def main():
|
||||
# Parse arguments
|
||||
if len(sys.argv) < 6:
|
||||
print("Usage: init_expert.py <expert-name> --type agent|team --path <output-dir>")
|
||||
print("\nExamples:")
|
||||
print(" python3 init_expert.py my-expert --type agent --path ./output")
|
||||
print(" python3 init_expert.py my-team --type team --path ./output")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
expert_type = None
|
||||
output_path = None
|
||||
|
||||
i = 2
|
||||
while i < len(sys.argv):
|
||||
if sys.argv[i] == '--type' and i + 1 < len(sys.argv):
|
||||
expert_type = sys.argv[i + 1]
|
||||
i += 2
|
||||
elif sys.argv[i] == '--path' and i + 1 < len(sys.argv):
|
||||
output_path = sys.argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if not expert_type or expert_type not in ('agent', 'team'):
|
||||
print("❌ Error: --type must be one of: agent, team")
|
||||
sys.exit(1)
|
||||
|
||||
if not output_path:
|
||||
print("❌ Error: --path is required")
|
||||
sys.exit(1)
|
||||
|
||||
# 硬检查:--path 必须是专家目录
|
||||
expected_path = get_expert_plugins_dir()
|
||||
resolved_path = Path(output_path).expanduser().resolve()
|
||||
if resolved_path != expected_path.resolve():
|
||||
print(f"❌ Error: --path 必须为专家目录: {expected_path}")
|
||||
print(f" 当前传入: {resolved_path}")
|
||||
print(f" 非专家目录下创建的专家无法被检测到。")
|
||||
sys.exit(1)
|
||||
|
||||
# Validate name
|
||||
if len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name):
|
||||
print(f"❌ Error: name '{name}' must be kebab-case (lowercase letters, digits, hyphens)")
|
||||
sys.exit(1)
|
||||
|
||||
# Create expert directory
|
||||
expert_dir = Path(output_path).resolve() / name
|
||||
if expert_dir.exists():
|
||||
print(f"❌ Error: directory already exists: {expert_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
expert_dir.mkdir(parents=True)
|
||||
print(f"🚀 Initializing {expert_type} expert: {name}")
|
||||
print(f" Location: {expert_dir}\n")
|
||||
|
||||
if expert_type == 'agent':
|
||||
init_agent(expert_dir, name)
|
||||
elif expert_type == 'team':
|
||||
init_team(expert_dir, name)
|
||||
|
||||
print(f"\n✅ Expert '{name}' ({expert_type}) initialized at {expert_dir}")
|
||||
print("\nNext steps:")
|
||||
print(" 1. Fill in all [TODO] placeholders in generated files")
|
||||
print(" 2. Generate avatars using ImageGen (see references/avatar-spec.md)")
|
||||
print(" 3. Run validate_expert.py to check the package")
|
||||
print(" 4. Run register_expert.py to register in marketplace.json (makes it visible in WorkBuddy)")
|
||||
print(f"\n Note: The expert will NOT appear in WorkBuddy until registered.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Expert Packager - Validates and packages an expert directory into a zip file.
|
||||
|
||||
Usage:
|
||||
package_expert.py <path/to/expert-dir> [output-dir]
|
||||
|
||||
Example:
|
||||
python3 package_expert.py plugins/my-expert
|
||||
python3 package_expert.py plugins/my-expert ./dist
|
||||
"""
|
||||
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# Import validate_expert from sibling script
|
||||
script_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(script_dir))
|
||||
from validate_expert import validate_expert
|
||||
|
||||
|
||||
def package_expert(expert_path, output_dir=None):
|
||||
"""
|
||||
Validate and package an expert directory into a .zip file.
|
||||
|
||||
Args:
|
||||
expert_path: Path to the expert directory
|
||||
output_dir: Optional output directory (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
Path to the created zip file, or None if error
|
||||
"""
|
||||
expert_path = Path(expert_path).resolve()
|
||||
|
||||
if not expert_path.exists():
|
||||
print(f"❌ Error: Expert directory not found: {expert_path}")
|
||||
return None
|
||||
|
||||
if not expert_path.is_dir():
|
||||
print(f"❌ Error: Path is not a directory: {expert_path}")
|
||||
return None
|
||||
|
||||
# Validate first
|
||||
print("🔍 Validating expert package...\n")
|
||||
result = validate_expert(expert_path)
|
||||
print(result.summary())
|
||||
|
||||
if not result.is_valid:
|
||||
print("\n❌ Packaging aborted. Please fix validation errors first.")
|
||||
return None
|
||||
|
||||
print()
|
||||
|
||||
# Determine output location
|
||||
expert_name = expert_path.name
|
||||
if output_dir:
|
||||
out = Path(output_dir).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
out = Path.cwd()
|
||||
|
||||
zip_filename = out / f"{expert_name}.zip"
|
||||
|
||||
# Create the zip file
|
||||
try:
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in sorted(expert_path.rglob('*')):
|
||||
if file_path.is_file():
|
||||
# Skip hidden files except .codebuddy-plugin
|
||||
rel = file_path.relative_to(expert_path)
|
||||
parts = rel.parts
|
||||
if any(p.startswith('.') and p != '.codebuddy-plugin' for p in parts):
|
||||
continue
|
||||
# Skip common junk files/directories
|
||||
if any(p in ('__pycache__', 'node_modules') for p in parts):
|
||||
continue
|
||||
if file_path.name in ('.gitkeep', '.DS_Store', 'Thumbs.db'):
|
||||
continue
|
||||
|
||||
arcname = str(Path(expert_name) / rel)
|
||||
zipf.write(file_path, arcname)
|
||||
print(f" 📄 {arcname}")
|
||||
file_count += 1
|
||||
|
||||
print(f"\n✅ Packaged {file_count} files to: {zip_filename}")
|
||||
print(f" Size: {zip_filename.stat().st_size / 1024:.1f} KB")
|
||||
return zip_filename
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating zip: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 package_expert.py <path/to/expert-dir> [output-dir]")
|
||||
print("\nExample:")
|
||||
print(" python3 package_expert.py plugins/my-expert")
|
||||
print(" python3 package_expert.py plugins/my-expert ./dist")
|
||||
sys.exit(1)
|
||||
|
||||
expert_path = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
print(f"📦 Packaging expert: {expert_path}")
|
||||
if output_dir:
|
||||
print(f" Output directory: {output_dir}")
|
||||
print()
|
||||
|
||||
result = package_expert(expert_path, output_dir)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Expert Register - Validates and registers an expert into marketplace.json
|
||||
|
||||
This script should be called AFTER the expert package is fully created and validated.
|
||||
Only registered experts will be visible in the WorkBuddy expert center.
|
||||
|
||||
Usage:
|
||||
register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]
|
||||
|
||||
Examples:
|
||||
python3 register_expert.py <expert-dir>
|
||||
python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>
|
||||
|
||||
The creating session id is read from CODEBUDDY_SESSION_ID when present.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
SESSION_ID_ENV_VAR = 'CODEBUDDY_SESSION_ID'
|
||||
UNEXPANDED_SESSION_ID_ARGS = {'$CODEBUDDY_SESSION_ID', '${CODEBUDDY_SESSION_ID}'}
|
||||
|
||||
|
||||
def get_expert_base_dir():
|
||||
"""获取专家 marketplace 根目录,优先读取 WORKBUDDY_CONFIG_DIR 环境变量。"""
|
||||
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip()
|
||||
if not config_dir:
|
||||
config_dir = str(Path.home() / '.workbuddy')
|
||||
return Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts'
|
||||
|
||||
|
||||
def find_plugin_json(expert_dir: Path) -> Optional[Path]:
|
||||
"""Find plugin.json in the expert directory."""
|
||||
for meta_dir in ['.codebuddy-plugin', '.workbuddy-plugin']:
|
||||
candidate = expert_dir / meta_dir / 'plugin.json'
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def validate_expert_completeness(expert_dir: Path) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate that the expert package is complete and ready for registration.
|
||||
Returns (is_valid, list_of_errors).
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# 1. Check plugin.json exists
|
||||
plugin_json_path = find_plugin_json(expert_dir)
|
||||
if not plugin_json_path:
|
||||
errors.append("Missing plugin.json (checked .codebuddy-plugin/ and .workbuddy-plugin/)")
|
||||
return False, errors
|
||||
|
||||
# 2. Parse plugin.json
|
||||
try:
|
||||
with open(plugin_json_path, 'r', encoding='utf-8') as f:
|
||||
plugin_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"plugin.json is not valid JSON: {e}")
|
||||
return False, errors
|
||||
|
||||
# 3. Check required fields are not [TODO]
|
||||
required_fields = ['name', 'description', 'expertType']
|
||||
for field in required_fields:
|
||||
value = plugin_data.get(field, '')
|
||||
if not value or '[TODO' in str(value):
|
||||
errors.append(f"Field '{field}' is missing or contains [TODO] placeholder")
|
||||
|
||||
# 4. Check displayName is filled
|
||||
display_name = plugin_data.get('displayName', {})
|
||||
if isinstance(display_name, dict):
|
||||
zh_name = display_name.get('zh', '')
|
||||
en_name = display_name.get('en', '')
|
||||
if (not zh_name or '[TODO' in zh_name) and (not en_name or '[TODO' in en_name):
|
||||
errors.append("displayName (zh or en) is missing or contains [TODO]")
|
||||
elif not display_name or '[TODO' in str(display_name):
|
||||
errors.append("displayName is missing or contains [TODO]")
|
||||
|
||||
# 5. Check agents directory has at least one non-template .md file
|
||||
agents_dir = expert_dir / 'agents'
|
||||
if agents_dir.exists():
|
||||
md_files = list(agents_dir.glob('*.md'))
|
||||
if not md_files:
|
||||
errors.append("No .md files found in agents/ directory")
|
||||
else:
|
||||
# Check at least one agent file has real content (not all [TODO])
|
||||
has_real_content = False
|
||||
for md_file in md_files:
|
||||
content = md_file.read_text(encoding='utf-8')
|
||||
# Count [TODO] occurrences - if more than 3, likely still a template
|
||||
todo_count = content.count('[TODO')
|
||||
if todo_count <= 2:
|
||||
has_real_content = True
|
||||
break
|
||||
if not has_real_content:
|
||||
errors.append("All agent .md files still contain many [TODO] placeholders")
|
||||
else:
|
||||
errors.append("Missing agents/ directory")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def _write_session_marker(expert_dir: Path, session_id: str) -> None:
|
||||
"""Write .created-by-session marker file into the expert directory."""
|
||||
marker_path = expert_dir / '.created-by-session'
|
||||
try:
|
||||
marker_path.write_text(session_id, encoding='utf-8')
|
||||
except OSError as e:
|
||||
print(f"⚠️ Warning: could not write session marker: {e}")
|
||||
|
||||
|
||||
def normalize_session_id(value: Optional[str]) -> Optional[str]:
|
||||
"""Return a usable session id, ignoring empty or unexpanded shell placeholders."""
|
||||
session_id = (value or '').strip()
|
||||
if not session_id or session_id in UNEXPANDED_SESSION_ID_ARGS:
|
||||
return None
|
||||
return session_id
|
||||
|
||||
|
||||
def get_session_id(explicit_session_id: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the creating session id from CLI arg or Bash runtime env."""
|
||||
return normalize_session_id(explicit_session_id) or normalize_session_id(os.environ.get(SESSION_ID_ENV_VAR))
|
||||
|
||||
|
||||
def get_marketplace_dir(expert_dir: Path, explicit_marketplace_dir: Optional[str]) -> Path:
|
||||
"""Determine the marketplace directory."""
|
||||
if explicit_marketplace_dir:
|
||||
return Path(explicit_marketplace_dir).expanduser().resolve()
|
||||
|
||||
# Infer from expert_dir path: .../marketplaces/my-experts/plugins/<name>
|
||||
# -> marketplace dir is .../marketplaces/my-experts/
|
||||
parts = expert_dir.parts
|
||||
try:
|
||||
plugins_idx = len(parts) - 2 # parent of expert dir should be "plugins"
|
||||
if parts[plugins_idx] == 'plugins':
|
||||
return Path(*parts[:plugins_idx])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# Fallback to default
|
||||
return get_expert_base_dir()
|
||||
|
||||
|
||||
def register_expert(expert_dir: Path, marketplace_dir: Path, session_id: Optional[str] = None) -> bool:
|
||||
"""Register the expert in marketplace.json."""
|
||||
manifest_path = marketplace_dir / '.codebuddy-plugin' / 'marketplace.json'
|
||||
|
||||
# Read existing manifest or create new one
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
else:
|
||||
# Create marketplace directory structure if needed
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"name": "my-experts",
|
||||
"description": "my-experts marketplace (auto-generated)",
|
||||
"plugins": []
|
||||
}
|
||||
|
||||
# Read expert plugin.json for metadata
|
||||
plugin_json_path = find_plugin_json(expert_dir)
|
||||
if not plugin_json_path:
|
||||
print(f"❌ Error: plugin.json not found in {expert_dir}")
|
||||
return False
|
||||
with open(plugin_json_path, 'r', encoding='utf-8') as f:
|
||||
plugin_data = json.load(f)
|
||||
|
||||
expert_name = plugin_data.get('name', expert_dir.name)
|
||||
expert_description = plugin_data.get('description', '')
|
||||
source = f"./plugins/{expert_dir.name}"
|
||||
|
||||
# Check if already registered
|
||||
plugins = manifest.get('plugins', [])
|
||||
for p in plugins:
|
||||
if p.get('source') == source or p.get('name') == expert_name:
|
||||
# Update existing entry
|
||||
p['name'] = expert_name
|
||||
p['source'] = source
|
||||
p['description'] = expert_description
|
||||
if session_id:
|
||||
_write_session_marker(expert_dir, session_id)
|
||||
print(f"✅ Updated existing registration for '{expert_name}' in marketplace.json")
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
|
||||
# Add new entry
|
||||
plugins.append({
|
||||
"name": expert_name,
|
||||
"source": source,
|
||||
"description": expert_description
|
||||
})
|
||||
manifest['plugins'] = plugins
|
||||
|
||||
if session_id:
|
||||
_write_session_marker(expert_dir, session_id)
|
||||
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"✅ Registered '{expert_name}' in marketplace.json")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: register_expert.py <expert-dir> [--marketplace-dir <marketplace-dir>]")
|
||||
print("\nExamples:")
|
||||
print(" python3 register_expert.py <expert-dir>")
|
||||
print(" python3 register_expert.py ./my-expert --marketplace-dir <marketplace-dir>")
|
||||
sys.exit(1)
|
||||
|
||||
expert_dir = Path(sys.argv[1]).expanduser().resolve()
|
||||
marketplace_dir_arg = None
|
||||
session_id_arg = None
|
||||
|
||||
i = 2
|
||||
while i < len(sys.argv):
|
||||
if sys.argv[i] == '--marketplace-dir' and i + 1 < len(sys.argv):
|
||||
marketplace_dir_arg = sys.argv[i + 1]
|
||||
i += 2
|
||||
elif sys.argv[i] == '--session-id' and i + 1 < len(sys.argv):
|
||||
session_id_arg = sys.argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Validate expert directory exists
|
||||
if not expert_dir.exists():
|
||||
print(f"❌ Error: expert directory does not exist: {expert_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
# Validate completeness
|
||||
print(f"🔍 Validating expert package: {expert_dir.name}")
|
||||
is_valid, errors = validate_expert_completeness(expert_dir)
|
||||
|
||||
if not is_valid:
|
||||
print(f"\n❌ Expert package is incomplete. Cannot register.")
|
||||
print(" Issues found:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
print("\n Please fix the above issues and try again.")
|
||||
sys.exit(1)
|
||||
|
||||
print(" ✅ Validation passed")
|
||||
|
||||
# Determine marketplace directory
|
||||
marketplace_dir = get_marketplace_dir(expert_dir, marketplace_dir_arg)
|
||||
print(f"📋 Marketplace: {marketplace_dir}")
|
||||
|
||||
session_id = get_session_id(session_id_arg)
|
||||
|
||||
# Register
|
||||
success = register_expert(expert_dir, marketplace_dir, session_id)
|
||||
if success and session_id:
|
||||
print(f"\n🎉 Expert '{expert_dir.name}' is now registered and visible in WorkBuddy!")
|
||||
elif success:
|
||||
print(f"\n✅ Expert '{expert_dir.name}' is registered in marketplace.json.")
|
||||
print("Warning: no session id found; it may not appear in My Experts for the current user automatically.")
|
||||
else:
|
||||
print(f"\n❌ Registration failed")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Expert Validator - Validates an expert package against the WorkBuddy specification.
|
||||
|
||||
Usage:
|
||||
validate_expert.py <path/to/expert-dir>
|
||||
|
||||
Example:
|
||||
python3 validate_expert.py plugins/my-expert
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VALID_CATEGORY_IDS = {
|
||||
'01-ProductDesign', '02-Engineering', '03-GameSpatial', '04-DataAI',
|
||||
'05-MarketingGrowth', '06-ContentCreative', '07-SalesCommerce',
|
||||
'08-FinanceInvestment', '09-OperationsHR', '10-ProjectQuality',
|
||||
'11-SecurityCompliance', '12-IndustryConsultant',
|
||||
}
|
||||
|
||||
VALID_EXPERT_TYPES = {'agent', 'team'}
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
def error(self, msg):
|
||||
self.errors.append(msg)
|
||||
|
||||
def warn(self, msg):
|
||||
self.warnings.append(msg)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return len(self.errors) == 0
|
||||
|
||||
def summary(self):
|
||||
lines = []
|
||||
if self.errors:
|
||||
lines.append(f"❌ {len(self.errors)} error(s):")
|
||||
for e in self.errors:
|
||||
lines.append(f" • {e}")
|
||||
if self.warnings:
|
||||
lines.append(f"⚠️ {len(self.warnings)} warning(s):")
|
||||
for w in self.warnings:
|
||||
lines.append(f" • {w}")
|
||||
if self.is_valid:
|
||||
lines.append("✅ Expert package is valid!")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def parse_md_frontmatter(md_path):
|
||||
"""Parse YAML frontmatter from a Markdown file. Returns dict or None."""
|
||||
try:
|
||||
content = md_path.read_text(encoding='utf-8')
|
||||
except Exception as e:
|
||||
return None, f"Cannot read {md_path}: {e}"
|
||||
|
||||
if not content.startswith('---'):
|
||||
return None, f"{md_path.name}: No YAML frontmatter found"
|
||||
|
||||
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not match:
|
||||
return None, f"{md_path.name}: Invalid frontmatter format"
|
||||
|
||||
# LIMITATION: Only parses simple top-level "key: value" pairs.
|
||||
# Does NOT handle nested objects, lists, or multi-line values.
|
||||
# Sufficient for extracting 'name', 'description', and detecting 'tools'.
|
||||
fm_text = match.group(1)
|
||||
result = {}
|
||||
|
||||
# Extract simple key-value pairs
|
||||
for line in fm_text.split('\n'):
|
||||
line = line.strip()
|
||||
if ':' in line and not line.startswith('-') and not line.startswith('#'):
|
||||
key, _, value = line.partition(':')
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if value:
|
||||
result[key] = value
|
||||
|
||||
return result, None
|
||||
|
||||
|
||||
def check_i18n_field(obj, field_name, result, context="plugin.json"):
|
||||
"""Check that a field has both 'en' and 'zh' values."""
|
||||
if field_name not in obj:
|
||||
result.error(f"{context}: missing '{field_name}'")
|
||||
return False
|
||||
|
||||
val = obj[field_name]
|
||||
if not isinstance(val, dict):
|
||||
result.error(f"{context}: '{field_name}' must be an object with 'en' and 'zh'")
|
||||
return False
|
||||
|
||||
if not val.get('en'):
|
||||
result.error(f"{context}: '{field_name}.en' is empty")
|
||||
if not val.get('zh'):
|
||||
result.error(f"{context}: '{field_name}.zh' is empty")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_plugin_json(plugin_json, expert_dir, result):
|
||||
"""Validate plugin.json fields."""
|
||||
# Required fields
|
||||
for field in ('name', 'version', 'description'):
|
||||
if field not in plugin_json:
|
||||
result.error(f"plugin.json: missing required field '{field}'")
|
||||
|
||||
name = plugin_json.get('name', '')
|
||||
if name and (len(name) < 2 or not re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', name)):
|
||||
result.error(f"plugin.json: 'name' must be kebab-case (at least 2 chars), got '{name}'")
|
||||
|
||||
expert_type = plugin_json.get('expertType', '')
|
||||
if expert_type not in VALID_EXPERT_TYPES:
|
||||
result.error(f"plugin.json: 'expertType' must be one of {VALID_EXPERT_TYPES}, got '{expert_type}'")
|
||||
return expert_type
|
||||
|
||||
# plugin field consistency
|
||||
plugin_field = plugin_json.get('plugin', '')
|
||||
if plugin_field and plugin_field != name:
|
||||
result.error(f"plugin.json: 'plugin' ({plugin_field}) must equal 'name' ({name})")
|
||||
|
||||
# Agent/Team specific
|
||||
if expert_type in ('agent', 'team'):
|
||||
agent_name = plugin_json.get('agentName', '')
|
||||
if not agent_name:
|
||||
result.error("plugin.json: missing 'agentName'")
|
||||
else:
|
||||
# Check agentName matches an MD file
|
||||
md_file = expert_dir / 'agents' / f'{agent_name}.md'
|
||||
if not md_file.exists():
|
||||
result.error(f"plugin.json: agentName '{agent_name}' has no matching file agents/{agent_name}.md")
|
||||
|
||||
# agentName must not be generic "team-lead"
|
||||
if agent_name == 'team-lead':
|
||||
result.error("plugin.json: agentName must not be generic 'team-lead', add team prefix")
|
||||
|
||||
# Display fields
|
||||
check_i18n_field(plugin_json, 'displayName', result)
|
||||
check_i18n_field(plugin_json, 'profession', result)
|
||||
check_i18n_field(plugin_json, 'displayDescription', result)
|
||||
check_i18n_field(plugin_json, 'defaultInitPrompt', result)
|
||||
|
||||
# displayDescription zh length
|
||||
dd = plugin_json.get('displayDescription', {})
|
||||
zh_desc = dd.get('zh', '')
|
||||
if zh_desc and not zh_desc.startswith('[TODO'):
|
||||
zh_len = len(zh_desc)
|
||||
if zh_len < 40 or zh_len > 50:
|
||||
result.warn(f"plugin.json: displayDescription.zh is {zh_len} chars (recommended 40-50)")
|
||||
|
||||
# Team profession == displayName
|
||||
if expert_type == 'team':
|
||||
dn = plugin_json.get('displayName', {})
|
||||
prof = plugin_json.get('profession', {})
|
||||
if dn.get('zh') and prof.get('zh') and dn['zh'] != prof['zh']:
|
||||
if not dn['zh'].startswith('[TODO') and not prof['zh'].startswith('[TODO'):
|
||||
result.error(f"plugin.json: Team 'profession' must equal 'displayName' (zh: '{prof['zh']}' != '{dn['zh']}')")
|
||||
|
||||
# categoryId
|
||||
cat = plugin_json.get('categoryId', '')
|
||||
if cat and not cat.startswith('[TODO') and cat not in VALID_CATEGORY_IDS:
|
||||
result.error(f"plugin.json: invalid categoryId '{cat}'")
|
||||
|
||||
# tags (must be 3)
|
||||
tags = plugin_json.get('tags', [])
|
||||
if isinstance(tags, list) and len(tags) != 3:
|
||||
result.error(f"plugin.json: must have exactly 3 tags, got {len(tags)}")
|
||||
|
||||
# quickPrompts (must be 3)
|
||||
qp = plugin_json.get('quickPrompts', [])
|
||||
if isinstance(qp, list) and len(qp) != 3:
|
||||
result.error(f"plugin.json: must have exactly 3 quickPrompts, got {len(qp)}")
|
||||
|
||||
# defaultInitPrompt == quickPrompts[0]
|
||||
dip = plugin_json.get('defaultInitPrompt', {})
|
||||
if isinstance(qp, list) and len(qp) > 0 and isinstance(qp[0], dict):
|
||||
if dip.get('zh') and qp[0].get('zh') and dip['zh'] != qp[0]['zh']:
|
||||
if not dip['zh'].startswith('[TODO'):
|
||||
result.warn("plugin.json: defaultInitPrompt.zh should match quickPrompts[0].zh")
|
||||
|
||||
# avatar path check
|
||||
avatar = plugin_json.get('avatar', '')
|
||||
if avatar:
|
||||
avatar_path = expert_dir / avatar
|
||||
if not avatar_path.exists() and not (expert_dir / 'avatars' / '.gitkeep').exists():
|
||||
result.warn(f"plugin.json: avatar file not found: {avatar}")
|
||||
|
||||
# agents array check
|
||||
agents_arr = plugin_json.get('agents', [])
|
||||
if isinstance(agents_arr, list):
|
||||
for agent_path in agents_arr:
|
||||
rel = agent_path[2:] if agent_path.startswith('./') else agent_path
|
||||
resolved = expert_dir / rel
|
||||
if not resolved.exists() and not agent_path.startswith('[TODO'):
|
||||
result.error(f"plugin.json: agents path not found: {agent_path}")
|
||||
|
||||
# skills array check
|
||||
skills_arr = plugin_json.get('skills', [])
|
||||
if isinstance(skills_arr, list):
|
||||
for skill_path in skills_arr:
|
||||
rel = skill_path[2:] if skill_path.startswith('./') else skill_path
|
||||
resolved = expert_dir / rel
|
||||
skill_md = resolved / 'SKILL.md'
|
||||
if not skill_md.exists() and not skill_path.startswith('[TODO'):
|
||||
result.error(f"plugin.json: skill path has no SKILL.md: {skill_path}")
|
||||
|
||||
# Team specific
|
||||
if expert_type == 'team':
|
||||
team_info = plugin_json.get('teamInfo', {})
|
||||
if not team_info:
|
||||
result.error("plugin.json: Team type must have 'teamInfo'")
|
||||
else:
|
||||
lead = team_info.get('leadAgent', '')
|
||||
members_agents = team_info.get('memberAgents', [])
|
||||
if lead and lead in members_agents:
|
||||
result.error("plugin.json: teamInfo.memberAgents should NOT contain the lead agent")
|
||||
|
||||
members = plugin_json.get('members', [])
|
||||
if not members:
|
||||
result.error("plugin.json: Team type must have 'members' array")
|
||||
else:
|
||||
has_lead = any(m.get('role') == 'lead' for m in members if isinstance(m, dict))
|
||||
if not has_lead:
|
||||
result.error("plugin.json: members array must contain at least one member with role='lead'")
|
||||
|
||||
# settings.json
|
||||
settings_path = expert_dir / 'settings.json'
|
||||
if not settings_path.exists():
|
||||
result.error("Team type must have settings.json")
|
||||
else:
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text(encoding='utf-8'))
|
||||
settings_agent = settings.get('agent', '')
|
||||
pj_agent = plugin_json.get('agentName', '')
|
||||
if settings_agent and pj_agent and settings_agent != pj_agent:
|
||||
result.error(f"settings.json 'agent' ({settings_agent}) != plugin.json 'agentName' ({pj_agent})")
|
||||
except json.JSONDecodeError as e:
|
||||
result.error(f"settings.json: invalid JSON: {e}")
|
||||
|
||||
return expert_type
|
||||
|
||||
|
||||
def validate_agent_mds(expert_dir, plugin_json, result):
|
||||
"""Validate all Agent MD files."""
|
||||
agents_dir = expert_dir / 'agents'
|
||||
if not agents_dir.exists():
|
||||
result.error("agents/ directory not found")
|
||||
return
|
||||
|
||||
for md_file in agents_dir.glob('*.md'):
|
||||
fm, err = parse_md_frontmatter(md_file)
|
||||
if err:
|
||||
result.error(err)
|
||||
continue
|
||||
|
||||
if not fm:
|
||||
result.error(f"{md_file.name}: empty frontmatter")
|
||||
continue
|
||||
|
||||
# name must match filename
|
||||
fm_name = fm.get('name', '')
|
||||
expected_name = md_file.stem
|
||||
if fm_name and fm_name != expected_name:
|
||||
result.error(f"{md_file.name}: frontmatter name '{fm_name}' != filename '{expected_name}'")
|
||||
|
||||
# Must have description
|
||||
if not fm.get('description'):
|
||||
result.warn(f"{md_file.name}: missing 'description' in frontmatter")
|
||||
|
||||
# Must NOT have tools field
|
||||
content = md_file.read_text(encoding='utf-8')
|
||||
fm_section = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if fm_section and 'tools:' in fm_section.group(1):
|
||||
result.error(f"{md_file.name}: frontmatter must NOT contain 'tools' field")
|
||||
|
||||
|
||||
def validate_file_structure(expert_dir, expert_type, result):
|
||||
"""Validate basic file structure."""
|
||||
# .codebuddy-plugin/plugin.json must exist
|
||||
pj = expert_dir / '.codebuddy-plugin' / 'plugin.json'
|
||||
if not pj.exists():
|
||||
result.error(".codebuddy-plugin/plugin.json not found")
|
||||
|
||||
# Must NOT have hooks/, commands/, .lsp.json
|
||||
for forbidden in ('hooks', 'commands'):
|
||||
if (expert_dir / forbidden).exists():
|
||||
result.error(f"Forbidden directory found: {forbidden}/")
|
||||
if (expert_dir / '.lsp.json').exists():
|
||||
result.error("Forbidden file found: .lsp.json")
|
||||
|
||||
# agents/, skills/, bin/ must be at root, not inside .codebuddy-plugin/
|
||||
cb_dir = expert_dir / '.codebuddy-plugin'
|
||||
for subdir in ('agents', 'skills', 'bin', 'avatars'):
|
||||
if (cb_dir / subdir).exists():
|
||||
result.error(f"{subdir}/ found inside .codebuddy-plugin/ — must be at plugin root")
|
||||
|
||||
# Must have agents/ dir
|
||||
if not (expert_dir / 'agents').exists():
|
||||
result.error(f"{expert_type} type must have agents/ directory")
|
||||
|
||||
# README.md recommended
|
||||
if not (expert_dir / 'README.md').exists():
|
||||
result.warn("README.md is recommended")
|
||||
|
||||
|
||||
def validate_expert(expert_path):
|
||||
"""Main validation entry point."""
|
||||
expert_dir = Path(expert_path).resolve()
|
||||
result = ValidationResult()
|
||||
|
||||
if not expert_dir.exists():
|
||||
result.error(f"Directory not found: {expert_dir}")
|
||||
return result
|
||||
|
||||
if not expert_dir.is_dir():
|
||||
result.error(f"Not a directory: {expert_dir}")
|
||||
return result
|
||||
|
||||
# Check if expert is in the default install path
|
||||
config_dir = os.environ.get('WORKBUDDY_CONFIG_DIR', '').strip() or str(Path.home() / '.workbuddy')
|
||||
default_plugins_dir = Path(config_dir) / 'plugins' / 'marketplaces' / 'my-experts' / 'plugins'
|
||||
try:
|
||||
expert_dir.relative_to(default_plugins_dir.resolve())
|
||||
except ValueError:
|
||||
result.error(
|
||||
f"专家不在专家目录下,将无法被检测到。"
|
||||
f" 专家目录: {default_plugins_dir}"
|
||||
)
|
||||
|
||||
# Load plugin.json
|
||||
pj_path = expert_dir / '.codebuddy-plugin' / 'plugin.json'
|
||||
if not pj_path.exists():
|
||||
result.error(".codebuddy-plugin/plugin.json not found")
|
||||
return result
|
||||
|
||||
try:
|
||||
plugin_json = json.loads(pj_path.read_text(encoding='utf-8'))
|
||||
except json.JSONDecodeError as e:
|
||||
result.error(f"plugin.json: invalid JSON: {e}")
|
||||
return result
|
||||
|
||||
# Validate plugin.json
|
||||
expert_type = validate_plugin_json(plugin_json, expert_dir, result)
|
||||
|
||||
# Validate file structure
|
||||
validate_file_structure(expert_dir, expert_type, result)
|
||||
|
||||
# Validate Agent MDs
|
||||
validate_agent_mds(expert_dir, plugin_json, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python3 validate_expert.py <path/to/expert-dir>")
|
||||
print("\nExample:")
|
||||
print(" python3 validate_expert.py plugins/my-expert")
|
||||
sys.exit(1)
|
||||
|
||||
expert_path = sys.argv[1]
|
||||
print(f"🔍 Validating expert package: {expert_path}\n")
|
||||
|
||||
result = validate_expert(expert_path)
|
||||
print(result.summary())
|
||||
|
||||
sys.exit(0 if result.is_valid else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,301 @@
|
||||
import { app } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { parseDocument } from 'yaml'
|
||||
import type { ExpertActivation, ExpertBinding, ExpertDocument, ExpertRecord } from '@shared/capabilities'
|
||||
import { expertText } from '@shared/capabilities'
|
||||
import managerSource from './assets/expert-manager-source.json'
|
||||
import { listMcpServers } from './mcp'
|
||||
|
||||
/** Package layout and registration semantics recovered through REA; see the adjacent source manifest. */
|
||||
const META = ['.codebuddy-plugin/plugin.json', '.workbuddy-plugin/plugin.json']
|
||||
const MAX_BYTES = 16 * 1024 * 1024
|
||||
const managerFiles: Record<string, string> = managerSource.files
|
||||
let operations: Promise<unknown> = Promise.resolve()
|
||||
function serialized<T>(run: () => Promise<T>): Promise<T> { const next = operations.then(run); operations = next.catch(() => {}); return next }
|
||||
const digest = (value: string): string => createHash('sha256').update(value).digest('hex')
|
||||
const configRoot = (): string => app.getPath('userData')
|
||||
const marketRoot = (): string => path.join(configRoot(), 'plugins', 'marketplaces', 'my-experts')
|
||||
const packageRoot = (): string => path.join(marketRoot(), 'plugins')
|
||||
const marketFile = (): string => path.join(marketRoot(), '.codebuddy-plugin', 'marketplace.json')
|
||||
function identifier(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$/.test(value) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value)) throw Error('专家标识必须是至少两位的 kebab-case')
|
||||
return value
|
||||
}
|
||||
function relative(value: unknown): string {
|
||||
if (typeof value !== 'string') throw Error('专家资源路径无效')
|
||||
const clean = value.replace(/^\.\//, '')
|
||||
if (!clean || clean.length > 240 || /[\\\x00-\x1f<>:"|?*]/.test(clean) || clean.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw Error('专家资源路径越界或无效')
|
||||
return clean
|
||||
}
|
||||
async function safePath(target: string, create = false): Promise<string> {
|
||||
const resolved = path.resolve(target), base = path.resolve(configRoot())
|
||||
const rel = path.relative(base, resolved)
|
||||
if (path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep)) throw Error('专家路径越界')
|
||||
let current = path.parse(resolved).root
|
||||
for (const part of path.relative(current, resolved).split(path.sep)) {
|
||||
current = path.join(current, part)
|
||||
try { if ((await fs.lstat(current)).isSymbolicLink()) throw Error('专家目录不能包含符号链接') }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; if (create) await fs.mkdir(current); else throw error }
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
async function fileMap(root: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {}; let bytes = 0, count = 0
|
||||
async function visit(dir: string): Promise<void> {
|
||||
const absolute = await safePath(dir)
|
||||
for (const item of await fs.readdir(absolute, { withFileTypes: true })) {
|
||||
if (item.isSymbolicLink()) throw Error('专家包不能包含符号链接')
|
||||
if (['node_modules', '.git', '__pycache__'].includes(item.name)) continue
|
||||
const file = path.join(absolute, item.name)
|
||||
if (item.isDirectory()) { await visit(file); continue }
|
||||
if (!item.isFile()) throw Error('专家包只能包含普通文件')
|
||||
const size = (await fs.stat(file)).size; bytes += size
|
||||
if (++count > 256 || bytes > MAX_BYTES) throw Error('专家包超过文件数或大小限制')
|
||||
const key = path.relative(root, file).replace(/\\/g, '/')
|
||||
if (/\.(?:md|json|txt|yaml|yml|py|js|cjs|mjs|ts|csv|css|html|svg|cs|csproj|slnx|xml|xsd|sh|ps1|LICENSE)$/i.test(key) || ['LICENSE', '.gitignore'].includes(path.basename(key)) || key.startsWith('.created-') || key === '.pending-preview-session') {
|
||||
const data = await fs.readFile(await safePath(file))
|
||||
try { files[key] = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data) } catch { throw Error('专家文档必须是 UTF-8 文本') }
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root); return files
|
||||
}
|
||||
function manifest(files: Record<string, string>): Record<string, any> {
|
||||
const content = META.map(key => files[key]).find(Boolean)
|
||||
if (!content) throw Error('专家包缺少 plugin.json')
|
||||
const data = JSON.parse(content.replace(/^\uFEFF/, ''))
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) throw Error('专家元数据无效')
|
||||
return data
|
||||
}
|
||||
function yamlBody(value: string): { meta: Record<string, any>; body: string } {
|
||||
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(value)
|
||||
if (!match) throw Error('Agent MD 缺少 YAML frontmatter')
|
||||
const doc = parseDocument(match[1], { uniqueKeys: true, schema: 'core' })
|
||||
if (doc.errors.length) throw Error('Agent MD YAML 无效')
|
||||
const meta = doc.toJS({ maxAliasCount: 30 })
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw Error('Agent MD 元数据无效')
|
||||
return { meta, body: value.slice(match[0].length).trim() }
|
||||
}
|
||||
export function validateExpertFiles(files: Record<string, string>, id: string): { errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [], warnings: string[] = []
|
||||
try {
|
||||
const p = manifest(files)
|
||||
for (const key of ['name', 'version', 'description']) if (typeof p[key] !== 'string' || !p[key].trim() || p[key].includes('[TODO')) errors.push(`${key} 不能为空或包含 [TODO]`)
|
||||
if (identifier(p.name) !== id) errors.push('name 必须与专家目录一致')
|
||||
if (!['agent', 'team'].includes(p.expertType)) errors.push('expertType 必须为 agent 或 team')
|
||||
if (p.plugin && p.plugin !== p.name) errors.push('plugin 必须等于 name')
|
||||
if (typeof p.agentName !== 'string' || p.agentName === 'team-lead' || !files[`agents/${p.agentName}.md`]) errors.push('agentName 必须对应真实 Agent MD,且不能是通用 team-lead')
|
||||
for (const key of ['displayName', 'profession', 'displayDescription', 'defaultInitPrompt']) if (!p[key] || typeof p[key] !== 'object' || ['zh', 'en'].some(locale => typeof p[key][locale] !== 'string' || !p[key][locale].trim() || p[key][locale].includes('[TODO'))) errors.push(`${key} 必须包含完整的中英文内容`)
|
||||
for (const key of ['tags', 'quickPrompts']) if (!Array.isArray(p[key]) || p[key].length !== 3) errors.push(`${key} 必须恰好有 3 项`)
|
||||
if (!Array.isArray(p.agents) || !p.agents.length) errors.push('agents 必须至少声明一个 Agent MD')
|
||||
for (const key of ['agents', 'skills']) for (const entry of p[key] ?? []) {
|
||||
const resource = relative(entry) + (key === 'skills' ? '/SKILL.md' : '')
|
||||
if (!files[resource]) errors.push(`资源未找到:${resource}`)
|
||||
}
|
||||
for (const [key, value] of Object.entries(files)) {
|
||||
relative(key)
|
||||
if (/^(?:hooks|commands)(?:\/|$)|(?:^|\/)\.lsp\.json$/.test(key)) errors.push(`不支持的专家资源:${key}`)
|
||||
if (/^\.(?:codebuddy|workbuddy)-plugin\/(?:agents|skills|bin|avatars)\//.test(key)) errors.push('资源必须位于包根目录')
|
||||
if (/^agents\/[^/]+\.md$/.test(key)) {
|
||||
const { meta, body } = yamlBody(value)
|
||||
if (meta.name !== path.basename(key, '.md')) errors.push(`${key} 的 name 必须与文件名一致`)
|
||||
if ('tools' in meta) errors.push(`${key} 禁止声明 tools`)
|
||||
if (!body || body.includes('[TODO')) errors.push(`${key} 正文未完成`)
|
||||
if (!meta.description) warnings.push(`${key} 缺少 description`)
|
||||
}
|
||||
}
|
||||
const n = Array.from(p.displayDescription?.zh ?? '').length
|
||||
if (n < 40 || n > 50) warnings.push('displayDescription.zh 推荐 40–50 字')
|
||||
if (p.defaultInitPrompt?.zh !== p.quickPrompts?.[0]?.zh) warnings.push('defaultInitPrompt 应与第一条 quickPrompt 一致')
|
||||
if (p.avatar) relative(p.avatar)
|
||||
else warnings.push('尚未提供专家头像')
|
||||
if (!files['README.md']) warnings.push('尚未提供 README.md')
|
||||
if (p.expertType === 'team') {
|
||||
if (!p.teamInfo || !Array.isArray(p.members) || !p.members.some((m: any) => m?.role === 'lead')) errors.push('专家团必须声明 teamInfo 和包含主理人的 members')
|
||||
if (p.teamInfo?.memberAgents?.includes(p.teamInfo?.leadAgent)) errors.push('memberAgents 不能包含主理人')
|
||||
if (p.profession?.zh !== p.displayName?.zh) errors.push('专家团 profession 必须等于 displayName')
|
||||
if (!files['settings.json']) errors.push('专家团缺少 settings.json')
|
||||
else { const settings = JSON.parse(files['settings.json']); if (settings.agent && settings.agent !== p.agentName) errors.push('settings.json agent 与 agentName 不一致') }
|
||||
}
|
||||
} catch (error) { errors.push(error instanceof Error ? error.message : String(error)) }
|
||||
return { errors, warnings }
|
||||
}
|
||||
async function record(id: string, files: Record<string, string>, root: string): Promise<ExpertRecord> {
|
||||
const p = manifest(files), validation = validateExpertFiles(files, id)
|
||||
let avatar: string | undefined
|
||||
if (typeof p.avatar === 'string' && !/^[a-z]+:/i.test(p.avatar)) try {
|
||||
const file = await safePath(path.join(root, relative(p.avatar)))
|
||||
if ((await fs.stat(file)).size <= 500 * 1024 && /\.(png|jpe?g|webp)$/i.test(file)) avatar = `data:image/${/\.jpe?g$/i.test(file) ? 'jpeg' : path.extname(file).slice(1)};base64,${(await fs.readFile(file)).toString('base64')}`
|
||||
} catch { /* Missing avatars remain optional; never load arbitrary filesystem URLs. */ }
|
||||
return { id, name: p.name, displayName: p.displayName, profession: p.profession, description: p.description ?? '', displayDescription: p.displayDescription, version: p.version || '1.0.0', expertType: p.expertType || 'skill', agentName: p.agentName, plugin: p.plugin || p.name, pluginRegisteredName: p.name, marketplace: 'my-experts', isCustomExpert: true, expertRootDir: root, avatar, defaultInitPrompt: p.defaultInitPrompt, tags: p.tags, quickPrompts: p.quickPrompts, skills: p.skills, categoryId: p.categoryId, members: p.members, createdBySession: (files['.created-by-session'] || files['.pending-preview-session'])?.trim(), updatedAt: (await fs.stat(await safePath(path.join(root, META.find(key => files[key])!)))).mtimeMs, error: validation.errors.join('\n') || undefined }
|
||||
}
|
||||
async function listExpertsUnlocked(): Promise<ExpertRecord[]> {
|
||||
let catalog: any
|
||||
try { catalog = JSON.parse(await fs.readFile(await safePath(marketFile()), 'utf8')) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
||||
if (!Array.isArray(catalog.plugins)) throw Error('本地专家目录格式无效')
|
||||
const result: ExpertRecord[] = [], seen = new Set<string>()
|
||||
for (const item of catalog.plugins) try {
|
||||
const id = identifier(String(item.source ?? '').replace(/^\.\/plugins\//, ''))
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id); const root = path.join(packageRoot(), id)
|
||||
result.push(await record(id, await fileMap(root), root))
|
||||
} catch { /* Broken entries do not prevent other experts from being discovered. */ }
|
||||
return result
|
||||
}
|
||||
async function readExpertUnlocked(id: string): Promise<ExpertDocument> {
|
||||
identifier(id); const root = path.join(packageRoot(), id), files = await fileMap(root), expert = await record(id, files, root)
|
||||
const prompt = expert.agentName && files[`agents/${expert.agentName}.md`] ? yamlBody(files[`agents/${expert.agentName}.md`]).body : ''
|
||||
return { expert, files, prompt }
|
||||
}
|
||||
export function listExperts(): Promise<ExpertRecord[]> { return serialized(listExpertsUnlocked) }
|
||||
export function readExpert(id: string): Promise<ExpertDocument> { return serialized(() => readExpertUnlocked(id)) }
|
||||
export async function activateExpert(id: string): Promise<ExpertActivation> {
|
||||
const document = await readExpert(id)
|
||||
return { ...await activateDocument(document), defaultInitPrompt: document.expert.defaultInitPrompt }
|
||||
}
|
||||
async function activateDocument(document: ExpertDocument): Promise<ExpertBinding> {
|
||||
const { expert, prompt, files } = document, id = expert.id, p = manifest(files)
|
||||
if (expert.error) throw Error(expert.error)
|
||||
if (expert.expertType === 'team') throw Error('专家团调度尚未接入,暂不能激活')
|
||||
if (!prompt) throw Error('专家缺少可运行的 Agent 指令')
|
||||
if ((p.dependencies?.connectors?.length ?? 0) > 0 || (p.connectorIds?.length ?? 0) > 0) throw Error('此专家依赖云端连接器,当前尚未接入')
|
||||
const declaration = p.dependencies?.mcpServers ?? p.mcpServers ?? (files['.mcp.json'] ? '.mcp.json' : undefined)
|
||||
if (declaration) {
|
||||
const read = (value: any): Record<string, any> => {
|
||||
if (typeof value === 'string') { const content = files[relative(value)]; if (!content) throw Error('专家依赖配置未找到'); const parsed = JSON.parse(content); return read(parsed.mcpServers ?? parsed) }
|
||||
if (Array.isArray(value)) return Object.assign({}, ...value.map(read))
|
||||
if (!value || typeof value !== 'object') throw Error('专家 MCP 依赖声明无效')
|
||||
return value
|
||||
}
|
||||
const declarations = read(declaration), connected = await listMcpServers()
|
||||
for (const name of Object.keys(declarations)) {
|
||||
const server = connected.find(item => item.name === name)
|
||||
if (!server?.enabled || server.status !== 'connected' || !server.trusted) throw Error(`请先在连接器中配置并连接 ${name}`)
|
||||
// A same-name server is not sufficient evidence of matching package dependencies.
|
||||
const required = declarations[name]
|
||||
if (required?.url && required.url !== server.url || required?.command && required.command !== server.command) throw Error(`连接器 ${name} 与专家要求的配置不一致`)
|
||||
if (required?.command) {
|
||||
if (server.transport !== 'stdio' || !Array.isArray(required.args ?? [])) throw Error(`连接器 ${name} 的传输方式或参数无效`)
|
||||
const args = (required.args ?? []).map((arg: unknown) => {
|
||||
if (typeof arg !== 'string') throw Error('MCP 参数必须是字符串')
|
||||
return arg.startsWith('./') ? path.join(expert.expertRootDir, relative(arg)) : arg
|
||||
})
|
||||
if (JSON.stringify(args) !== JSON.stringify(server.args ?? [])) throw Error(`连接器 ${name} 的启动参数与专家要求不一致`)
|
||||
}
|
||||
if (required?.url && server.transport !== 'http' || required?.type && ['stdio', 'http'].includes(required.type) && required.type !== server.transport) throw Error(`连接器 ${name} 的传输方式与专家要求不一致`)
|
||||
if (required?.cwd && path.resolve(required.cwd) !== path.resolve(server.cwd ?? '')) throw Error(`连接器 ${name} 的工作目录与专家要求不一致`)
|
||||
}
|
||||
}
|
||||
return { id, runtimeIdentity: 'expert-v2:' + JSON.stringify({ v: 2, kind: 'expert', expertId: id, pluginRegisteredName: expert.pluginRegisteredName, agentName: expert.agentName, source: 'custom' }), name: expertText(expert.displayName) || expert.name, profession: expertText(expert.profession) || undefined, avatarUrl: expert.avatar, expertType: expert.expertType }
|
||||
}
|
||||
export async function expertTurnContext(id: string): Promise<{ context: string; display: ExpertBinding; resources: Record<string, string> }> {
|
||||
const document = await readExpert(id), display = await activateDocument(document), { expert, files, prompt } = document, p = manifest(files)
|
||||
// REA agent-md-spec: Agent YAML skills are startup preloads. plugin.skills only registers directories.
|
||||
const legalSkillName = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
|
||||
const skillEntries: unknown[] = p.skills ?? []
|
||||
const directory = skillEntries.map((entry: unknown) => {
|
||||
const key = relative(entry) + '/SKILL.md', content = files[key]
|
||||
if (typeof content !== 'string') throw Error(`专家技能资源未找到:${key}`)
|
||||
const { meta } = yamlBody(content)
|
||||
if (!legalSkillName(meta.name)) throw Error(`专家技能名称无效:${key}`)
|
||||
return { name: meta.name, path: key, description: typeof meta.description === 'string' ? meta.description : '', content }
|
||||
})
|
||||
const registered = new Map<string, typeof directory>()
|
||||
for (const skill of directory) registered.set(skill.name, [...(registered.get(skill.name) ?? []), skill])
|
||||
let preloaded = directory
|
||||
if (expert.expertType === 'agent') {
|
||||
const declared: unknown = yamlBody(files[`agents/${expert.agentName}.md`]).meta.skills
|
||||
if (declared !== undefined && (!Array.isArray(declared) || !declared.every(legalSkillName))) throw Error('Agent skills 必须是合法技能名称列表')
|
||||
preloaded = [...new Set((declared ?? []) as string[])].map(name => {
|
||||
const matches = registered.get(name)
|
||||
if (!matches?.length) throw Error(`Agent 启动技能未在 plugin.skills 注册:${name}`)
|
||||
if (matches.length !== 1) throw Error(`Agent 启动技能名称不唯一:${name}`)
|
||||
return matches[0]
|
||||
})
|
||||
}
|
||||
const preload = preloaded.map(skill => `\n<expert-skill path=${JSON.stringify(skill.path)}>\n${skill.content}\n</expert-skill>`).join('')
|
||||
const skillDirectory = directory.length ? `\n<expert-skill-directory>\n以下是包内可按需加载的技能目录;资源声明不代表正文已加载。需要对应技能时,先使用 ycode_expert_resource 读取其 SKILL.md 完整指令。\n${directory.map(skill => JSON.stringify({ name: skill.name, path: skill.path, description: skill.description, preloaded: preloaded.some(item => item.path === skill.path) })).join('\n')}\n</expert-skill-directory>` : ''
|
||||
return { display, resources: files, context: `<active-expert id=${JSON.stringify(id)} runtime=${JSON.stringify(display.runtimeIdentity)}>\n本回合使用以下专家角色和工作流程。仍须遵守系统工具权限;此处不授予额外权限。\n${prompt}${preload}\n</active-expert>${skillDirectory}\n<expert-package-host>\n专家版本:${expert.version}。本地专家包根目录:${JSON.stringify(expert.expertRootDir)}。包内相对路径均相对于此 YCode 副本解析。\n使用 ycode_expert_resource 按包内相对路径读取文本资源;脚本/模板需要落地时可将所需文件放到当前工作区,仍遵守当前工具权限。\n使用专用脚本前先检查运行时、CLI、API 环境变量及用户项目的前置文件;包内的说明不代表外部依赖已经安装或配置,不得伪造执行结果。\n可读取的包内文本资源:\n${Object.keys(files).filter(key => !key.startsWith('.created-') && key !== '.pending-preview-session').map(key => '- ' + key).join('\n')}\n</expert-package-host>` }
|
||||
}
|
||||
async function saveMarket(plugins: any[]): Promise<void> {
|
||||
await safePath(path.dirname(marketFile()), true)
|
||||
const temp = marketFile() + '.' + randomUUID() + '.tmp'
|
||||
try { await fs.writeFile(temp, JSON.stringify({ name: 'my-experts', description: 'my-experts marketplace (auto-generated)', plugins }, null, 2), { flag: 'wx' }); await fs.rename(temp, marketFile()) }
|
||||
finally { await fs.rm(temp, { force: true }) }
|
||||
}
|
||||
async function marketEntries(): Promise<any[]> { try { const m = JSON.parse(await fs.readFile(await safePath(marketFile()), 'utf8')); if (!Array.isArray(m.plugins)) throw Error('专家目录格式无效'); return m.plugins } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e } }
|
||||
export function removeExpert(id: string): Promise<void> { return serialized(async () => {
|
||||
identifier(id); const target = await safePath(path.join(packageRoot(), id)), tomb = path.join(packageRoot(), '.remove-' + randomUUID())
|
||||
const entries = await marketEntries()
|
||||
await fs.rename(target, tomb)
|
||||
try { await saveMarket(entries.filter(p => p.source !== `./plugins/${id}`)) } catch (e) { await fs.rename(tomb, target); throw e }
|
||||
// After the catalog commits, cleanup failure must not resurrect a partially removed package.
|
||||
await fs.rm(tomb, { recursive: true, force: true }).catch(() => console.warn('专家删除已提交,待清理备份:', id))
|
||||
}) }
|
||||
interface Draft { files: Record<string, string>; baseHash: string | null }
|
||||
export function expertPackageOperation(sessionId: string, args: Record<string, unknown>): Promise<unknown> { return serialized(async () => {
|
||||
if (!/^[a-zA-Z0-9-]{1,128}$/.test(sessionId)) throw Error('会话标识无效')
|
||||
const id = identifier(args.id), action = args.action
|
||||
const draftDir = path.join(configRoot(), 'capabilities', 'expert-drafts', sessionId), draftFile = path.join(draftDir, id + '.json')
|
||||
let draft: Draft
|
||||
try { if (action === 'read' && args.refresh === true) throw Object.assign(Error('refresh'), { code: 'ENOENT' }); draft = JSON.parse(await fs.readFile(await safePath(draftFile), 'utf8')) } catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
|
||||
let files: Record<string, string> = {}
|
||||
try { files = (await readExpertUnlocked(id)).files } catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause }
|
||||
draft = { files, baseHash: Object.keys(files).length ? digest(JSON.stringify(files)) : null }
|
||||
}
|
||||
if (action === 'read') {
|
||||
await safePath(draftDir, true)
|
||||
const temp = draftFile + '.' + randomUUID(); await fs.writeFile(temp, JSON.stringify(draft), { flag: 'wx' }); await fs.rename(temp, draftFile)
|
||||
return { id, ...draft }
|
||||
}
|
||||
if (action === 'write') {
|
||||
if (!args.files || typeof args.files !== 'object' || Array.isArray(args.files)) throw Error('files 必须是相对路径到 UTF-8 内容的对象')
|
||||
const next = { ...draft.files }
|
||||
for (const [key, value] of Object.entries(args.files)) {
|
||||
const name = relative(key)
|
||||
if (name === '.created-by-session' || name === '.pending-preview-session') throw Error('会话标记由注册步骤写入')
|
||||
if (value === null) delete next[name]
|
||||
else if (typeof value === 'string' && Buffer.byteLength(value) <= 1024 * 1024) next[name] = value
|
||||
else throw Error('专家文件必须是小于 1MB 的 UTF-8 文本,删除请用 null')
|
||||
}
|
||||
if (Object.keys(next).length > 256 || Buffer.byteLength(JSON.stringify(next)) > MAX_BYTES) throw Error('专家包超过大小或文件数限制')
|
||||
if (draft.baseHash && META.some(key => next[key])) { const previous = manifest(draft.files), updated = manifest(next); if (previous.name !== updated.name || previous.agentName !== updated.agentName) throw Error('修改专家时不能改变 name 或 agentName') }
|
||||
draft.files = next; await safePath(draftDir, true)
|
||||
const temp = draftFile + '.' + randomUUID(); await fs.writeFile(temp, JSON.stringify(draft), { flag: 'wx' }); await fs.rename(temp, draftFile)
|
||||
return { id, files: Object.keys(next), ...validateExpertFiles(next, id), registered: false }
|
||||
}
|
||||
const validation = validateExpertFiles(draft.files, id)
|
||||
if (action === 'validate') return { id, ...validation, valid: !validation.errors.length }
|
||||
if (action !== 'register') throw Error('action 必须是 read、write、validate 或 register')
|
||||
if (validation.errors.length) throw Error(validation.errors.join('\n'))
|
||||
const target = path.join(packageRoot(), id)
|
||||
let current: Record<string, string> | undefined
|
||||
try { current = (await readExpertUnlocked(id)).files } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e }
|
||||
if ((current ? digest(JSON.stringify(current)) : null) !== draft.baseHash) throw Error('专家已被其他会话修改,请重新读取后编辑')
|
||||
await safePath(packageRoot(), true)
|
||||
const stage = path.join(packageRoot(), '.stage-' + randomUUID()), backup = path.join(packageRoot(), '.backup-' + randomUUID())
|
||||
const entries = await marketEntries(); await fs.mkdir(stage); let backedUp = false, published = false
|
||||
try {
|
||||
// Preserve existing binary resources during textual edits.
|
||||
if (current) { await safePath(target); await fs.cp(target, stage, { recursive: true, force: true, dereference: false }); await fileMap(stage) }
|
||||
for (const key of Object.keys(current ?? {})) if (!(key in draft.files)) await fs.rm(path.join(stage, relative(key)), { force: true })
|
||||
for (const [key, content] of Object.entries(draft.files)) { const file = path.join(stage, relative(key)); await safePath(path.dirname(file), true); await fs.writeFile(file, content) }
|
||||
await fs.writeFile(path.join(stage, '.created-by-session'), sessionId)
|
||||
if (current) { await fs.rename(target, backup); backedUp = true }
|
||||
await fs.rename(stage, target); published = true
|
||||
const p = manifest(draft.files), filtered = entries.filter(entry => entry.name !== p.name && entry.source !== `./plugins/${id}`)
|
||||
await saveMarket([...filtered, { name: p.name, source: `./plugins/${id}`, description: p.description }])
|
||||
} catch (error) { if (published) await fs.rm(target, { recursive: true, force: true }); if (backedUp) await fs.rename(backup, target); throw error }
|
||||
finally { await fs.rm(stage, { recursive: true, force: true }) }
|
||||
if (backedUp) await fs.rm(backup, { recursive: true, force: true }).catch(() => console.warn('专家注册已提交,待清理备份:', id))
|
||||
await fs.rm(draftFile, { force: true }).catch(() => console.warn('专家注册已提交,待清理草稿:', id))
|
||||
return { success: true, expert: (await readExpertUnlocked(id)).expert, warnings: validation.warnings }
|
||||
}) }
|
||||
export function expertManagerDocument(): { content: string; files: string[] } { return { content: managerFiles['SKILL.md'], files: Object.keys(managerFiles) } }
|
||||
export function expertManagerResource(resource: string): string { const key = relative(resource), value = Object.hasOwn(managerFiles, key) ? managerFiles[key] : undefined; if (typeof value !== 'string') throw Error('专家管理器资源不存在'); return value }
|
||||
export function expertManagerAdapter(sessionId: string): string {
|
||||
return `\n<ycode-expert-manager-host>\n以上是通过 REA 恢复的 WorkBuddy 原始 expert-manager 技能。当前运行在 YCode,以下是宿主适配:\n1. WORKBUDDY_CONFIG_DIR 对应 YCode 的受控本地配置目录;不要写入 WorkBuddy 目录,不要在工作区伪造注册结果。\n2. 使用 ycode_expert_package 代替 Python 初始化/写文件/校验/注册:先 read(id),再 write(id,files) 增量保存,validate,最后 register。files 为包内相对路径到完整 UTF-8 文本的对象,null 删除文件。格式仍遵守原 references;工具自动管理固定目录、marketplace.json 与 CODEBUDDY_SESSION_ID=${sessionId} 标记。修改前必须 read,保留未修改字段,禁止改变 name/agentName/Agent 文件名。\n3. 用 ycode_read_skill_resource(id=builtin:expert-manager,path=...) 阅读原 references/scripts;Python脚本供规范参考,不在当前宿主执行。\n4. 当前未接入 ImageGen;需要头像时如实说明并请用户提供图片,不要声称已生成。可先不填 avatar 并展示校验警告。专家团包可编辑/校验,团队调度未接入,不能声称可运行。\n5. register 返回 success:true 才算创建完成。校验失败必须修复,不得只输出一段角色描述。\n</ycode-expert-manager-host>`
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, isAbsolute } from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import type { McpServerInput, McpServerView, McpToolView, McpStatus } from '@shared/capabilities'
|
||||
import type { ToolSpec } from '@shared/types'
|
||||
|
||||
interface SecretValues { env: Record<string, string>; headers: Record<string, string> }
|
||||
interface StoredServer extends Omit<McpServerView, 'status' | 'error' | 'trusted'> {
|
||||
secrets?: string
|
||||
trustedHash?: string
|
||||
}
|
||||
interface Registry { version: 1; servers: StoredServer[] }
|
||||
interface Runtime {
|
||||
generation: string
|
||||
configHash: string
|
||||
status: McpStatus
|
||||
error?: string
|
||||
client: Client
|
||||
transport: Transport
|
||||
controller: AbortController
|
||||
secretValues: string[]
|
||||
flight?: Promise<McpServerView>
|
||||
}
|
||||
let registry: Registry | null = null
|
||||
let loading: Promise<Registry> | null = null
|
||||
let writes: Promise<unknown> = Promise.resolve()
|
||||
const runtimes = new Map<string, Runtime>()
|
||||
const MAX_SERVERS = 100, MAX_TOOLS = 10000, CONNECT_TIMEOUT = 20000, CALL_TIMEOUT = 120000
|
||||
const filePath = (): string => join(app.getPath('userData'), 'mcp-servers.json')
|
||||
const clone = <T>(value: T): T => structuredClone(value)
|
||||
const abortError = (): Error => Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })
|
||||
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value) }
|
||||
function sorted(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sorted)
|
||||
return object(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])])) : value
|
||||
}
|
||||
function readSecrets(server: StoredServer): SecretValues {
|
||||
if (!server.secrets) return { env: {}, headers: {} }
|
||||
try {
|
||||
const values = JSON.parse(safeStorage.decryptString(Buffer.from(server.secrets, 'base64'))) as SecretValues
|
||||
if (!object(values.env) || !object(values.headers)) throw Error()
|
||||
return values
|
||||
} catch { throw new Error('无法解密 MCP 凭据,请重新配置') }
|
||||
}
|
||||
function encryptSecrets(values: SecretValues): string | undefined {
|
||||
if (!Object.keys(values.env).length && !Object.keys(values.headers).length) return undefined
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) throw Error()
|
||||
return safeStorage.encryptString(JSON.stringify(values)).toString('base64')
|
||||
} catch { throw new Error('系统加密服务不可用,无法安全保存 MCP 凭据') }
|
||||
}
|
||||
function mergeSecretMap(input: unknown, previous: Record<string, string>, headers: boolean): Record<string, string> {
|
||||
if (input === undefined) return { ...previous }
|
||||
if (!object(input) || Object.keys(input).length > 128) throw new Error('MCP 环境变量或 Headers 格式无效')
|
||||
const result: Record<string, string> = Object.create(null)
|
||||
for (const [rawKey, value] of Object.entries(input)) {
|
||||
const key = headers ? rawKey.toLowerCase() : rawKey
|
||||
if (!key || key.length > 256 || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) || typeof value !== 'string' || value.length > 65536) throw new Error('MCP 环境变量或 Headers 格式无效')
|
||||
if (headers && /[\r\n]/.test(value)) throw new Error('MCP Headers 格式无效')
|
||||
// Empty retained keys keep credentials; omit a key from an explicitly supplied map to remove it.
|
||||
if (value) result[key] = value
|
||||
else if (previous[key] !== undefined) result[key] = previous[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
function normalize(input: McpServerInput, previous?: StoredServer): StoredServer {
|
||||
if (!input || typeof input.name !== 'string' || !input.name.trim() || input.name.length > 128) throw new Error('请输入有效的 MCP 名称')
|
||||
if (input.id && (!previous || previous.id !== input.id)) throw new Error('MCP 服务不存在')
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') throw new Error('不支持的 MCP 连接类型')
|
||||
const before = previous ? readSecrets(previous) : { env: {}, headers: {} }
|
||||
const secrets = { env: mergeSecretMap(input.env, before.env, false), headers: mergeSecretMap(input.headers, before.headers, true) }
|
||||
const config: Omit<McpServerInput, 'id' | 'env' | 'headers' | 'enabled'> = { name: input.name.trim(), transport: input.transport }
|
||||
if (input.transport === 'stdio') {
|
||||
if (typeof input.command !== 'string' || !input.command.trim() || input.command.length > 4096 || /[\r\n\0]/.test(input.command)) throw new Error('请输入有效的 MCP 启动命令')
|
||||
if (input.args !== undefined && (!Array.isArray(input.args) || input.args.length > 256 || input.args.some(arg => typeof arg !== 'string' || arg.length > 65536 || arg.includes('\0')))) throw new Error('MCP 参数格式无效')
|
||||
if (input.cwd && (!isAbsolute(input.cwd) || input.cwd.includes('\0'))) throw new Error('MCP 工作目录必须为绝对路径')
|
||||
config.command = input.command.trim(); config.args = input.args?.slice() || []
|
||||
if (input.cwd) config.cwd = input.cwd
|
||||
} else {
|
||||
let url: URL
|
||||
try { url = new URL(input.url || '') } catch { throw new Error('请输入有效的 MCP HTTP 地址') }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || [...url.searchParams.keys()].some(key => /token|key|secret|password|authorization/i.test(key))) throw new Error('MCP 地址必须为 HTTP(S),凭据请使用 Headers 配置')
|
||||
config.url = url.href
|
||||
}
|
||||
const configHash = createHash('sha256').update(JSON.stringify(sorted({ ...config, ...secrets }))).digest('hex')
|
||||
return { ...config, id: previous?.id || randomUUID(), enabled: input.enabled ?? previous?.enabled ?? false,
|
||||
envKeys: Object.keys(secrets.env).sort(), headerKeys: Object.keys(secrets.headers).sort(), configHash,
|
||||
secrets: encryptSecrets(secrets), trustedHash: previous?.configHash === configHash ? previous.trustedHash : undefined,
|
||||
tools: previous?.configHash === configHash ? clone(previous.tools) : [] }
|
||||
}
|
||||
async function load(): Promise<Registry> {
|
||||
if (registry) return registry
|
||||
if (loading) return loading
|
||||
loading = (async () => {
|
||||
let content: string
|
||||
try { content = await fs.readFile(filePath(), 'utf8') }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return registry = { version: 1, servers: [] }; throw error }
|
||||
let parsed: Registry
|
||||
try { parsed = JSON.parse(content) as Registry } catch { throw new Error('MCP 配置文件损坏,请保留配置文件后检查内容') }
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.servers) || parsed.servers.length > MAX_SERVERS) throw new Error('MCP 配置版本或内容无效')
|
||||
const ids = new Set<string>()
|
||||
const normalized: StoredServer[] = []
|
||||
for (const server of parsed.servers) {
|
||||
if (!server || typeof server.id !== 'string' || ids.has(server.id) || typeof server.name !== 'string' || typeof server.configHash !== 'string'
|
||||
|| !['stdio', 'http'].includes(server.transport) || !Array.isArray(server.tools) || !Array.isArray(server.envKeys) || !Array.isArray(server.headerKeys)) throw new Error('MCP 配置内容无效')
|
||||
ids.add(server.id)
|
||||
// A hand-edited file cannot retain approval for a different command, URL or secret.
|
||||
// Reconstructing the record also excludes unknown credential-bearing fields from IPC views.
|
||||
normalized.push(normalize({ ...server, ...readSecrets(server) }, server))
|
||||
}
|
||||
return registry = { version: 1, servers: normalized }
|
||||
})().finally(() => { loading = null })
|
||||
return loading
|
||||
}
|
||||
async function persist(next: Registry): Promise<void> {
|
||||
const file = filePath(), temporary = `${file}.${randomUUID()}.tmp`
|
||||
await fs.mkdir(join(file, '..'), { recursive: true })
|
||||
try { await fs.writeFile(temporary, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 }); await fs.rename(temporary, file); registry = next }
|
||||
finally { await fs.unlink(temporary).catch(() => undefined) }
|
||||
}
|
||||
function mutate<T>(operation: (current: Registry) => Promise<T>): Promise<T> {
|
||||
const next = writes.then(async () => operation(await load()))
|
||||
writes = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
function requireServer(current: Registry, id: string): StoredServer {
|
||||
const server = current.servers.find(server => server.id === id)
|
||||
if (!server) throw new Error('MCP 服务不存在')
|
||||
return server
|
||||
}
|
||||
function view(server: StoredServer): McpServerView {
|
||||
const { secrets: _secret, trustedHash, ...publicFields } = server
|
||||
const runtime = runtimes.get(server.id)
|
||||
return { ...clone(publicFields), trusted: trustedHash === server.configHash,
|
||||
status: runtime?.status || 'disconnected', ...(runtime?.error ? { error: runtime.error } : {}) }
|
||||
}
|
||||
function publicError(error: unknown, operation: 'connect' | 'call'): string {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (/401|403|unauthoriz|forbidden/i.test(message)) return 'MCP 鉴权失败,请检查凭据与权限'
|
||||
if (/timeout|timed out/i.test(message)) return 'MCP 请求超时,请稍后重试'
|
||||
return operation === 'connect' ? '无法连接 MCP 服务,请检查命令、地址和运行环境' : 'MCP 工具调用失败'
|
||||
}
|
||||
async function closeRuntime(id: string): Promise<void> {
|
||||
const runtime = runtimes.get(id)
|
||||
if (!runtime) return
|
||||
runtimes.delete(id)
|
||||
runtime.controller.abort()
|
||||
// Close aborts all pending requests before best-effort remote session cleanup.
|
||||
if (runtime.transport instanceof StreamableHTTPClientTransport) {
|
||||
await Promise.race([runtime.transport.terminateSession().catch(() => undefined), new Promise(resolve => setTimeout(resolve, 500))])
|
||||
}
|
||||
await runtime.client.close().catch(() => undefined)
|
||||
}
|
||||
export async function listMcpServers(): Promise<McpServerView[]> { await writes; return (await load()).servers.map(view) }
|
||||
export async function saveMcpServer(input: McpServerInput): Promise<McpServerView> {
|
||||
return mutate(async current => {
|
||||
const previous = input.id ? requireServer(current, input.id) : undefined, next = normalize(input, previous)
|
||||
if (!previous && current.servers.length >= MAX_SERVERS) throw new Error('MCP 服务数量已达上限')
|
||||
if (current.servers.some(server => server.id !== next.id && server.name === next.name)) throw new Error('同名 MCP 已存在')
|
||||
await persist({ version: 1, servers: previous ? current.servers.map(server => server.id === next.id ? next : server) : [...current.servers, next] })
|
||||
if (previous && (previous.configHash !== next.configHash || !next.enabled)) await closeRuntime(next.id)
|
||||
return view(next)
|
||||
})
|
||||
}
|
||||
export async function trustMcpServer(id: string, configHash: string): Promise<void> {
|
||||
await mutate(async current => {
|
||||
const server = requireServer(current, id)
|
||||
if (server.configHash !== configHash) throw new Error('MCP 配置已变化,请重新查看并批准')
|
||||
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, trustedHash: configHash } : item) })
|
||||
})
|
||||
}
|
||||
export async function connectMcpServer(id: string): Promise<McpServerView> {
|
||||
await writes
|
||||
const server = requireServer(await load(), id)
|
||||
if (!server.enabled) throw new Error('MCP 服务已禁用')
|
||||
if (server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
|
||||
const existing = runtimes.get(id)
|
||||
if (existing?.status === 'connected' && existing.configHash === server.configHash) return view(server)
|
||||
if (existing?.flight && existing.configHash === server.configHash) return existing.flight
|
||||
if (existing) await closeRuntime(id)
|
||||
await writes
|
||||
const current = requireServer(await load(), id)
|
||||
if (current.configHash !== server.configHash || !current.enabled || current.trustedHash !== current.configHash) throw new Error('MCP 配置已变化,请重试')
|
||||
const values = readSecrets(server), client = new Client({ name: 'YCode', version: '0.1.0' })
|
||||
const transport: Transport = server.transport === 'stdio'
|
||||
? new StdioClientTransport({ command: server.command!, args: server.args, cwd: server.cwd, env: values.env, stderr: 'ignore' })
|
||||
: new StreamableHTTPClientTransport(new URL(server.url!), { requestInit: { headers: values.headers, redirect: 'error' },
|
||||
// Keep credentials on the configured origin. GET streams also use this guarded fetch.
|
||||
fetch: async (input, init) => {
|
||||
const target = new URL(input instanceof Request ? input.url : String(input)), configured = new URL(server.url!)
|
||||
if (target.origin !== configured.origin) throw new Error('MCP origin changed')
|
||||
return fetch(input, { ...init, redirect: 'error' })
|
||||
} })
|
||||
const runtime: Runtime = { generation: randomUUID(), configHash: server.configHash, status: 'connecting', client, transport,
|
||||
controller: new AbortController(), secretValues: [...Object.values(values.env), ...Object.values(values.headers),
|
||||
...Object.values(values.headers).filter(value => /^Bearer\s+/i.test(value)).map(value => value.replace(/^Bearer\s+/i, ''))].filter(Boolean) }
|
||||
runtimes.set(id, runtime)
|
||||
client.onclose = () => { if (runtimes.get(id) === runtime && runtime.status === 'connected') { runtime.status = 'error'; runtime.error = 'MCP 服务连接已断开' } }
|
||||
client.onerror = () => {} // Do not forward upstream payloads/credentials into logs or the renderer.
|
||||
runtime.flight = (async () => {
|
||||
try {
|
||||
await client.connect(transport, { signal: runtime.controller.signal, timeout: CONNECT_TIMEOUT })
|
||||
const tools: McpToolView[] = [], names = new Set<string>(), cursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : undefined, { signal: runtime.controller.signal, timeout: CONNECT_TIMEOUT })
|
||||
for (const tool of page.tools) {
|
||||
if (names.has(tool.name)) continue
|
||||
names.add(tool.name)
|
||||
if (tools.length >= MAX_TOOLS) throw Error('MCP tool budget exceeded')
|
||||
if (runtime.secretValues.some(secret => tool.name.includes(secret))) throw Error('MCP tool name contains a credential')
|
||||
tools.push({ name: tool.name, description: mask(tool.description || '', runtime.secretValues) as string, inputSchema: mask(tool.inputSchema, runtime.secretValues) as Record<string, unknown>,
|
||||
enabled: server.tools.find(previous => previous.name === tool.name)?.enabled !== false })
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
if (cursor && cursors.has(cursor)) throw Error('MCP repeated pagination cursor')
|
||||
if (cursor) cursors.add(cursor)
|
||||
} while (cursor)
|
||||
await mutate(async current => {
|
||||
const latest = requireServer(current, id)
|
||||
if (runtimes.get(id) !== runtime || latest.configHash !== runtime.configHash || !latest.enabled || latest.trustedHash !== latest.configHash) throw abortError()
|
||||
// A concurrent tool toggle wins over an older list result.
|
||||
const merged = tools.map(tool => ({ ...tool, enabled: latest.tools.find(previous => previous.name === tool.name)?.enabled ?? tool.enabled }))
|
||||
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, tools: merged } : item) })
|
||||
runtime.status = 'connected'; runtime.error = undefined
|
||||
})
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined)
|
||||
if (runtimes.get(id) === runtime) { runtime.status = runtime.controller.signal.aborted ? 'disconnected' : 'error'; runtime.error = runtime.controller.signal.aborted ? undefined : publicError(error, 'connect') }
|
||||
} finally { runtime.flight = undefined }
|
||||
const latest = (await load()).servers.find(server => server.id === id)
|
||||
if (!latest) throw new Error('MCP 服务已移除')
|
||||
return view(latest)
|
||||
})()
|
||||
return runtime.flight
|
||||
}
|
||||
export async function disconnectMcpServer(id: string): Promise<void> { await writes; requireServer(await load(), id); await closeRuntime(id) }
|
||||
export async function setMcpEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
await mutate(async current => {
|
||||
const server = requireServer(current, id)
|
||||
if (enabled && server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
|
||||
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, enabled } : item) })
|
||||
if (!enabled) await closeRuntime(id)
|
||||
})
|
||||
if (enabled) await connectMcpServer(id)
|
||||
}
|
||||
export async function setMcpToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void> {
|
||||
await mutate(async current => {
|
||||
const server = requireServer(current, id)
|
||||
if (!server.tools.some(tool => tool.name === toolName)) throw new Error('MCP 工具不存在')
|
||||
await persist({ ...current, servers: current.servers.map(item => item.id === id ? { ...item, tools: item.tools.map(tool => tool.name === toolName ? { ...tool, enabled } : tool) } : item) })
|
||||
})
|
||||
}
|
||||
export async function removeMcpServer(id: string): Promise<void> {
|
||||
await mutate(async current => { requireServer(current, id); await persist({ ...current, servers: current.servers.filter(item => item.id !== id) }); await closeRuntime(id) })
|
||||
}
|
||||
export async function disposeMcpServers(): Promise<void> { await Promise.all([...runtimes.keys()].map(closeRuntime)) }
|
||||
/** Startup reconnect is background work; ordinary sends never await unavailable MCP servers. */
|
||||
export async function initializeMcpServers(): Promise<void> {
|
||||
await writes
|
||||
for (const server of (await load()).servers) if (server.enabled && server.trustedHash === server.configHash) void connectMcpServer(server.id).catch(() => undefined)
|
||||
}
|
||||
function mask(value: unknown, secrets: string[]): unknown {
|
||||
if (typeof value === 'string') { for (const secret of secrets) if (secret) value = (value as string).split(secret).join('[已隐藏]'); return value }
|
||||
if (Array.isArray(value)) return value.map(item => mask(item, secrets))
|
||||
return object(value) ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mask(item, secrets)])) : value
|
||||
}
|
||||
/** Namespace and connection generation are captured once; changed configurations fail closed. */
|
||||
export async function snapshotMcpTools(): Promise<{ specs: ToolSpec[]; describe(name: string): { serverName: string; toolName: string } | undefined; execute(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<{ ok: boolean; content: string; data?: unknown }> }> {
|
||||
await writes
|
||||
await load()
|
||||
const specs: ToolSpec[] = [], mappings = new Map<string, { id: string; serverName: string; tool: string; runtime: Runtime }>()
|
||||
for (const server of (await load()).servers) {
|
||||
const runtime = runtimes.get(server.id)
|
||||
if (!server.enabled || server.trustedHash !== server.configHash || runtime?.status !== 'connected') continue
|
||||
for (const tool of server.tools.filter(tool => tool.enabled)) {
|
||||
const suffix = createHash('sha256').update(server.id + '\0' + tool.name).digest('hex').slice(0, 16)
|
||||
const prefix = tool.name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 24) || 'tool', name = `mcp_${prefix}_${suffix}`
|
||||
specs.push({ type: 'function', function: { name, description: `[${server.name}] ${tool.description}`.slice(0, 8000), parameters: clone(tool.inputSchema) } })
|
||||
mappings.set(name, { id: server.id, serverName: server.name, tool: tool.name, runtime })
|
||||
}
|
||||
}
|
||||
return { specs, describe: name => { const mapping = mappings.get(name); return mapping ? { serverName: mapping.serverName, toolName: mapping.tool } : undefined }, execute: async (name, args, signal) => {
|
||||
if (signal.aborted) throw abortError()
|
||||
const mapping = mappings.get(name)
|
||||
if (!mapping) return { ok: false, content: 'MCP 工具不在本回合快照中' }
|
||||
await writes
|
||||
const server = (await load()).servers.find(server => server.id === mapping.id), runtime = mapping.runtime
|
||||
if (!server || !server.enabled || server.trustedHash !== server.configHash || server.configHash !== runtime.configHash
|
||||
|| runtimes.get(mapping.id) !== runtime || runtime.status !== 'connected' || !server.tools.some(tool => tool.name === mapping.tool && tool.enabled)) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
|
||||
try {
|
||||
const result = await runtime.client.callTool({ name: mapping.tool, arguments: args }, undefined,
|
||||
{ signal: AbortSignal.any([signal, runtime.controller.signal]), timeout: CALL_TIMEOUT })
|
||||
const data = mask(result, runtime.secretValues) as Record<string, unknown>
|
||||
const content = Array.isArray(data.content) ? data.content.map(part => object(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : JSON.stringify(part)).join('\n') : JSON.stringify(data)
|
||||
return { ok: !result.isError, content, data }
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw abortError()
|
||||
if (runtime.controller.signal.aborted) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
|
||||
return { ok: false, content: publicError(error, 'call') }
|
||||
}
|
||||
} }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ToolSpec } from '@shared/types'
|
||||
import type { ConversationToolContext, ConversationToolResult } from '../agent/conversation-tools'
|
||||
import { listSkills, buildSkillContext, loadSkillContext, readSkillResource } from './skills'
|
||||
import { snapshotMcpTools } from './mcp'
|
||||
import { expertManagerDocument, expertManagerAdapter, expertManagerResource, expertPackageOperation, expertTurnContext } from './experts'
|
||||
import { agentMode, agentModeInstructions, modeToolDenial, modeToolSpecs } from '../agent/mode-policy'
|
||||
import type { AgentMode } from '@shared/conversation'
|
||||
|
||||
export interface CapabilityToolSnapshot {
|
||||
specs: ToolSpec[]
|
||||
metadata?(name: string): Record<string, unknown> | undefined
|
||||
execute(name: string, args: Record<string, unknown>, ctx: ConversationToolContext): Promise<ConversationToolResult | undefined>
|
||||
}
|
||||
export interface CapabilitySnapshot extends CapabilityToolSnapshot {
|
||||
context: string
|
||||
references: Array<{ id: string; name: string; uri: string }>
|
||||
assistantDisplay?: import('@shared/capabilities').ExpertBinding
|
||||
}
|
||||
|
||||
/** A turn retains stable capability IDs and MCP connection generations. */
|
||||
export async function createCapabilitySnapshot(ids: string[], cwd: string, options: { sessionId?: string; expertId?: string | null; agentMode?: AgentMode } = {}): Promise<CapabilitySnapshot> {
|
||||
const mode = agentMode(options.agentMode)
|
||||
if (!Array.isArray(ids) || ids.length > 32 || ids.some(id => typeof id !== 'string')) throw new Error('技能引用无效')
|
||||
const selected = [...new Set(ids)]
|
||||
const installed = await listSkills(cwd)
|
||||
const references = selected.map(id => {
|
||||
if (id === 'builtin:expert-manager') return { id, name: 'expert-manager', uri: 'skill://expert-manager' }
|
||||
const skill = installed.find(item => item.id === id)
|
||||
if (!skill?.enabled || !skill.userInvocable) throw new Error('引用的技能已禁用或不可手动调用')
|
||||
return { id, name: skill.name, uri: `skill://${encodeURIComponent(skill.name)}` }
|
||||
})
|
||||
const managerId = 'builtin:expert-manager'
|
||||
let managerLoaded = selected.includes(managerId)
|
||||
const managerContext = (): string => `<skill id="${managerId}" name="expert-manager">\n${expertManagerDocument().content}\n</skill>${expertManagerAdapter(options.sessionId ?? '')}`
|
||||
const skillContext = [await buildSkillContext(selected.filter(id => id !== managerId), cwd), managerLoaded ? managerContext() : ''].filter(Boolean).join('\n\n')
|
||||
const available = installed.filter(item => item.enabled && !item.disableModelInvocation)
|
||||
const allowedIds = new Set([...selected, ...available.map(item => item.id), managerId])
|
||||
const mcp = await snapshotMcpTools()
|
||||
// Snapshot first: a concurrent MCP reconfiguration invalidates these generations,
|
||||
// so dependency checks can never authorize a newly substituted connection.
|
||||
const activeExpert = options.expertId ? await expertTurnContext(options.expertId) : undefined
|
||||
const specs: ToolSpec[] = [...mcp.specs]
|
||||
if (options.sessionId) specs.push({ type: 'function', function: { name: 'ycode_expert_package', description: '创建或编辑专家包:read 读取当前包;write 增量保存包内文件;validate 校验;register 原子注册到我的专家。冲突后使用 read(refresh:true) 重新读取最新包。必须先加载 expert-manager 技能。', parameters: { type: 'object', properties: { id: { type: 'string' }, action: { type: 'string', enum: ['read', 'write', 'validate', 'register'] }, refresh: { type: 'boolean' }, files: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'null' }] } } }, required: ['id', 'action'] } } })
|
||||
if (activeExpert) specs.push({ type: 'function', function: { name: 'ycode_expert_resource', description: '读取当前激活专家包内的 UTF-8 文本资源。', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } })
|
||||
if (allowedIds.size) {
|
||||
specs.push({ type: 'function', function: { name: 'ycode_load_skill', description: '加载可用技能的完整指令。开始对应任务前先加载技能,遵循其流程。', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } })
|
||||
specs.push({ type: 'function', function: { name: 'ycode_read_skill_resource', description: '读取技能包中的文本资源。path 是包内相对路径。', parameters: { type: 'object', properties: { id: { type: 'string' }, path: { type: 'string' } }, required: ['id', 'path'] } } })
|
||||
}
|
||||
const names = new Set(mcp.specs.map(spec => spec.function.name))
|
||||
return {
|
||||
specs: modeToolSpecs(specs, mode),
|
||||
references,
|
||||
assistantDisplay: activeExpert?.display,
|
||||
metadata: name => { const identity = mcp.describe(name); return identity ? { capability: 'mcp', ...identity } : undefined },
|
||||
context: [activeExpert?.context ?? (options.expertId === null ? '<active-expert disabled="true">当前会话已移除专家,后续回复使用默认助手身份,不再沿用之前专家的角色指令。</active-expert>' : ''), skillContext, `可自动调用的技能(先用 ycode_load_skill 加载):\n${[...available.map(item => ({ id: item.id, name: item.name, description: item.description })), { id: managerId, name: 'expert-manager', description: '创建、转换、修改、校验本地专家包。' }].map(item => JSON.stringify(item)).join('\n')}`, agentModeInstructions(mode)].filter(Boolean).join('\n\n'),
|
||||
async execute(name, args, ctx) {
|
||||
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
|
||||
const denial = modeToolDenial(mode, name)
|
||||
if (denial) return denial
|
||||
if (name === 'ycode_expert_package') {
|
||||
if (!managerLoaded || !options.sessionId) return { ok: false, content: '请先加载完整 expert-manager 技能' }
|
||||
try { return { ok: true, content: JSON.stringify(await expertPackageOperation(options.sessionId, args)), metadata: { capability: 'expert-package', action: args.action, expertId: args.id } } } catch (e) { return { ok: false, content: e instanceof Error ? e.message : String(e) } }
|
||||
}
|
||||
if (name === 'ycode_expert_resource' && options.expertId) {
|
||||
const resource = typeof args.path === 'string' ? args.path.replace(/^\.\//, '') : ''
|
||||
const content = activeExpert && Object.hasOwn(activeExpert.resources, resource) ? activeExpert.resources[resource] : undefined
|
||||
return typeof content !== 'string' ? { ok: false, content: '当前专家包中没有此文本资源' } : { ok: true, content }
|
||||
}
|
||||
if (name === 'ycode_load_skill' || name === 'ycode_read_skill_resource') {
|
||||
if (typeof args.id !== 'string' || !allowedIds.has(args.id)) return { ok: false, content: '技能不在当前回合可用范围内' }
|
||||
if (args.id === managerId) {
|
||||
if (name === 'ycode_load_skill') { managerLoaded = true; return { ok: true, content: managerContext() } }
|
||||
return { ok: true, content: expertManagerResource(String(args.path ?? '')) }
|
||||
}
|
||||
const content = name === 'ycode_load_skill' ? await loadSkillContext(args.id, cwd) : await readSkillResource(args.id, String(args.path ?? ''), cwd)
|
||||
return { ok: true, content }
|
||||
}
|
||||
if (!names.has(name)) return undefined
|
||||
if (ctx.permissionMode !== 'full-access') {
|
||||
if (!ctx.interactions) return { ok: false, content: 'MCP 调用需要用户确认' }
|
||||
const identity = mcp.describe(name)
|
||||
const decision = await ctx.interactions.approval(ctx.toolCallId, identity ? `调用 ${identity.serverName} 的 ${identity.toolName} 工具` : `调用连接器工具 ${name}`, [
|
||||
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
|
||||
])
|
||||
if (decision.optionId !== 'allow-once') return { ok: false, content: '用户拒绝了连接器调用' }
|
||||
}
|
||||
const result = await mcp.execute(name, args, ctx.signal)
|
||||
return { ...result, metadata: { capability: 'mcp', ...mcp.describe(name) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { app } from 'electron'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { parseDocument } from 'yaml'
|
||||
import { Unzip, UnzipInflate, zipSync } from 'fflate'
|
||||
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
|
||||
import { expertManagerDocument, expertManagerResource } from './experts'
|
||||
|
||||
const MAX_PACKAGE = 50 * 1024 * 1024, MAX_ARCHIVE = 20 * 1024 * 1024, MAX_FILES = 2000, MAX_DOCUMENT = 1024 * 1024
|
||||
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string }
|
||||
interface Registry { version: 1; entries: Entry[]; disabledWorkspace: string[] }
|
||||
interface PackageFile { name: string; data: Uint8Array }
|
||||
let operations = Promise.resolve()
|
||||
function serialized<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = operations.then(run); operations = next.then(() => {}, () => {}); return next
|
||||
}
|
||||
const hash = (value: string | Uint8Array): string => createHash('sha256').update(value).digest('hex')
|
||||
const rootPath = (): string => path.join(app.getPath('userData'), 'capabilities', 'skills')
|
||||
function within(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target)
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep))
|
||||
}
|
||||
function relativeName(value: string): string {
|
||||
if (!value || value.includes('\\') || value.startsWith('/') || /[\x00-\x1f\x7f<>:"|?*]/u.test(value)) throw Error('技能包包含非法相对路径')
|
||||
const parts = value.replace(/\/$/, '').split('/')
|
||||
if (parts.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw Error('技能包包含不安全的文件路径')
|
||||
return parts.join('/')
|
||||
}
|
||||
/** Check every component before following it; no installed or discovered symlink is trusted. */
|
||||
async function checked(root: string, relative: string): Promise<string> {
|
||||
const target = path.resolve(root, relative)
|
||||
if (!within(root, target)) throw Error('技能资源路径越界')
|
||||
let current = root
|
||||
if ((await fs.lstat(root)).isSymbolicLink()) throw Error('技能目录不能是符号链接')
|
||||
for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, part)
|
||||
if ((await fs.lstat(current)).isSymbolicLink()) throw Error('技能包不能包含符号链接')
|
||||
}
|
||||
const actualRoot = await fs.realpath(root), actual = await fs.realpath(target)
|
||||
if (!within(actualRoot, actual)) throw Error('技能资源路径越界')
|
||||
return actual
|
||||
}
|
||||
async function ensureRoot(): Promise<string> {
|
||||
const root = rootPath()
|
||||
let current = path.parse(path.resolve(root)).root
|
||||
// Validate an ancestor before creating the next component, not after following a symlink.
|
||||
for (const segment of path.relative(current, path.join(root, 'packages')).split(path.sep)) {
|
||||
current = path.join(current, segment)
|
||||
try {
|
||||
const stat = await fs.lstat(current)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能存储目录不能包含符号链接或文件')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
await fs.mkdir(current).catch(async cause => { if ((cause as NodeJS.ErrnoException).code !== 'EEXIST') throw cause })
|
||||
const stat = await fs.lstat(current)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能存储路径被重定向')
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
async function registry(): Promise<Registry> {
|
||||
const root = await ensureRoot()
|
||||
try {
|
||||
const file = await checked(root, 'registry.json'), parsed = JSON.parse(await fs.readFile(file, 'utf8')) as Registry
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.entries) || !Array.isArray(parsed.disabledWorkspace) || parsed.entries.some(entry => !/^user:[a-f0-9]{24}$/.test(entry.id) || !/^[a-f0-9]{24}$/.test(entry.folder) || typeof entry.name !== 'string' || typeof entry.enabled !== 'boolean')) throw Error('技能目录索引格式无效')
|
||||
return parsed
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, entries: [], disabledWorkspace: [] }; throw error }
|
||||
}
|
||||
async function saveRegistry(value: Registry): Promise<void> {
|
||||
const root = await ensureRoot(), temp = path.join(root, '.registry-' + randomUUID())
|
||||
try { await fs.writeFile(temp, JSON.stringify(value, null, 2), { flag: 'wx' }); await fs.rename(temp, path.join(root, 'registry.json')) }
|
||||
finally { await fs.unlink(temp).catch(() => {}) }
|
||||
}
|
||||
function metadata(content: string): { name: string; description: string; version?: string; enabled: boolean; userInvocable: boolean; disableModelInvocation: boolean } {
|
||||
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)
|
||||
if (!match) throw Error('SKILL.md 缺少 YAML 元数据')
|
||||
const document = parseDocument(match[1], { uniqueKeys: true, schema: 'core' })
|
||||
if (document.errors.length) throw Error('SKILL.md YAML 元数据无效')
|
||||
let value: unknown
|
||||
try { value = document.toJS({ maxAliasCount: 50 }) } catch { throw Error('SKILL.md YAML 引用过多') }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('SKILL.md 元数据必须是对象')
|
||||
const data = value as Record<string, unknown>
|
||||
if (typeof data.name !== 'string' || !data.name.trim() || data.name.trim().length > 128) throw Error('SKILL.md 需要有效 name')
|
||||
if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 8192) throw Error('SKILL.md 需要有效 description')
|
||||
for (const key of ['enabled', 'disable', 'user-invocable', 'userInvocable', 'disable-model-invocation', 'disableModelInvocation']) if (data[key] !== undefined && typeof data[key] !== 'boolean') throw Error(`SKILL.md 的 ${key} 必须是布尔值`)
|
||||
return { name: data.name.trim(), description: data.description.trim(), version: typeof data.version === 'string' || typeof data.version === 'number' ? String(data.version) : undefined,
|
||||
enabled: data.enabled !== false && data.disable !== true, userInvocable: (data['user-invocable'] ?? data.userInvocable) !== false,
|
||||
disableModelInvocation: (data['disable-model-invocation'] ?? data.disableModelInvocation) === true }
|
||||
}
|
||||
function text(bytes: Uint8Array): string {
|
||||
if (bytes.includes(0)) throw Error('技能资源不是文本文件')
|
||||
try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw Error('技能文档必须是 UTF-8 文本') }
|
||||
}
|
||||
async function packageFiles(root: string): Promise<PackageFile[]> {
|
||||
const files: PackageFile[] = []; let total = 0
|
||||
await checked(root, '')
|
||||
async function visit(relative: string): Promise<void> {
|
||||
const directory = await checked(root, relative)
|
||||
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const name = relativeName(relative ? relative + '/' + item.name : item.name), file = await checked(root, name)
|
||||
if (item.isDirectory()) { await visit(name); continue }
|
||||
if (!item.isFile()) throw Error('技能包只能包含普通文件和文件夹')
|
||||
const stat = await fs.stat(file)
|
||||
total += stat.size
|
||||
if (total > MAX_PACKAGE || files.length >= MAX_FILES) throw Error('技能包超过大小或文件数量限制')
|
||||
const data = await fs.readFile(file)
|
||||
files.push({ name, data })
|
||||
}
|
||||
}
|
||||
await visit(''); return files
|
||||
}
|
||||
function packageDigest(files: PackageFile[]): string {
|
||||
const digest = createHash('sha256')
|
||||
for (const file of [...files].sort((a, b) => a.name.localeCompare(b.name))) digest.update(file.name).update('\0').update(hash(file.data)).update('\n')
|
||||
return digest.digest('hex')
|
||||
}
|
||||
/** Inspect ZIP central-directory sizes/types/paths before decompressing any entry. */
|
||||
function archiveFiles(bytes: Buffer): PackageFile[] {
|
||||
if (bytes.length > MAX_ARCHIVE) throw Error('技能 ZIP 文件超过 20MB')
|
||||
let end = -1
|
||||
for (let position = bytes.length - 22; position >= Math.max(0, bytes.length - 65557); position--) if (bytes.readUInt32LE(position) === 0x06054b50 && position + 22 + bytes.readUInt16LE(position + 20) === bytes.length) { end = position; break }
|
||||
if (end < 0 || bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0) throw Error('技能 ZIP 格式不受支持')
|
||||
const count = bytes.readUInt16LE(end + 10), start = bytes.readUInt32LE(end + 16), size = bytes.readUInt32LE(end + 12)
|
||||
if (!count || count > MAX_FILES || start + size > end || count !== bytes.readUInt16LE(end + 8)) throw Error('技能 ZIP 目录无效')
|
||||
let cursor = start, total = 0; const names = new Set<string>(), declared = new Map<string, number>()
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) throw Error('技能 ZIP 条目无效')
|
||||
const flags = bytes.readUInt16LE(cursor + 8), originalSize = bytes.readUInt32LE(cursor + 24), nameLength = bytes.readUInt16LE(cursor + 28), extra = bytes.readUInt16LE(cursor + 30), comment = bytes.readUInt16LE(cursor + 32)
|
||||
if (cursor + 46 + nameLength + extra + comment > start + size || flags & 1 || originalSize === 0xffffffff) throw Error('不支持加密或 ZIP64 技能包')
|
||||
const raw = text(bytes.subarray(cursor + 46, cursor + 46 + nameLength)), name = relativeName(raw), mode = bytes.readUInt32LE(cursor + 38) >>> 16, type = mode & 0xf000
|
||||
if (type !== 0 && type !== 0x8000 && type !== 0x4000) throw Error('技能 ZIP 不能包含符号链接或设备')
|
||||
const key = name.normalize('NFKC').toLowerCase()
|
||||
if (names.has(key)) throw Error('技能 ZIP 包含重复路径')
|
||||
names.add(key); declared.set(raw, originalSize); total += originalSize
|
||||
if (total > MAX_PACKAGE) throw Error('技能 ZIP 解压后超过 50MB')
|
||||
cursor += 46 + nameLength + extra + comment
|
||||
}
|
||||
if (cursor !== start + size) throw Error('技能 ZIP 目录不完整')
|
||||
const files: PackageFile[] = [], seen = new Set<string>(); let actualTotal = 0, completed = 0
|
||||
const stream = new Unzip(entry => {
|
||||
const expected = declared.get(entry.name), name = relativeName(entry.name)
|
||||
if (expected === undefined || seen.has(entry.name)) throw Error('技能 ZIP 本地目录不匹配')
|
||||
seen.add(entry.name)
|
||||
const chunks: Uint8Array[] = []; let length = 0
|
||||
entry.ondata = (error, chunk, final) => {
|
||||
if (error) throw error
|
||||
length += chunk.length; actualTotal += chunk.length
|
||||
if (length > expected || actualTotal > MAX_PACKAGE) throw Error('技能 ZIP 实际解压大小超过声明或限制')
|
||||
chunks.push(chunk)
|
||||
if (final) {
|
||||
if (length !== expected) throw Error('技能 ZIP 实际解压大小与声明不符')
|
||||
completed++
|
||||
if (!entry.name.endsWith('/')) {
|
||||
const data = new Uint8Array(length); let position = 0
|
||||
for (const part of chunks) { data.set(part, position); position += part.length }
|
||||
files.push({ name, data })
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.start()
|
||||
})
|
||||
stream.register(UnzipInflate)
|
||||
// Bound each inflation step as well as cumulative output; distrust claimed expanded sizes.
|
||||
try { for (let offset = 0; offset < bytes.length; offset += 4096) stream.push(bytes.subarray(offset, offset + 4096), offset + 4096 >= bytes.length) }
|
||||
catch (error) { if (error instanceof Error && error.message.startsWith('技能 ZIP')) throw error; throw Error('技能 ZIP 解压失败') }
|
||||
if (completed !== declared.size) throw Error('技能 ZIP 文件不完整')
|
||||
// Accept a direct package or one enclosing directory; never pick an arbitrary nested skill.
|
||||
if (files.some(file => file.name === 'SKILL.md')) return files
|
||||
const first = files[0]?.name.split('/')[0]
|
||||
if (first && files.every(file => file.name.startsWith(first + '/')) && files.some(file => file.name === first + '/SKILL.md')) return files.map(file => ({ ...file, name: file.name.slice(first.length + 1) }))
|
||||
throw Error('技能 ZIP 需要根目录 SKILL.md 或单个技能文件夹')
|
||||
}
|
||||
async function fromPath(input: string): Promise<PackageFile[]> {
|
||||
const source = path.resolve(input), stat = await fs.lstat(source)
|
||||
if (stat.isSymbolicLink()) throw Error('不能从符号链接导入技能')
|
||||
// Check source ancestors, including a selected SKILL.md's parent.
|
||||
await checked(path.parse(source).root, path.relative(path.parse(source).root, source))
|
||||
if (stat.isDirectory()) return packageFiles(source)
|
||||
if (!stat.isFile()) throw Error('请选择技能文件夹或 ZIP 文件')
|
||||
if (path.basename(source) === 'SKILL.md') return packageFiles(path.dirname(source))
|
||||
if (path.extname(source).toLowerCase() !== '.zip') throw Error('请选择技能文件夹或 ZIP 文件')
|
||||
if (stat.size > MAX_ARCHIVE) throw Error('技能 ZIP 文件超过 20MB')
|
||||
return archiveFiles(await fs.readFile(source))
|
||||
}
|
||||
function record(id: string, folder: string, meta: ReturnType<typeof metadata>, scope: SkillRecord['scope'], enabled: boolean, source: SkillRecord['source'], userModified: boolean): SkillRecord {
|
||||
return { id, ...meta, path: folder, scope, enabled: enabled && meta.enabled, source, userModified }
|
||||
}
|
||||
function invalidRecord(id: string, name: string, folder: string, scope: SkillRecord['scope'], error: unknown): SkillRecord {
|
||||
const code = (error as NodeJS.ErrnoException)?.code
|
||||
const message = code === 'ENOENT' ? '技能包或 SKILL.md 已被移除' : error instanceof Error ? error.message : '技能包无效'
|
||||
return { id, name, description: '', path: folder, scope, source: scope === 'user' ? 'imported' : 'discovered', enabled: false, userInvocable: false, disableModelInvocation: true, userModified: true, error: message }
|
||||
}
|
||||
async function documentAt(folder: string): Promise<string> {
|
||||
const file = await checked(folder, 'SKILL.md'), stat = await fs.stat(file)
|
||||
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('SKILL.md 文件无效或超过 1MB')
|
||||
return text(await fs.readFile(file))
|
||||
}
|
||||
async function listUnlocked(cwd?: string): Promise<SkillRecord[]> {
|
||||
const state = await registry(), managed = path.join(rootPath(), 'packages'), result: SkillRecord[] = []
|
||||
for (const entry of state.entries) {
|
||||
const folder = path.join(managed, entry.folder)
|
||||
try {
|
||||
await checked(managed, entry.folder)
|
||||
const meta = metadata(await documentAt(folder)), files = await packageFiles(folder)
|
||||
result.push(record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest))
|
||||
} catch (error) { result.push(invalidRecord(entry.id, entry.name, folder, 'user', error)) }
|
||||
}
|
||||
if (cwd) {
|
||||
const workspace = await fs.realpath(path.resolve(cwd)).catch(() => undefined)
|
||||
if (!workspace) return result
|
||||
for (const source of ['.agents/skills', '.claude/skills']) {
|
||||
let directory: string
|
||||
try { directory = await checked(workspace, source) } catch { continue }
|
||||
if (!(await fs.stat(directory)).isDirectory()) continue
|
||||
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (!item.isDirectory() || item.isSymbolicLink()) continue
|
||||
const folder = path.join(directory, item.name), id = 'workspace:' + hash(folder).slice(0, 24)
|
||||
try {
|
||||
await checked(directory, item.name)
|
||||
const meta = metadata(await documentAt(folder))
|
||||
await packageFiles(folder)
|
||||
result.push(record(id, folder, meta, 'workspace', !state.disabledWorkspace.includes(id), 'discovered', false))
|
||||
} catch (error) { result.push(invalidRecord(id, item.name, folder, 'workspace', error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.scope === b.scope ? a.name.localeCompare(b.name) : a.scope === 'workspace' ? -1 : 1)
|
||||
}
|
||||
export function listSkills(cwd?: string): Promise<SkillRecord[]> { return serialized(() => listUnlocked(cwd)) }
|
||||
async function findSkill(id: string, cwd?: string): Promise<SkillRecord> {
|
||||
const found = (await listUnlocked(cwd)).find(skill => skill.id === id)
|
||||
if (!found) throw Error('技能不存在或不属于当前空间')
|
||||
if (found.error) throw Error(found.error)
|
||||
return found
|
||||
}
|
||||
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
|
||||
return serialized(async () => {
|
||||
const files = await fromPath(source), main = files.find(file => file.name === 'SKILL.md')
|
||||
if (!main || main.data.length > MAX_DOCUMENT) throw Error('技能根目录需要有效 SKILL.md')
|
||||
const meta = metadata(text(main.data)), name = meta.name.normalize('NFKC').toLowerCase(), folder = hash(name).slice(0, 24), id = 'user:' + folder
|
||||
const state = await registry(), existing = state.entries.find(entry => entry.id === id)
|
||||
if (existing && !overwrite) throw Error('已存在同名技能,确认替换后重试')
|
||||
const packages = path.join(rootPath(), 'packages'), destination = path.join(packages, folder), stage = path.join(packages, '.stage-' + randomUUID()), backup = path.join(packages, '.backup-' + randomUUID())
|
||||
let moved = false, committed = false
|
||||
await fs.mkdir(stage)
|
||||
try {
|
||||
for (const file of files) { const target = path.join(stage, relativeName(file.name)); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, file.data, { flag: 'wx' }) }
|
||||
if (existing) {
|
||||
try { await checked(packages, folder); await fs.rename(destination, backup); moved = true }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||
}
|
||||
await fs.rename(stage, destination)
|
||||
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files) }
|
||||
state.entries = [...state.entries.filter(item => item.id !== id), entry]
|
||||
try { await saveRegistry(state); committed = true } catch (error) { await fs.rm(destination, { recursive: true, force: true }); throw error }
|
||||
return record(id, destination, meta, 'user', entry.enabled, 'imported', false)
|
||||
} finally {
|
||||
await fs.rm(stage, { recursive: true, force: true })
|
||||
if (moved && !committed) await fs.rename(backup, destination)
|
||||
if (committed) await fs.rm(backup, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
|
||||
if (id === 'builtin:expert-manager') { const document = expertManagerDocument(); return Promise.resolve({ skill: { id, name: 'expert-manager', description: '创建和修改本地专家包', path: 'builtin://expert-manager', scope: 'user', enabled: true, userInvocable: true, disableModelInvocation: false, source: 'discovered', userModified: false }, ...document }) }
|
||||
return serialized(async () => { const skill = await findSkill(id, cwd); return { skill, content: await documentAt(skill.path), files: (await packageFiles(skill.path)).map(file => file.name).sort() } })
|
||||
}
|
||||
export function setSkillEnabled(id: string, enabled: boolean, cwd?: string): Promise<void> {
|
||||
return serialized(async () => {
|
||||
const skill = await findSkill(id, cwd), state = await registry()
|
||||
if (skill.scope === 'workspace') state.disabledWorkspace = [...state.disabledWorkspace.filter(item => item !== id), ...enabled ? [] : [id]]
|
||||
else state.entries = state.entries.map(entry => entry.id === id ? { ...entry, enabled } : entry)
|
||||
await saveRegistry(state)
|
||||
})
|
||||
}
|
||||
export function removeSkill(id: string): Promise<void> {
|
||||
return serialized(async () => {
|
||||
const state = await registry(), entry = state.entries.find(item => item.id === id)
|
||||
if (!entry) throw Error('只能卸载受管技能副本,空间技能请在其源目录管理')
|
||||
const packages = path.join(rootPath(), 'packages'), target = path.join(packages, entry.folder), tomb = path.join(packages, '.remove-' + randomUUID())
|
||||
await checked(packages, '')
|
||||
let moved = false, symlink = false
|
||||
try { symlink = (await fs.lstat(target)).isSymbolicLink(); await fs.rename(target, tomb); moved = true }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||
try { await saveRegistry({ ...state, entries: state.entries.filter(item => item.id !== id) }) } catch (error) { if (moved) await fs.rename(tomb, target); throw error }
|
||||
if (moved) { if (symlink) await fs.unlink(tomb); else await fs.rm(tomb, { recursive: true, force: true }) }
|
||||
})
|
||||
}
|
||||
export function exportSkill(id: string, destination: string): Promise<string> {
|
||||
return serialized(async () => {
|
||||
const skill = await findSkill(id), files = await packageFiles(skill.path), target = path.resolve(destination)
|
||||
if (!target.toLowerCase().endsWith('.zip')) throw Error('技能导出目标必须是 ZIP 文件')
|
||||
const parent = path.dirname(target)
|
||||
await checked(path.parse(parent).root, path.relative(path.parse(parent).root, parent))
|
||||
if (within(rootPath(), target) || within(skill.path, target)) throw Error('不能导出覆盖技能存储目录')
|
||||
const bytes = zipSync(Object.fromEntries(files.map(file => [file.name, file.data])))
|
||||
// Export never overwrites an existing file or follows a destination symlink.
|
||||
await fs.writeFile(target, bytes, { flag: 'wx' }); return target
|
||||
})
|
||||
}
|
||||
function context(skill: SkillRecord, markdown: string, files: string[]): string {
|
||||
if (markdown.length > 65536) throw Error('技能文档过长,请通过技能资源分段读取')
|
||||
return `\n<skill id=${JSON.stringify(skill.id)} name=${JSON.stringify(skill.name)}>\n${markdown}\n\nAvailable package resources (relative paths):\n${files.map(file => '- ' + file).join('\n')}\n</skill>\n`
|
||||
}
|
||||
export function buildSkillContext(ids: string[], cwd: string): Promise<string> {
|
||||
return serialized(async () => {
|
||||
let result = ''
|
||||
for (const id of new Set(ids)) {
|
||||
const skill = await findSkill(id, cwd)
|
||||
if (!skill.enabled || !skill.userInvocable) throw Error('该技能已禁用或不允许手动调用')
|
||||
result += context(skill, await documentAt(skill.path), (await packageFiles(skill.path)).map(file => file.name))
|
||||
if (result.length > 131072) throw Error('所选技能上下文过长')
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
export function buildAvailableSkills(cwd: string): Promise<string> {
|
||||
return serialized(async () => JSON.stringify((await listUnlocked(cwd)).filter(skill => skill.enabled && !skill.disableModelInvocation).map(skill => ({ id: skill.id, name: skill.name, description: skill.description }))))
|
||||
}
|
||||
export function loadSkillContext(id: string, cwd: string): Promise<string> {
|
||||
return serialized(async () => {
|
||||
const skill = await findSkill(id, cwd)
|
||||
if (!skill.enabled || skill.disableModelInvocation) throw Error('该技能不允许模型自动调用')
|
||||
return context(skill, await documentAt(skill.path), (await packageFiles(skill.path)).map(file => file.name))
|
||||
})
|
||||
}
|
||||
export function readSkillResource(id: string, resource: string, cwd: string): Promise<string> {
|
||||
if (id === 'builtin:expert-manager') return Promise.resolve(expertManagerResource(resource))
|
||||
return serialized(async () => {
|
||||
const skill = await findSkill(id, cwd)
|
||||
if (!skill.enabled) throw Error('该技能已禁用')
|
||||
const file = await checked(skill.path, relativeName(resource)), stat = await fs.stat(file)
|
||||
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('技能资源无效或过大')
|
||||
return text(await fs.readFile(file))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user