# Skill 系统迁移实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 Neta skill 系统从文档占位状态迁移为完整可用的 prompt-based skill 系统,对齐 OpenClaw + Hermes 架构,支持 GitHub 安装、Node/Python 依赖、渐进式披露、条件激活、前后端完整实现。 **Architecture:** 后端三层服务(SkillLoader 加载/解析 + SkillInstaller 安装/更新 + SkillRegistry 注册/追踪),两个内置工具(read_skill + skill_manage),Controller 改为 @CoolController 混合模式。前端改用 Cool Admin service 代理,重写 skills.vue 和 agent-edit.vue Skill Tab。 **Tech Stack:** Midway.js + TypeORM + js-yaml + child_process(git/npm/uv) | Vue 3 + Element Plus + Cool Admin service 代理 + Socket.IO **Spec:** `docs/superpowers/specs/2026-04-13-skill-system-migration-design.md` --- ## File Structure ### 后端 — 新增文件 - `packages/backend/src/modules/netaclaw/service/skill_installer.ts` — GitHub clone + 依赖安装 - `packages/backend/src/modules/netaclaw/service/skill_registry.ts` — lockfile + 指纹 + 来源追踪 - `packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts` — Agent 按需读取 SKILL.md - `packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts` — Agent 自主 CRUD skill - `packages/backend/src/modules/netaclaw/service/skill_context.ts` — buildSkillContext 公共函数 ### 后端 — 改造文件 - `packages/backend/src/modules/netaclaw/entity/skill.ts` — 移除 icon/category/config,新增 6 字段 - `packages/backend/src/modules/netaclaw/service/skill_loader.ts` — 完全重写 - `packages/backend/src/modules/netaclaw/controller/skill.ts` — @CoolController + 自定义接口 - `packages/backend/src/modules/netaclaw/gateway/server.ts` — 统一 skill 加载逻辑 - `packages/backend/src/modules/netaclaw/controller/chat.ts` — 统一 skill 加载逻辑 - `packages/backend/package.json` — 新增 js-yaml 依赖 ### 前端 — 删除文件 - `packages/frontend/src/modules/agent/components/skill-config.vue` - `packages/frontend/src/modules/agent/components/skill-prompts.vue` - `packages/frontend/src/modules/agent/components/skill-model.vue` ### 前端 — 改造文件 - `packages/frontend/src/modules/agent/views/skills.vue` — 重写 - `packages/frontend/src/modules/agent/components/skill-detail.vue` — 重写为新详情抽屉 - `packages/frontend/src/modules/agent/views/agent-edit.vue` — Skill Tab 改造 ### 数据 — 清理 - 数据库 `netaclaw_skill` 表清空老数据 - `skills/` 目录清空老 skill(保留目录) --- ## Phase 1: 清理与基础 ### Task 1: 清理老 skill 数据和依赖准备 **Files:** - Modify: `packages/backend/package.json` - Clean: `skills/` 目录 - Clean: 数据库 `netaclaw_skill` 表 - [ ] **Step 1: 安装 js-yaml 依赖** ```bash cd packages/backend && pnpm add js-yaml && pnpm add -D @types/js-yaml ``` - [ ] **Step 2: 清空 skills 目录下的老 skill** ```bash cd packages/backend rm -rf skills/hello-world # 保留 skills/ 目录本身 mkdir -p skills ``` - [ ] **Step 3: 清空数据库老数据** ```sql TRUNCATE TABLE netaclaw_skill; ``` - [ ] **Step 4: Commit** ```bash git add packages/backend/package.json packages/backend/pnpm-lock.yaml git commit -m "chore: add js-yaml dep, clean old skill data" ``` --- ### Task 2: 改造 Skill Entity **Files:** - Modify: `packages/backend/src/modules/netaclaw/entity/skill.ts` - [ ] **Step 1: 重写 skill.ts entity** 将 `packages/backend/src/modules/netaclaw/entity/skill.ts` 完整替换为: ```typescript import { BaseEntity } from '../../base/entity/base.js'; import { Column, Entity, Index } from 'typeorm'; /** * NetaClaw Skill — prompt-based skill 元数据与状态管理 */ @Entity('netaclaw_skill') export class NetaClawSkillEntity extends BaseEntity { @Column({ comment: 'Skill名称', length: 100, unique: true }) name: string; @Column({ comment: '显示名称', length: 200 }) label: string; @Column({ comment: '描述', type: 'text', nullable: true }) description: string; @Column({ comment: 'Skill类型: compute/llm/multimodal', length: 20, nullable: true }) skillType: string; @Column({ type: 'json', comment: '标签', nullable: true }) tags: string[]; @Index() @Column({ comment: '状态: 0=禁用 1=启用', default: 1 }) status: number; @Column({ comment: '版本号', length: 20, nullable: true }) version: string; @Column({ comment: '来源: local/github', length: 20, nullable: true }) source: string; @Column({ comment: 'GitHub URL', length: 500, nullable: true }) sourceUrl: string; @Column({ type: 'json', comment: '安装规格 (OpenClaw SkillInstallSpec 兼容)', nullable: true }) installSpec: Record[]; @Column({ type: 'json', comment: '完整 frontmatter 元数据', nullable: true }) metadata: Record; @Column({ comment: '安装时间', type: 'datetime', nullable: true }) installedAt: Date; @Column({ comment: 'SHA256 内容指纹', length: 64, nullable: true }) fingerprint: string; } ``` - [ ] **Step 2: 验证 entity 注册** 确认 `packages/backend/src/entities.ts` 中已有 `NetaClawSkillEntity` 的 import 和注册。无需改动。 - [ ] **Step 3: 启动后端验证表结构自动同步** ```bash cd packages/backend && pnpm dev ``` 检查日志无报错,数据库 `netaclaw_skill` 表字段已更新(老字段 icon/category/config 被移除,新字段 source/sourceUrl/installSpec/metadata/installedAt/fingerprint 已添加)。 - [ ] **Step 4: Commit** ```bash git add packages/backend/src/modules/netaclaw/entity/skill.ts git commit -m "refactor: update skill entity - remove old fields, add source/metadata/fingerprint" ``` --- ## Phase 2: 后端服务层 ### Task 3: 重写 SkillLoaderService **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts` - [ ] **Step 1: 完整重写 skill_loader.ts** 将 `packages/backend/src/modules/netaclaw/service/skill_loader.ts` 完整替换为: ```typescript import { Provide, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import { NetaClawSkillEntity } from '../entity/skill.js'; export interface SkillMeta { name: string; description: string; version?: string; metadata?: Record; content: string; } export interface SkillEntry { meta: SkillMeta; dbRecord?: NetaClawSkillEntity; } @Provide() @Scope(ScopeEnum.Singleton) export class SkillLoaderService { @Logger() logger: ILogger; private skills: Map = new Map(); private skillsDir: string; @InjectEntityModel(NetaClawSkillEntity) skillRepo: Repository; @Init() async init() { this.skillsDir = path.resolve(process.cwd(), 'skills'); await this.scanSkills(); } private static readonly MAX_SKILLS = 200; private static readonly MAX_SKILL_FILE_BYTES = 256 * 1024; // 256KB private static readonly MAX_SKILLS_PROMPT_CHARS = 30_000; /** 扫描 skills/ 目录,解析所有 SKILL.md */ async scanSkills(): Promise { this.skills.clear(); try { const entries = await fs.readdir(this.skillsDir, { withFileTypes: true }); let loaded = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; if (loaded >= SkillLoaderService.MAX_SKILLS) { this.logger.warn('[SkillLoader] 已达 %d 个 Skill 上限,跳过剩余', SkillLoaderService.MAX_SKILLS); break; } const skillMdPath = path.join(this.skillsDir, entry.name, 'SKILL.md'); try { const raw = await fs.readFile(skillMdPath, 'utf-8'); if (Buffer.byteLength(raw) > SkillLoaderService.MAX_SKILL_FILE_BYTES) { this.logger.warn('[SkillLoader] SKILL.md 超过 256KB 限制: %s', entry.name); continue; } const skill = this.parseSkillMd(raw); if (skill) { this.skills.set(skill.name, skill); loaded++; this.logger.info('[SkillLoader] 已加载 Skill: %s', skill.name); } } catch { // SKILL.md 不存在或解析失败,跳过 } } this.logger.info('[SkillLoader] 共加载 %d 个 Skill', this.skills.size); } catch { this.logger.warn('[SkillLoader] Skills 目录不存在: %s', this.skillsDir); } } /** 解析 SKILL.md — 完整 YAML frontmatter 解析 */ parseSkillMd(raw: string): SkillMeta | null { const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!fmMatch) return null; try { const frontmatter = yaml.load(fmMatch[1]) as Record; const content = fmMatch[2].trim(); const name = frontmatter.name as string; if (!name) return null; return { name, description: (frontmatter.description as string) ?? '', version: (frontmatter.version as string) ?? undefined, metadata: (frontmatter.metadata as Record) ?? undefined, content, }; } catch (e) { this.logger.warn('[SkillLoader] YAML 解析失败: %s', e); return null; } } getSkill(name: string): SkillMeta | undefined { return this.skills.get(name); } getAllSkills(): SkillMeta[] { return Array.from(this.skills.values()); } /** 按需获取完整 SKILL.md 内容(渐进式披露) */ getSkillContent(name: string): string | null { const skill = this.skills.get(name); return skill ? skill.content : null; } /** 条件过滤:根据 agent 可用工具过滤 skill */ filterByConditions(skillNames: string[], availableTools: string[]): SkillMeta[] { const result: SkillMeta[] = []; for (const name of skillNames) { const skill = this.skills.get(name); if (!skill) continue; const conditions = (skill.metadata as any)?.conditions; if (conditions) { // requires_tools: 必须全部可用 if (conditions.requires_tools) { const required = conditions.requires_tools as string[]; if (!required.every(t => availableTools.includes(t))) continue; } // fallback_for_tools: 当指定工具不可用时才显示 if (conditions.fallback_for_tools) { const fallbackFor = conditions.fallback_for_tools as string[]; if (fallbackFor.every(t => availableTools.includes(t))) continue; } } result.push(skill); } return result; } /** 构建 XML 索引(渐进式披露:只含 name + description) */ buildSkillsPrompt(skillNames: string[], availableTools: string[]): string { const filtered = this.filterByConditions(skillNames, availableTools); if (filtered.length === 0) return ''; const lines: string[] = []; let totalChars = 0; for (const s of filtered) { const line = ` \n ${s.name}\n ${s.description}\n `; if (totalChars + line.length > SkillLoaderService.MAX_SKILLS_PROMPT_CHARS) { this.logger.warn('[SkillLoader] Skill 索引超过 %d 字符限制,截断', SkillLoaderService.MAX_SKILLS_PROMPT_CHARS); break; } lines.push(line); totalChars += line.length; } if (lines.length === 0) return ''; const skillsXml = lines.join('\n'); return `\n\n\n${skillsXml}\n\n\n当你需要使用某个 skill 时,调用 read_skill 工具读取完整指令后再执行。`; } /** 获取所有 Skill 元数据(文件 + 数据库合并) */ async getSkillMetas(): Promise { const fileSkills = this.getAllSkills(); const dbSkills = await this.skillRepo.find(); const dbMap = new Map(dbSkills.map(s => [s.name, s])); const result = []; for (const fs of fileSkills) { let dbRecord = dbMap.get(fs.name); if (!dbRecord) { dbRecord = await this.skillRepo.save({ name: fs.name, label: fs.name, description: fs.description, skillType: (fs.metadata as any)?.skillType ?? null, tags: (fs.metadata as any)?.tags ?? null, version: fs.version ?? null, source: 'local', metadata: fs.metadata ?? null, status: 1, } as any); } result.push({ name: fs.name, label: dbRecord.label || fs.name, description: fs.description || dbRecord.description, skillType: (fs.metadata as any)?.skillType ?? dbRecord.skillType, tags: (fs.metadata as any)?.tags ?? dbRecord.tags, emoji: (fs.metadata as any)?.emoji ?? null, status: dbRecord.status, version: fs.version ?? dbRecord.version, source: dbRecord.source ?? 'local', sourceUrl: dbRecord.sourceUrl, installedAt: dbRecord.installedAt, metadata: fs.metadata, }); } return result; } /** 设置 Skill 启用/禁用状态 */ async setSkillStatus(name: string, status: number): Promise { const existing = await this.skillRepo.findOneBy({ name }); if (existing) { await this.skillRepo.update({ name }, { status }); } else { await this.skillRepo.save({ name, label: name, status } as any); } } } ``` - [ ] **Step 2: 验证编译通过** ```bash cd packages/backend && npx tsc --noEmit ``` Expected: 无错误。 - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_loader.ts git commit -m "refactor: rewrite skill_loader - yaml parsing, progressive disclosure, condition filtering" ``` --- ### Task 4: 创建 SkillRegistryService **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_registry.ts` - [ ] **Step 1: 创建 skill_registry.ts** ```typescript import { Provide, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; export interface SkillHubLockfile { version: 1; skills: Record; } export interface SkillOrigin { version: 1; source: string; url: string; branch?: string; commitHash: string; installedAt: string; } @Provide() @Scope(ScopeEnum.Singleton) export class SkillRegistryService { @Logger() logger: ILogger; private hubDir: string; private originsDir: string; private lockfilePath: string; @Init() async init() { this.hubDir = path.resolve(process.cwd(), '.skillhub'); this.originsDir = path.join(this.hubDir, 'origins'); this.lockfilePath = path.join(this.hubDir, 'lock.json'); await fs.mkdir(this.originsDir, { recursive: true }); } async readLockfile(): Promise { try { const raw = await fs.readFile(this.lockfilePath, 'utf-8'); return JSON.parse(raw); } catch { return { version: 1, skills: {} }; } } async writeLockfile(lockfile: SkillHubLockfile): Promise { await fs.writeFile(this.lockfilePath, JSON.stringify(lockfile, null, 2), 'utf-8'); } async writeOrigin(name: string, origin: SkillOrigin): Promise { const filePath = path.join(this.originsDir, `${name}.json`); await fs.writeFile(filePath, JSON.stringify(origin, null, 2), 'utf-8'); } async readOrigin(name: string): Promise { try { const filePath = path.join(this.originsDir, `${name}.json`); const raw = await fs.readFile(filePath, 'utf-8'); return JSON.parse(raw); } catch { return null; } } async removeOrigin(name: string): Promise { try { await fs.unlink(path.join(this.originsDir, `${name}.json`)); } catch { /* ignore */ } } /** 计算 skill 目录的 SHA256 指纹(基于 SKILL.md 内容) */ async computeFingerprint(skillDir: string): Promise { const skillMdPath = path.join(skillDir, 'SKILL.md'); const content = await fs.readFile(skillMdPath, 'utf-8'); return crypto.createHash('sha256').update(content).digest('hex'); } } ``` - [ ] **Step 2: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_registry.ts git commit -m "feat: add SkillRegistryService - lockfile, fingerprint, origin tracking" ``` --- ### Task 5: 创建 SkillInstallerService **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_installer.ts` - [ ] **Step 1: 创建 skill_installer.ts** ```typescript import { Provide, Inject, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs/promises'; import * as path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { NetaClawSkillEntity } from '../entity/skill.js'; import { SkillLoaderService, SkillMeta } from './skill_loader.js'; import { SkillRegistryService, SkillOrigin } from './skill_registry.js'; const execAsync = promisify(exec); // 安全校验正则(对齐 OpenClaw) const SAFE_GITHUB_URL = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\.git)?$/; const SAFE_NODE_PACKAGE = /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[a-z0-9^~>=<.*|-]+)?$/; const SAFE_UV_PACKAGE = /^[a-z0-9][a-z0-9._-]*(\[[a-z0-9,._-]+\])?(([><=!~]=?|===?)[a-z0-9.*_-]+)?$/i; @Provide() @Scope(ScopeEnum.Singleton) export class SkillInstallerService { @Logger() logger: ILogger; @Inject() skillLoader: SkillLoaderService; @Inject() registry: SkillRegistryService; @InjectEntityModel(NetaClawSkillEntity) skillRepo: Repository; private skillsDir: string; @Init() async init() { this.skillsDir = path.resolve(process.cwd(), 'skills'); } /** 从 GitHub 安装 skill */ async installFromGitHub( url: string, options?: { branch?: string; tag?: string }, onProgress?: (step: string, percent: number) => void, ): Promise { // 1. 校验 URL const cleanUrl = url.replace(/\/$/, ''); if (!SAFE_GITHUB_URL.test(cleanUrl)) { throw new Error('无效的 GitHub URL,仅支持 github.com 仓库'); } onProgress?.('正在克隆仓库...', 10); // 2. Clone 到临时目录 const tmpDir = path.join(this.skillsDir, `.tmp-${Date.now()}`); const ref = options?.tag || options?.branch || 'main'; try { await execAsync(`git clone --depth 1 --branch ${ref} ${cleanUrl} "${tmpDir}"`, { timeout: 60000 }); } catch (e: any) { // 如果 branch 失败,尝试不指定 branch try { await execAsync(`git clone --depth 1 ${cleanUrl} "${tmpDir}"`, { timeout: 60000 }); } catch (e2: any) { throw new Error(`Git clone 失败: ${e2.message}`); } } onProgress?.('正在验证 SKILL.md...', 30); // 3. 验证 SKILL.md 存在 const skillMdPath = path.join(tmpDir, 'SKILL.md'); try { await fs.access(skillMdPath); } catch { await fs.rm(tmpDir, { recursive: true, force: true }); throw new Error('仓库中未找到 SKILL.md 文件'); } // 4. 解析 SKILL.md const raw = await fs.readFile(skillMdPath, 'utf-8'); const skillMeta = this.skillLoader.parseSkillMd(raw); if (!skillMeta) { await fs.rm(tmpDir, { recursive: true, force: true }); throw new Error('SKILL.md 解析失败:缺少 name 字段'); } onProgress?.('正在安装...', 50); // 5. 路径安全检查 + 移动到 skills/{name}/ const targetDir = path.join(this.skillsDir, skillMeta.name); const realSkillsDir = await fs.realpath(this.skillsDir); const realTarget = path.resolve(realSkillsDir, skillMeta.name); if (!realTarget.startsWith(realSkillsDir)) { await fs.rm(tmpDir, { recursive: true, force: true }); throw new Error('路径安全检查失败'); } // 移除旧版本(如果存在) await fs.rm(targetDir, { recursive: true, force: true }); await fs.rename(tmpDir, targetDir); // 删除 .git 目录 await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true }); onProgress?.('正在获取 commit hash...', 60); // 6. 获取 commit hash let commitHash = ''; try { const { stdout } = await execAsync(`git ls-remote ${cleanUrl} HEAD`, { timeout: 15000 }); commitHash = stdout.split('\t')[0] || ''; } catch { /* ignore */ } // 7. 计算指纹 + 写入 origin + 更新 lockfile const fingerprint = await this.registry.computeFingerprint(targetDir); const origin: SkillOrigin = { version: 1, source: 'github', url: cleanUrl, branch: ref, commitHash, installedAt: new Date().toISOString(), }; await this.registry.writeOrigin(skillMeta.name, origin); const lockfile = await this.registry.readLockfile(); lockfile.skills[skillMeta.name] = { version: skillMeta.version || '0.0.0', fingerprint, installedAt: Date.now(), }; await this.registry.writeLockfile(lockfile); onProgress?.('正在同步数据库...', 80); // 8. 同步到数据库 const existing = await this.skillRepo.findOneBy({ name: skillMeta.name }); const entityData: Partial = { name: skillMeta.name, label: skillMeta.name, description: skillMeta.description, skillType: (skillMeta.metadata as any)?.skillType ?? null, tags: (skillMeta.metadata as any)?.tags ?? null, version: skillMeta.version ?? null, source: 'github', sourceUrl: cleanUrl, installSpec: (skillMeta.metadata as any)?.install ?? null, metadata: skillMeta.metadata ?? null, installedAt: new Date(), fingerprint, status: 1, }; if (existing) { await this.skillRepo.update({ name: skillMeta.name }, entityData); } else { await this.skillRepo.save(entityData as any); } // 9. 重新扫描 await this.skillLoader.scanSkills(); onProgress?.('安装完成', 100); // 10. 安装依赖(如果有) const installSpecs = (skillMeta.metadata as any)?.install; if (installSpecs && Array.isArray(installSpecs)) { await this.installDependencies(skillMeta.name, installSpecs); } return skillMeta; } /** 安装 skill 声明的依赖 */ async installDependencies( skillName: string, installSpecs?: Record[], ): Promise<{ success: boolean; logs: string[] }> { const logs: string[] = []; if (!installSpecs) { const skill = this.skillLoader.getSkill(skillName); installSpecs = (skill?.metadata as any)?.install; } if (!installSpecs || !Array.isArray(installSpecs)) { return { success: true, logs: ['无依赖需要安装'] }; } for (const spec of installSpecs) { const kind = spec.kind as string; const pkg = spec.package as string; try { if (kind === 'node' && pkg) { if (!SAFE_NODE_PACKAGE.test(pkg)) { logs.push(`[node] 包名校验失败: ${pkg}`); continue; } const { stdout } = await execAsync(`pnpm add ${pkg}`, { cwd: path.resolve(process.cwd()), timeout: 120000, }); logs.push(`[node] 已安装 ${pkg}: ${stdout.trim()}`); } else if (kind === 'uv' && pkg) { if (!SAFE_UV_PACKAGE.test(pkg)) { logs.push(`[uv] 包名校验失败: ${pkg}`); continue; } const { stdout } = await execAsync(`uv tool install ${pkg}`, { timeout: 120000 }); logs.push(`[uv] 已安装 ${pkg}: ${stdout.trim()}`); } else { logs.push(`[${kind}] 不支持的安装类型或缺少 package 字段`); } } catch (e: any) { logs.push(`[${kind}] 安装 ${pkg} 失败: ${e.message}`); } } return { success: logs.every(l => !l.includes('失败')), logs }; } /** 卸载 skill */ async uninstall(name: string): Promise { const targetDir = path.join(this.skillsDir, name); await fs.rm(targetDir, { recursive: true, force: true }); await this.registry.removeOrigin(name); const lockfile = await this.registry.readLockfile(); delete lockfile.skills[name]; await this.registry.writeLockfile(lockfile); await this.skillRepo.delete({ name }); await this.skillLoader.scanSkills(); } /** 更新 skill(重新拉取) */ async update( name: string, onProgress?: (step: string, percent: number) => void, ): Promise { const origin = await this.registry.readOrigin(name); if (!origin || origin.source !== 'github') { throw new Error(`Skill "${name}" 不是 GitHub 安装的,无法更新`); } return this.installFromGitHub(origin.url, { branch: origin.branch }, onProgress); } /** 检查所有 GitHub skill 是否有更新 */ async checkUpdates(): Promise> { const results: Array<{ name: string; hasUpdate: boolean; currentHash: string; latestHash: string }> = []; const dbSkills = await this.skillRepo.find({ where: { source: 'github' } }); for (const skill of dbSkills) { const origin = await this.registry.readOrigin(skill.name); if (!origin) continue; try { const { stdout } = await execAsync(`git ls-remote ${origin.url} HEAD`, { timeout: 15000 }); const latestHash = stdout.split('\t')[0] || ''; results.push({ name: skill.name, hasUpdate: latestHash !== origin.commitHash, currentHash: origin.commitHash, latestHash, }); } catch { results.push({ name: skill.name, hasUpdate: false, currentHash: origin.commitHash, latestHash: '' }); } } return results; } } ``` - [ ] **Step 2: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_installer.ts git commit -m "feat: add SkillInstallerService - GitHub install, deps, update, uninstall" ``` --- ### Task 6: 创建内置工具 read_skill + skill_manage **Files:** - Create: `packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts` - Create: `packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts` - [ ] **Step 1: 创建 read_skill.ts** ```typescript import { Type } from '@sinclair/typebox'; import { AgentToolWithMeta, textResult } from '../common.js'; import { SkillLoaderService } from '../../service/skill_loader.js'; const ReadSkillParams = Type.Object({ name: Type.String({ description: '要读取的 skill 名称' }), }); export function createReadSkillTool(skillLoader: SkillLoaderService): AgentToolWithMeta { return { name: 'read_skill', label: '读取 Skill', description: '读取指定 skill 的完整 SKILL.md 内容。在 索引中看到感兴趣的 skill 后,调用此工具获取完整指令。', parameters: ReadSkillParams, async execute(_id, params) { const content = skillLoader.getSkillContent(params.name); if (!content) { return textResult(`未找到名为 "${params.name}" 的 skill`); } return textResult(content); }, }; } ``` - [ ] **Step 2: 创建 skill_manage.ts** ```typescript import { Type } from '@sinclair/typebox'; import * as fs from 'fs/promises'; import * as path from 'path'; import { AgentToolWithMeta, textResult } from '../common.js'; import { SkillLoaderService } from '../../service/skill_loader.js'; const SkillManageParams = Type.Object({ action: Type.Union([ Type.Literal('create'), Type.Literal('edit'), Type.Literal('delete'), ], { description: '操作类型' }), name: Type.String({ description: 'skill 名称' }), content: Type.Optional(Type.String({ description: 'SKILL.md 完整内容(create/edit 时必填)' })), }); export function createSkillManageTool(skillLoader: SkillLoaderService): AgentToolWithMeta { const skillsDir = path.resolve(process.cwd(), 'skills'); return { name: 'skill_manage', label: '管理 Skill', description: '创建、编辑或删除 skill。创建/编辑时需提供完整的 SKILL.md 内容(含 YAML frontmatter)。', parameters: SkillManageParams, async execute(_id, params) { // 路径安全检查 const targetDir = path.join(skillsDir, params.name); const realSkillsDir = await fs.realpath(skillsDir).catch(() => skillsDir); const realTarget = path.resolve(realSkillsDir, params.name); if (!realTarget.startsWith(realSkillsDir)) { return textResult('错误: 路径安全检查失败'); } if (params.action === 'create' || params.action === 'edit') { if (!params.content) { return textResult('错误: create/edit 操作需要提供 content'); } // 验证 content 是合法的 SKILL.md const parsed = skillLoader.parseSkillMd(params.content); if (!parsed) { return textResult('错误: content 不是合法的 SKILL.md 格式(需要 YAML frontmatter 含 name 字段)'); } await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(path.join(targetDir, 'SKILL.md'), params.content, 'utf-8'); await skillLoader.scanSkills(); return textResult(`Skill "${params.name}" 已${params.action === 'create' ? '创建' : '更新'}`); } if (params.action === 'delete') { await fs.rm(targetDir, { recursive: true, force: true }); await skillLoader.scanSkills(); return textResult(`Skill "${params.name}" 已删除`); } return textResult('错误: 未知操作'); }, }; } ``` - [ ] **Step 3: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` - [ ] **Step 4: Commit** ```bash git add packages/backend/src/modules/netaclaw/tools/builtin/read_skill.ts packages/backend/src/modules/netaclaw/tools/builtin/skill_manage.ts git commit -m "feat: add read_skill and skill_manage builtin tools" ``` --- ### Task 7: 创建 buildSkillContext 公共函数 **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_context.ts` - [ ] **Step 1: 创建 skill_context.ts** ```typescript import { SkillLoaderService } from './skill_loader.js'; import { AnyAgentTool } from '../tools/common.js'; import { createReadSkillTool } from '../tools/builtin/read_skill.js'; import { createSkillManageTool } from '../tools/builtin/skill_manage.js'; export interface SkillContext { skillPrompt: string; skillTools: AnyAgentTool[]; } /** * 构建 skill 上下文(Gateway 和 Chat Controller 共用) * @param skillLoader - SkillLoaderService 实例 * @param agentSkills - Agent 配置的 skill 名称列表 * @param builtinToolNames - 当前请求中已有的内置工具名称列表(用于条件过滤) */ export function buildSkillContext( skillLoader: SkillLoaderService, agentSkills: string[] | undefined, builtinToolNames: string[], ): SkillContext { const skillNames = agentSkills || []; const skillPrompt = skillLoader.buildSkillsPrompt(skillNames, builtinToolNames); const skillTools: AnyAgentTool[] = [ createReadSkillTool(skillLoader), createSkillManageTool(skillLoader), ]; return { skillPrompt, skillTools }; } ``` - [ ] **Step 2: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_context.ts git commit -m "feat: add buildSkillContext shared helper for gateway and chat" ``` --- ### Task 8: 改造 Skill Controller **Files:** - Modify: `packages/backend/src/modules/netaclaw/controller/skill.ts` - [ ] **Step 1: 重写 skill controller** 将 `packages/backend/src/modules/netaclaw/controller/skill.ts` 完整替换为: ```typescript import { Provide, Inject, Get, Post, Body, Query } from '@midwayjs/core'; import { CoolController, BaseController } from '@cool-midway/core'; import { NetaClawSkillEntity } from '../entity/skill.js'; import { SkillLoaderService } from '../service/skill_loader.js'; import { SkillInstallerService } from '../service/skill_installer.js'; @Provide() @CoolController({ api: ['info', 'list', 'page'], entity: NetaClawSkillEntity, pageQueryOp: { keyWordLikeFields: ['name', 'label', 'description'], fieldEq: ['status', 'source', 'skillType'], addOrderBy: { createTime: 'DESC' }, }, }) export class AdminNetaClawSkillController extends BaseController { @Inject() skillLoader: SkillLoaderService; @Inject() skillInstaller: SkillInstallerService; @Get('/metas', { summary: '获取已启用 skill 元数据快照' }) async metas() { return this.ok(await this.skillLoader.getSkillMetas()); } @Post('/setStatus', { summary: '启用/禁用 skill' }) async setStatus(@Body() body: { name: string; status: number }) { await this.skillLoader.setSkillStatus(body.name, body.status); return this.ok(); } @Post('/install', { summary: '安装 skill (GitHub)' }) async install(@Body() body: { url: string; branch?: string; version?: string }) { try { const meta = await this.skillInstaller.installFromGitHub( body.url, { branch: body.branch, tag: body.version }, ); return this.ok(meta); } catch (e: any) { return this.fail(e.message); } } @Post('/uninstall', { summary: '卸载 skill' }) async uninstall(@Body() body: { name: string }) { try { await this.skillInstaller.uninstall(body.name); return this.ok(); } catch (e: any) { return this.fail(e.message); } } @Post('/update', { summary: '更新 skill (重新拉取)' }) async update(@Body() body: { name: string }) { try { const meta = await this.skillInstaller.update(body.name); return this.ok(meta); } catch (e: any) { return this.fail(e.message); } } @Get('/content', { summary: '按需获取完整 SKILL.md 内容' }) async content(@Query('name') name: string) { const content = this.skillLoader.getSkillContent(name); if (!content) return this.fail('Skill 不存在'); return this.ok({ name, content }); } @Post('/checkUpdates', { summary: '检查所有 skill 是否有更新' }) async checkUpdates() { const results = await this.skillInstaller.checkUpdates(); return this.ok(results); } @Post('/installDeps', { summary: '手动触发依赖安装' }) async installDeps(@Body() body: { name: string }) { try { const result = await this.skillInstaller.installDependencies(body.name); return this.ok(result); } catch (e: any) { return this.fail(e.message); } } @Post('/create', { summary: 'Agent 自主创建 skill' }) async create(@Body() body: { name: string; content: string }) { try { const skillsDir = path.resolve(process.cwd(), 'skills'); const targetDir = path.join(skillsDir, body.name); // 路径安全检查 const realSkillsDir = await fs.realpath(skillsDir).catch(() => skillsDir); const realTarget = path.resolve(realSkillsDir, body.name); if (!realTarget.startsWith(realSkillsDir)) { return this.fail('路径安全检查失败'); } await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile(path.join(targetDir, 'SKILL.md'), body.content, 'utf-8'); await this.skillLoader.scanSkills(); return this.ok(); } catch (e: any) { return this.fail(e.message); } } @Post('/edit', { summary: 'Agent 自主编辑 skill' }) async edit(@Body() body: { name: string; content: string }) { return this.create(body); // 逻辑相同:覆盖写入 } } ``` 注意:需要在文件顶部补充 import: ```typescript import * as fs from 'fs/promises'; import * as path from 'path'; ``` - [ ] **Step 2: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/controller/skill.ts git commit -m "refactor: rewrite skill controller - @CoolController + install/uninstall/update APIs" ``` --- ### Task 9: 统一 Gateway + Chat Controller 的 skill 加载 **Files:** - Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts` - Modify: `packages/backend/src/modules/netaclaw/controller/chat.ts` - [ ] **Step 1: 改造 gateway/server.ts** 在 `server.ts` 顶部添加 import: ```typescript import { buildSkillContext } from '../service/skill_context.js'; ``` 找到构建 agentConfig 的代码块(约第 113-146 行),将 skill 加载逻辑替换为: ```typescript // 替换原来的: // const skillNames = agentInfo.skills || []; // const skillPrompt = this.skillLoader.getSkillPrompt(skillNames); // 改为(builtinToolNames 从当前已有的工具中提取): const builtinToolNames = ['bash', 'read_file', 'write_file', 'list_dir']; const { skillPrompt, skillTools } = buildSkillContext( this.skillLoader, agentInfo.skills, builtinToolNames, ); ``` 在 systemPrompt 拼接处保持不变(已经用 skillPrompt)。 在 `runAgent` 调用处(约第 188 行),将 tools 改为: ```typescript tools: [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools], ``` - [ ] **Step 2: 改造 chat.ts** 在 `chat.ts` 顶部添加 import: ```typescript import { buildSkillContext } from '../service/skill_context.js'; ``` 添加 SkillLoaderService 注入(如果还没有): ```typescript @Inject() skillLoader: SkillLoaderService; ``` 找到第 72 行的硬编码空数组: ```typescript // 替换原来的: // const skillPrompt = this.skillLoader.getSkillPrompt([]); // 改为: const builtinToolNames = ['bash', 'read_file', 'write_file', 'list_dir']; const { skillPrompt, skillTools } = buildSkillContext( this.skillLoader, agentEntity?.skills, builtinToolNames, ); ``` 在 `runAgent` 调用处(约第 106 行),将 tools 改为包含 skillTools: ```typescript const tools = [bashTool, readFileTool, writeFileTool, listDirTool, ...memoryTools, ...skillTools]; ``` - [ ] **Step 3: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` - [ ] **Step 4: 启动后端验证无报错** ```bash cd packages/backend && pnpm dev ``` - [ ] **Step 5: Commit** ```bash git add packages/backend/src/modules/netaclaw/gateway/server.ts packages/backend/src/modules/netaclaw/controller/chat.ts git commit -m "fix: unify skill loading in gateway and chat controller via buildSkillContext" ``` --- ## Phase 3: 前端改造 ### Task 10: 删除老组件 + 重写 skills.vue **Files:** - Delete: `packages/frontend/src/modules/agent/components/skill-config.vue` - Delete: `packages/frontend/src/modules/agent/components/skill-prompts.vue` - Delete: `packages/frontend/src/modules/agent/components/skill-model.vue` - Modify: `packages/frontend/src/modules/agent/views/skills.vue` - [ ] **Step 1: 删除老组件** ```bash rm packages/frontend/src/modules/agent/components/skill-config.vue rm packages/frontend/src/modules/agent/components/skill-prompts.vue rm packages/frontend/src/modules/agent/components/skill-model.vue ``` - [ ] **Step 2: 重写 skills.vue** 将 `packages/frontend/src/modules/agent/views/skills.vue` 完整替换。核心改动: - 所有 `fetch()` 调用改为 `useCool()` 的 service 代理 - 新增安装对话框(GitHub URL 输入) - 新增卸载/更新操作 - 卡片显示 emoji + source 标签 关键代码结构: ```vue ``` - [ ] **Step 3: Commit** ```bash git add -A packages/frontend/src/modules/agent/ git commit -m "refactor: rewrite skills.vue, delete old skill-config/prompts/model components" ``` --- ### Task 11: 重写 skill-detail.vue **Files:** - Modify: `packages/frontend/src/modules/agent/components/skill-detail.vue` - [ ] **Step 1: 重写 skill-detail.vue** 替换为新的详情抽屉,显示 SKILL.md 渲染、来源信息、依赖状态: ```vue ``` - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/modules/agent/components/skill-detail.vue git commit -m "refactor: rewrite skill-detail.vue - markdown content, deps, conditions" ``` --- ### Task 12: 改造 agent-edit.vue Skill Tab **Files:** - Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue` - [ ] **Step 1: 改造 Skill 配置 Tab** 在 `agent-edit.vue` 中找到 Tab 2(Skill 配置)部分,将其改造为: - 使用 `service.request` 替代 `fetch` - 左右布局:可用 skill 列表 + 已选 skill 列表 找到加载 skill 列表的 `fetch` 调用(约第 306-315 行),替换为: ```typescript async function loadSkillMetas() { const { service } = useCool(); const res = await service.request({ url: '/admin/netaclaw/skill/metas' }); skillMetas.value = (res || []).filter((s: any) => s.status === 1); } ``` 找到保存 agent 的 `fetch` 调用(约第 410-425 行),替换为: ```typescript async function saveAgent() { const { service } = useCool(); if (form.value.id) { await service.netaclaw.agent.update(form.value); } else { await service.netaclaw.agent.add(form.value); } } ``` 找到加载 agent 详情的 `fetch` 调用(约第 334-378 行),替换为: ```typescript async function loadAgentInfo(id: number) { const { service } = useCool(); const res = await service.netaclaw.agent.info({ id }); if (res) { Object.assign(form.value, res); } } ``` Skill 选择区域改为带搜索的双列布局: ```vue
可用 Skill
{{ skill.emoji || '🔧' }} {{ skill.label || skill.name }} {{ skill.skillType || '-' }}
已选 Skill ({{ form.skills?.length || 0 }})
{{ getSkillLabel(name) }} 移除
``` - [ ] **Step 2: 验证前端编译** ```bash cd packages/frontend && pnpm dev ``` 在浏览器中访问 `/agent/agents`,点击编辑 Agent,确认 Skill Tab 正常显示。 - [ ] **Step 3: Commit** ```bash git add packages/frontend/src/modules/agent/views/agent-edit.vue git commit -m "refactor: agent-edit skill tab - cool admin service proxy, dual-column layout" ``` --- ## Phase 4: 集成验证 ### Task 13: 端到端验证 - [ ] **Step 1: 创建测试 skill** 在 `packages/backend/skills/` 下创建一个测试 skill: ```bash mkdir -p packages/backend/skills/test-greeting ``` 创建 `packages/backend/skills/test-greeting/SKILL.md`: ```markdown --- name: test-greeting description: 测试 Skill - 根据用户姓名生成个性化问候语 version: 1.0.0 metadata: skillType: llm emoji: "👋" tags: [测试, 问候] conditions: requires_tools: ["bash"] --- # 问候语生成 Skill ## 何时使用 当用户要求生成问候语或打招呼时使用此 skill。 ## 执行流程 1. 获取用户提供的姓名 2. 根据当前时间选择合适的问候方式(早上好/下午好/晚上好) 3. 生成个性化的问候语 ## 输出格式 直接返回问候语文本。 ``` - [ ] **Step 2: 启动后端,验证 skill 加载** ```bash cd packages/backend && pnpm dev ``` 检查日志应包含:`[SkillLoader] 已加载 Skill: test-greeting` - [ ] **Step 3: 验证 API 接口** ```bash # 获取 skill 列表 curl http://localhost:8003/admin/netaclaw/skill/metas -H "Authorization: " # 获取 skill 内容 curl "http://localhost:8003/admin/netaclaw/skill/content?name=test-greeting" -H "Authorization: " # 禁用 skill curl -X POST http://localhost:8003/admin/netaclaw/skill/setStatus \ -H "Content-Type: application/json" \ -H "Authorization: " \ -d '{"name":"test-greeting","status":0}' ``` - [ ] **Step 4: 验证前端页面** 启动前端 `pnpm dev`,访问: 1. `/agent/skills` — 应显示 test-greeting 卡片,可启停/查看详情 2. `/agent/agents` — 编辑 Agent,Skill Tab 应显示 test-greeting 可选 - [ ] **Step 5: 验证 Agent 对话中的 skill 注入** 在 `/agent/chat` 中与配置了 test-greeting skill 的 Agent 对话,检查: 1. System prompt 中应包含 `` XML 索引 2. Agent 应能调用 `read_skill` 工具读取完整内容 3. Agent 应能调用 `skill_manage` 工具创建新 skill - [ ] **Step 6: 最终 Commit** ```bash git add packages/backend/skills/test-greeting/ git commit -m "test: add test-greeting skill for e2e verification" ```