feat(node-runtime): extract SkillManagerCore class with abstract FS

Platform-agnostic skill CRUD manager with dependency injection for file
system ops, builtin skill resources, and content hashing. Supports init,
CRUD, builtin import/reimport, cloud markdown import, and AI skill menu
generation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-18 00:14:51 +08:00
1 parent cb9849c164
commit 8412180470
4 files changed
+529 -1

No files matched your search

@@ -0,0 +1,163 @@
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { SkillManagerCore, type SkillManagerFs, type SkillManagerCoreDeps } from '../skill-manager-core'
function createMemoryFs(): SkillManagerFs & { files: Map<string, string> } {
const files = new Map<string, string>()
return {
files,
ensureDir: () => {
/* no-op */
},
listFiles: (_dir, ext) =>
Array.from(files.keys())
.filter((f) => f.endsWith(ext))
.map((f) => f.split('/').pop()!),
readFile: (p) => {
const content = files.get(p)
if (!content) throw new Error(`File not found: ${p}`)
return content
},
writeFile: (p, content) => files.set(p, content),
deleteFile: (p) => files.delete(p),
fileExists: (p) => files.has(p),
joinPath: (...parts) => parts.join('/'),
}
}
const SAMPLE_SKILL = `---
id: test_skill
name: Test Skill
description: A test skill
tags:
- test
chatScope: all
tools: []
---
You are a test skill.`
function createManager(builtins?: Array<{ id: string; content: string }>) {
const memFs = createMemoryFs()
const deps: SkillManagerCoreDeps = {
fs: memFs,
skillsDir: '/data/skills',
builtinRawSkills: builtins || [],
}
return { manager: new SkillManagerCore(deps), fs: memFs }
}
describe('SkillManagerCore', () => {
let manager: SkillManagerCore
beforeEach(() => {
const ctx = createManager()
manager = ctx.manager
})
it('initializes with empty catalog', () => {
const result = manager.init()
assert.equal(result.total, 0)
})
it('creates a skill from raw markdown', () => {
manager.init()
const result = manager.createSkill(SAMPLE_SKILL)
assert.ok(result.success)
assert.equal(result.id, 'test_skill')
assert.equal(manager.getAllSkills().length, 1)
})
it('getSkillConfig returns full def', () => {
manager.init()
manager.createSkill(SAMPLE_SKILL)
const def = manager.getSkillConfig('test_skill')
assert.ok(def)
assert.equal(def!.name, 'Test Skill')
assert.equal(def!.description, 'A test skill')
})
it('updates a skill', () => {
manager.init()
manager.createSkill(SAMPLE_SKILL)
const updatedMd = SAMPLE_SKILL.replace('Test Skill', 'Updated Skill')
const result = manager.updateSkill('test_skill', updatedMd)
assert.ok(result.success)
assert.equal(manager.getSkillConfig('test_skill')!.name, 'Updated Skill')
})
it('deletes a skill', () => {
manager.init()
manager.createSkill(SAMPLE_SKILL)
const result = manager.deleteSkill('test_skill')
assert.ok(result.success)
assert.equal(manager.getSkillConfig('test_skill'), null)
})
it('returns error for non-existent skill ops', () => {
manager.init()
assert.equal(manager.updateSkill('nope', 'x').success, false)
assert.equal(manager.deleteSkill('nope').success, false)
})
it('imports from raw markdown (cloud)', () => {
manager.init()
const result = manager.importSkillFromMd(SAMPLE_SKILL)
assert.ok(result.success)
assert.equal(result.id, 'test_skill')
})
it('rejects duplicate cloud import', () => {
manager.init()
manager.importSkillFromMd(SAMPLE_SKILL)
const result = manager.importSkillFromMd(SAMPLE_SKILL)
assert.equal(result.success, false)
})
it('getSkillMenu returns null when no skills', () => {
manager.init()
assert.equal(manager.getSkillMenu('group'), null)
})
it('getSkillMenu returns menu text', () => {
manager.init()
manager.createSkill(SAMPLE_SKILL)
const menu = manager.getSkillMenu('group')
assert.ok(menu)
assert.ok(menu!.includes('test_skill'))
assert.ok(menu!.includes('Test Skill'))
})
it('getSkillMenu filters by chatScope', () => {
manager.init()
const groupOnly = SAMPLE_SKILL.replace('chatScope: all', 'chatScope: group')
manager.createSkill(groupOnly)
assert.ok(manager.getSkillMenu('group'))
assert.equal(manager.getSkillMenu('private'), null)
})
})
describe('SkillManagerCore with builtins', () => {
it('imports builtin skill', () => {
const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }])
manager.init()
const result = manager.importSkill('builtin_1')
assert.ok(result.success)
assert.equal(manager.getAllSkills().length, 1)
})
it('shows builtin catalog', () => {
const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }])
manager.init()
const catalog = manager.getBuiltinCatalog()
assert.equal(catalog.length, 1)
assert.equal(catalog[0].imported, false)
})
it('marks imported builtin in catalog', () => {
const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }])
manager.init()
manager.importSkill('builtin_1')
const catalog = manager.getBuiltinCatalog()
assert.equal(catalog[0].imported, true)
})
})
+11 -1
View File
@@ -33,8 +33,18 @@ export type {
// Tokenizer
export { countTokens, countMessagesTokens } from './tokenizer'
// SkillManager
// SkillManager (runtime: activate-skill tool builder)
export { SkillManager } from './skill-manager'
// SkillManagerCore (CRUD, shared)
export { SkillManagerCore } from './skill-manager-core'
export type {
SkillInitResult,
SkillSaveResult as SkillManagerSaveResult,
BuiltinSkillInfo,
SkillManagerFs,
SkillManagerCoreDeps,
} from './skill-manager-core'
export type { SkillManagerLogger } from './skill-manager'
export { createActivateSkillTool } from './activate-skill-tool'
export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool'
@@ -0,0 +1,345 @@
/**
* Platform-agnostic skill manager (core logic).
* Same strategy as AssistantManager: abstract FS and builtin resources via DI.
*
* Note: Named "skill-manager-core" to avoid conflict with the existing
* Electron-specific SkillManager class in skill-manager.ts.
*/
import { parseSkillFile } from './skill-parser'
import type { SkillDef, SkillSummary } from './types'
// ==================== Result types ====================
export interface SkillInitResult {
total: number
}
export interface SkillSaveResult {
success: boolean
error?: string
}
export interface BuiltinSkillInfo {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
imported: boolean
hasUpdate: boolean
}
// ==================== Dependency abstraction ====================
export interface SkillManagerFs {
ensureDir(dir: string): void
listFiles(dir: string, ext: string): string[]
readFile(filePath: string): string
writeFile(filePath: string, content: string): void
deleteFile(filePath: string): void
fileExists(filePath: string): boolean
joinPath(...parts: string[]): string
}
export interface SkillManagerCoreDeps {
fs: SkillManagerFs
skillsDir: string
builtinRawSkills?: Array<{ id: string; content: string }>
contentHash?: (content: string) => string
logger?: {
info: (category: string, message: string, data?: unknown) => void
warn: (category: string, message: string, data?: unknown) => void
error: (category: string, message: string, data?: unknown) => void
}
}
// ==================== Internal helpers ====================
function toSummary(def: SkillDef): SkillSummary {
return {
id: def.id,
name: def.name,
description: def.description,
tags: def.tags,
chatScope: def.chatScope,
tools: def.tools,
builtinId: def.builtinId,
}
}
function injectBuiltinId(rawMd: string, builtinId: string): string {
if (rawMd.includes('builtinId:')) return rawMd
const endOfFrontmatter = rawMd.indexOf('\n---', 3)
if (endOfFrontmatter === -1) return rawMd
return rawMd.slice(0, endOfFrontmatter) + `\nbuiltinId: ${builtinId}` + rawMd.slice(endOfFrontmatter)
}
function stripBuiltinId(content: string): string {
return content.replace(/\nbuiltinId:.*\n/g, '\n')
}
function simpleHash(content: string): string {
let hash = 0
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0
}
return hash.toString(36)
}
// ==================== Manager ====================
const MAX_SKILL_MENU_ITEMS = 15
export class SkillManagerCore {
private deps: SkillManagerCoreDeps
private builtinDefs = new Map<string, SkillDef>()
private builtinRawMap = new Map<string, string>()
private cache = new Map<string, SkillDef>()
private initialized = false
private hashFn: (content: string) => string
constructor(deps: SkillManagerCoreDeps) {
this.deps = deps
this.hashFn = deps.contentHash || simpleHash
this.initBuiltinCache()
}
private initBuiltinCache(): void {
if (!this.deps.builtinRawSkills) return
for (const { id, content } of this.deps.builtinRawSkills) {
this.builtinRawMap.set(id, content)
const def = parseSkillFile(content, `${id}.md`)
if (def) this.builtinDefs.set(id, def)
}
}
private ensureInitialized(): void {
if (!this.initialized) this.init()
}
// ==================== Init ====================
init(): SkillInitResult {
const { fs, skillsDir } = this.deps
fs.ensureDir(skillsDir)
this.loadAll()
this.initialized = true
this.deps.logger?.info('SkillManager', 'Initialized', { total: this.cache.size })
return { total: this.cache.size }
}
private loadAll(): void {
const { fs, skillsDir } = this.deps
this.cache.clear()
const files = fs.listFiles(skillsDir, '.md')
for (const file of files) {
try {
const filePath = fs.joinPath(skillsDir, file)
const content = fs.readFile(filePath)
const def = parseSkillFile(content, filePath)
if (def) {
this.cache.set(def.id, def)
} else {
this.deps.logger?.warn('SkillManager', `Failed to parse: ${file}`)
}
} catch (error) {
this.deps.logger?.warn('SkillManager', `Failed to load: ${file}`, { error: String(error) })
}
}
}
// ==================== Query ====================
getAllSkills(): SkillSummary[] {
this.ensureInitialized()
return Array.from(this.cache.values()).map(toSummary)
}
getSkillConfig(id: string): SkillDef | null {
this.ensureInitialized()
return this.cache.get(id) ?? null
}
// ==================== Builtin catalog ====================
getBuiltinCatalog(): BuiltinSkillInfo[] {
this.ensureInitialized()
return Array.from(this.builtinDefs.entries()).map(([builtinId, builtin]) => {
const userSkill = this.findByBuiltinId(builtinId)
const imported = !!userSkill
const hasUpdate = imported ? this.hasBuiltinUpdate(builtinId, userSkill!) : false
return {
id: builtinId,
name: builtin.name,
description: builtin.description,
tags: builtin.tags,
chatScope: builtin.chatScope,
tools: builtin.tools,
imported,
hasUpdate,
}
})
}
// ==================== Import ====================
importSkill(builtinId: string): SkillSaveResult & { id?: string } {
this.ensureInitialized()
const rawContent = this.builtinRawMap.get(builtinId)
if (!rawContent) return { success: false, error: `Builtin skill not found: ${builtinId}` }
const existing = this.findByBuiltinId(builtinId)
if (existing) return { success: false, error: `Skill already imported: ${builtinId}` }
const contentWithId = injectBuiltinId(rawContent, builtinId)
const def = parseSkillFile(contentWithId, `${builtinId}.md`)
if (!def) return { success: false, error: `Failed to parse builtin skill: ${builtinId}` }
def.builtinId = builtinId
const result = this.saveToDisk(def.id, contentWithId, def)
return { ...result, id: result.success ? def.id : undefined }
}
reimportSkill(id: string): SkillSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Skill not found: ${id}` }
if (!existing.builtinId) return { success: false, error: 'Only imported builtin skills can be reimported' }
const rawContent = this.builtinRawMap.get(existing.builtinId)
if (!rawContent) return { success: false, error: `Builtin template not found: ${existing.builtinId}` }
const contentWithId = injectBuiltinId(rawContent, existing.builtinId)
const def = parseSkillFile(contentWithId, `${id}.md`)
if (!def) return { success: false, error: `Failed to parse builtin skill: ${existing.builtinId}` }
def.builtinId = existing.builtinId
return this.saveToDisk(id, contentWithId, def)
}
importSkillFromMd(rawMd: string): SkillSaveResult & { id?: string } {
this.ensureInitialized()
const def = parseSkillFile(rawMd, 'cloud_import.md')
if (!def) return { success: false, error: 'Failed to parse skill markdown' }
if (this.cache.has(def.id)) return { success: false, error: `Skill already exists: ${def.id}` }
const result = this.saveToDisk(def.id, rawMd, def)
return { ...result, id: result.success ? def.id : undefined }
}
// ==================== Mutate ====================
updateSkill(id: string, rawMd: string): SkillSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Skill not found: ${id}` }
const def = parseSkillFile(rawMd, `${id}.md`)
if (!def) return { success: false, error: 'Failed to parse skill content' }
def.id = id
if (existing.builtinId) def.builtinId = existing.builtinId
return this.saveToDisk(id, rawMd, def)
}
createSkill(rawMd: string): SkillSaveResult & { id?: string } {
this.ensureInitialized()
const def = parseSkillFile(rawMd, 'new_skill.md')
if (!def) return { success: false, error: 'Failed to parse skill content' }
if (this.cache.has(def.id)) {
def.id = `${def.id}_${Date.now().toString(36)}`
}
const result = this.saveToDisk(def.id, rawMd, def)
return { ...result, id: result.success ? def.id : undefined }
}
deleteSkill(id: string): SkillSaveResult {
this.ensureInitialized()
const existing = this.cache.get(id)
if (!existing) return { success: false, error: `Skill not found: ${id}` }
try {
const filePath = this.deps.fs.joinPath(this.deps.skillsDir, `${id}.md`)
if (this.deps.fs.fileExists(filePath)) this.deps.fs.deleteFile(filePath)
this.cache.delete(id)
return { success: true }
} catch (error) {
return { success: false, error: String(error) }
}
}
// ==================== AI Skill Menu ====================
getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null {
this.ensureInitialized()
const compatible = Array.from(this.cache.values()).filter((skill) => {
if (skill.chatScope !== 'all' && skill.chatScope !== chatType) return false
if (skill.tools.length > 0 && allowedTools && allowedTools.length > 0) {
if (!skill.tools.every((t) => allowedTools.includes(t))) return false
}
return true
})
if (compatible.length === 0) return null
const items = compatible.slice(0, MAX_SKILL_MENU_ITEMS)
const lines = items.map((s) => `- ${s.id}: ${s.name} — ${s.description}`)
return `## 可用技能
以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时,
请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。
${lines.join('\n')}
如果用户的问题不需要使用技能,直接回答即可。`
}
// ==================== Internal ====================
private findByBuiltinId(builtinId: string): SkillDef | undefined {
return Array.from(this.cache.values()).find((s) => s.builtinId === builtinId)
}
private saveToDisk(id: string, rawMd: string, def: SkillDef): SkillSaveResult {
try {
const filePath = this.deps.fs.joinPath(this.deps.skillsDir, `${id}.md`)
this.deps.fs.writeFile(filePath, rawMd)
this.cache.set(id, def)
return { success: true }
} catch (error) {
this.deps.logger?.error('SkillManager', `Failed to save: ${id}`, { error: String(error) })
return { success: false, error: String(error) }
}
}
private hasBuiltinUpdate(builtinId: string, userSkill: SkillDef): boolean {
const rawContent = this.builtinRawMap.get(builtinId)
if (!rawContent) return false
try {
const userFilePath = this.deps.fs.joinPath(this.deps.skillsDir, `${userSkill.id}.md`)
const userContent = this.deps.fs.readFile(userFilePath)
return this.hashFn(rawContent) !== this.hashFn(stripBuiltinId(userContent))
} catch {
return false
}
}
}
+10
View File
@@ -57,6 +57,16 @@ export { checkAndCompress, manualCompress } from './ai'
// SkillManager
export { SkillManager } from './ai'
// SkillManagerCore
export { SkillManagerCore } from './ai'
export type {
SkillInitResult,
SkillManagerSaveResult,
BuiltinSkillInfo,
SkillManagerFs,
SkillManagerCoreDeps,
} from './ai'
export type { SkillManagerLogger, ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './ai'
export { createActivateSkillTool } from './ai'