2026-05-02 10:56:58 +10:00
|
|
|
import { readdir, readFile } from 'fs/promises'
|
2026-04-21 12:35:48 +08:00
|
|
|
import { join, resolve } from 'path'
|
2026-05-02 10:56:58 +10:00
|
|
|
import { createHash } from 'crypto'
|
2026-04-21 12:35:48 +08:00
|
|
|
import {
|
|
|
|
|
readConfigYaml, writeConfigYaml,
|
|
|
|
|
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
|
|
|
|
} from '../../services/config-helpers'
|
2026-05-02 10:56:58 +10:00
|
|
|
import { pinSkill } from '../../services/hermes/hermes-cli'
|
|
|
|
|
|
|
|
|
|
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
|
|
|
|
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
|
|
|
|
const map = new Map<string, string>()
|
|
|
|
|
if (!manifestContent) return map
|
|
|
|
|
for (const line of manifestContent.split('\n')) {
|
|
|
|
|
const trimmed = line.trim()
|
|
|
|
|
if (!trimmed) continue
|
|
|
|
|
const idx = trimmed.indexOf(':')
|
|
|
|
|
if (idx === -1) continue
|
|
|
|
|
const name = trimmed.slice(0, idx).trim()
|
|
|
|
|
const hash = trimmed.slice(idx + 1).trim()
|
|
|
|
|
if (name && hash) map.set(name, hash)
|
|
|
|
|
}
|
|
|
|
|
return map
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read hub-installed skill names from ~/.hermes/skills/.hub/lock.json */
|
|
|
|
|
function readHubInstalledNames(lockContent: string | null): Set<string> {
|
|
|
|
|
if (!lockContent) return new Set()
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(lockContent)
|
|
|
|
|
if (data?.installed && typeof data.installed === 'object') {
|
|
|
|
|
return new Set(Object.keys(data.installed))
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
return new Set()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Compute md5 hash of all files in a directory (mirrors Hermes _dir_hash), with in-memory cache */
|
|
|
|
|
const hashCache = new Map<string, { hash: string; mtime: number }>()
|
|
|
|
|
const HASH_CACHE_TTL = 60_000 // 1 minute
|
|
|
|
|
|
|
|
|
|
async function dirHash(directory: string): Promise<string> {
|
|
|
|
|
const cached = hashCache.get(directory)
|
|
|
|
|
if (cached && Date.now() - cached.mtime < HASH_CACHE_TTL) return cached.hash
|
|
|
|
|
|
|
|
|
|
const hasher = createHash('md5')
|
|
|
|
|
const files = await listFilesRecursive(directory, '')
|
|
|
|
|
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
|
|
|
for (const f of files) {
|
|
|
|
|
hasher.update(f.path)
|
|
|
|
|
const content = await readFile(join(directory, f.path))
|
|
|
|
|
hasher.update(content)
|
|
|
|
|
}
|
|
|
|
|
const hash = hasher.digest('hex')
|
|
|
|
|
hashCache.set(directory, { hash, mtime: Date.now() })
|
|
|
|
|
return hash
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Determine the source type of a skill */
|
|
|
|
|
function getSkillSource(
|
|
|
|
|
dirName: string,
|
|
|
|
|
bundledManifest: Map<string, string>,
|
|
|
|
|
hubNames: Set<string>,
|
|
|
|
|
): 'builtin' | 'hub' | 'local' {
|
|
|
|
|
if (bundledManifest.has(dirName)) return 'builtin'
|
|
|
|
|
if (hubNames.has(dirName)) return 'hub'
|
|
|
|
|
return 'local'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read .usage.json as a name→stats map */
|
|
|
|
|
interface UsageStats { patch_count: number; use_count: number; view_count: number; pinned: boolean }
|
|
|
|
|
function readUsageStats(usageContent: string | null): Map<string, UsageStats> {
|
|
|
|
|
const map = new Map<string, UsageStats>()
|
|
|
|
|
if (!usageContent) return map
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(usageContent)
|
|
|
|
|
for (const [name, stats] of Object.entries(data)) {
|
|
|
|
|
const s = stats as any
|
|
|
|
|
map.set(name, { patch_count: s.patch_count ?? 0, use_count: s.use_count ?? 0, view_count: s.view_count ?? 0, pinned: !!s.pinned })
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
return map
|
|
|
|
|
}
|
2026-04-21 12:35:48 +08:00
|
|
|
|
2026-05-07 10:44:52 +08:00
|
|
|
/**
|
|
|
|
|
* Scan for skills at different directory depths.
|
|
|
|
|
*
|
|
|
|
|
* Supports both:
|
|
|
|
|
* - Three-level: skills/<category>/<skill-name>/SKILL.md (category is a container)
|
|
|
|
|
* - Two-level: skills/<skill-name>/SKILL.md (flat skill under "misc" category)
|
|
|
|
|
*
|
|
|
|
|
* Categories are identified by having a DESCRIPTION.md at the category level
|
|
|
|
|
* or by containing subdirectories with SKILL.md (three-level pattern).
|
|
|
|
|
* Skills without a parent category (flat skills) are grouped under the "misc" category.
|
|
|
|
|
*/
|
|
|
|
|
async function scanSkillsDir(skillsDir: string, bundledManifest: Map<string, string>, hubNames: Set<string>, disabledList: string[], usageStats: Map<string, UsageStats>) {
|
|
|
|
|
const allEntries = await readdir(skillsDir, { withFileTypes: true })
|
|
|
|
|
const dirNames = allEntries
|
|
|
|
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
|
|
|
.map(e => e.name)
|
|
|
|
|
|
|
|
|
|
// Classify directories: categories vs. flat skills
|
|
|
|
|
const categoryDirs: { name: string; description: string }[] = []
|
|
|
|
|
const flatSkills: { name: string; skillMd: string; source: string }[] = []
|
|
|
|
|
|
|
|
|
|
for (const dirName of dirNames) {
|
|
|
|
|
const catDir = join(skillsDir, dirName)
|
|
|
|
|
const hasDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
|
|
|
|
|
const hasSkillMd = await safeReadFile(join(catDir, 'SKILL.md'))
|
|
|
|
|
const subEntries = await readdir(catDir, { withFileTypes: true })
|
|
|
|
|
const subDirs = subEntries.filter(se => se.isDirectory())
|
|
|
|
|
|
|
|
|
|
// Priority: SKILL.md at top level → flat skill
|
|
|
|
|
// DESCRIPTION.md or subdirs (without SKILL.md) → category
|
|
|
|
|
if (hasSkillMd) {
|
|
|
|
|
// Flat skill: has SKILL.md at the top level (two-level pattern)
|
|
|
|
|
// Could also have subdirectories (references/, scripts/, etc.)
|
|
|
|
|
flatSkills.push({
|
|
|
|
|
name: dirName,
|
|
|
|
|
skillMd: hasSkillMd,
|
|
|
|
|
source: getSkillSource(dirName, bundledManifest, hubNames),
|
|
|
|
|
})
|
|
|
|
|
} else if (!!hasDesc || subDirs.length > 0) {
|
|
|
|
|
// True category: has DESCRIPTION.md or subdirs, but no SKILL.md at top level
|
|
|
|
|
const catDescription = hasDesc ? hasDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
|
|
|
|
|
categoryDirs.push({ name: dirName, description: catDescription })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build categories with their nested skills
|
|
|
|
|
const categories: any[] = []
|
|
|
|
|
|
|
|
|
|
for (const cat of categoryDirs) {
|
|
|
|
|
const catDir = join(skillsDir, cat.name)
|
|
|
|
|
const subEntries = await readdir(catDir, { withFileTypes: true })
|
|
|
|
|
const skills: any[] = []
|
|
|
|
|
for (const se of subEntries) {
|
|
|
|
|
if (!se.isDirectory()) continue
|
|
|
|
|
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
|
|
|
|
|
if (skillMd) {
|
|
|
|
|
const source = getSkillSource(se.name, bundledManifest, hubNames)
|
|
|
|
|
let modified = false
|
|
|
|
|
if (source === 'builtin') {
|
|
|
|
|
const manifestHash = bundledManifest.get(se.name)
|
|
|
|
|
if (manifestHash) {
|
|
|
|
|
const currentHash = await dirHash(join(catDir, se.name))
|
|
|
|
|
modified = currentHash !== manifestHash
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const usage = usageStats.get(se.name)
|
|
|
|
|
skills.push({
|
|
|
|
|
name: se.name,
|
|
|
|
|
description: extractDescription(skillMd),
|
|
|
|
|
enabled: !disabledList.includes(se.name),
|
|
|
|
|
source,
|
|
|
|
|
modified: modified || undefined,
|
|
|
|
|
patchCount: usage?.patch_count,
|
|
|
|
|
useCount: usage?.use_count,
|
|
|
|
|
viewCount: usage?.view_count,
|
|
|
|
|
pinned: usage?.pinned || undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (skills.length > 0) {
|
|
|
|
|
categories.push({ name: cat.name, description: cat.description, skills })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Group flat skills into a "misc" (雜項) category
|
|
|
|
|
if (flatSkills.length > 0) {
|
|
|
|
|
const miscSkills: any[] = []
|
|
|
|
|
for (const fs of flatSkills) {
|
|
|
|
|
const usage = usageStats.get(fs.name)
|
|
|
|
|
miscSkills.push({
|
|
|
|
|
name: fs.name,
|
|
|
|
|
description: extractDescription(fs.skillMd),
|
|
|
|
|
enabled: !disabledList.includes(fs.name),
|
|
|
|
|
source: fs.source,
|
|
|
|
|
modified: undefined,
|
|
|
|
|
patchCount: usage?.patch_count,
|
|
|
|
|
useCount: usage?.use_count,
|
|
|
|
|
viewCount: usage?.view_count,
|
|
|
|
|
pinned: usage?.pinned || undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
miscSkills.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
|
|
|
|
categories.push({
|
|
|
|
|
name: 'misc',
|
|
|
|
|
description: '雜項',
|
|
|
|
|
skills: miscSkills,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
categories.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) }
|
|
|
|
|
return categories
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:35:48 +08:00
|
|
|
export async function list(ctx: any) {
|
|
|
|
|
const skillsDir = join(getHermesDir(), 'skills')
|
|
|
|
|
try {
|
|
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
const disabledList: string[] = config.skills?.disabled || []
|
2026-05-02 10:56:58 +10:00
|
|
|
|
|
|
|
|
// Read provenance sources
|
|
|
|
|
const bundledManifest = readBundledManifest(await safeReadFile(join(skillsDir, '.bundled_manifest')))
|
|
|
|
|
const hubNames = readHubInstalledNames(await safeReadFile(join(skillsDir, '.hub', 'lock.json')))
|
|
|
|
|
const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json')))
|
|
|
|
|
|
2026-05-07 10:44:52 +08:00
|
|
|
// Scan all skills (supports both two-level and three-level directory structures)
|
|
|
|
|
const categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats)
|
2026-05-02 10:56:58 +10:00
|
|
|
|
|
|
|
|
// Read archived skills from .archive/
|
|
|
|
|
const archived: any[] = []
|
|
|
|
|
const archiveDir = join(skillsDir, '.archive')
|
|
|
|
|
const archiveEntries = await readdir(archiveDir, { withFileTypes: true }).catch(() => [] as import('fs').Dirent[])
|
|
|
|
|
for (const entry of archiveEntries) {
|
|
|
|
|
if (!entry.isDirectory()) continue
|
|
|
|
|
const skillMd = await safeReadFile(join(archiveDir, entry.name, 'SKILL.md'))
|
|
|
|
|
if (skillMd) {
|
|
|
|
|
const usage = usageStats.get(entry.name)
|
|
|
|
|
archived.push({
|
|
|
|
|
name: entry.name,
|
|
|
|
|
description: extractDescription(skillMd),
|
|
|
|
|
source: getSkillSource(entry.name, bundledManifest, hubNames),
|
|
|
|
|
patchCount: usage?.patch_count,
|
|
|
|
|
useCount: usage?.use_count,
|
|
|
|
|
viewCount: usage?.view_count,
|
|
|
|
|
pinned: usage?.pinned || undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
archived.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
|
|
|
|
|
|
|
|
|
ctx.body = { categories, archived }
|
2026-04-21 12:35:48 +08:00
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function toggle(ctx: any) {
|
|
|
|
|
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
|
|
|
|
if (!name || typeof enabled !== 'boolean') {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing name or enabled flag' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const config = await readConfigYaml()
|
|
|
|
|
if (!config.skills) config.skills = {}
|
|
|
|
|
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
|
|
|
|
const disabled = config.skills.disabled as string[]
|
|
|
|
|
const idx = disabled.indexOf(name)
|
|
|
|
|
if (enabled) { if (idx !== -1) disabled.splice(idx, 1) }
|
|
|
|
|
else { if (idx === -1) disabled.push(name) }
|
|
|
|
|
await writeConfigYaml(config)
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listFiles(ctx: any) {
|
|
|
|
|
const { category, skill } = ctx.params
|
2026-05-07 10:44:52 +08:00
|
|
|
const hd = getHermesDir()
|
|
|
|
|
// Handle "misc" category: real skill dir is skills/<skill>, not skills/misc/<skill>
|
|
|
|
|
const realDir = category === 'misc' ? skill : join(category, skill)
|
|
|
|
|
const skillDir = join(hd, 'skills', realDir)
|
2026-04-21 12:35:48 +08:00
|
|
|
try {
|
|
|
|
|
const allFiles = await listFilesRecursive(skillDir, '')
|
|
|
|
|
const files = allFiles.filter(f => f.path !== 'SKILL.md')
|
|
|
|
|
ctx.body = { files }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function readFile_(ctx: any) {
|
|
|
|
|
const filePath = (ctx.params as any).path
|
|
|
|
|
const hd = getHermesDir()
|
2026-05-07 10:44:52 +08:00
|
|
|
// Handle "misc" category: real skill dir is skills/<skill>, not skills/misc/<skill>
|
|
|
|
|
let realPath = filePath
|
|
|
|
|
if (filePath.startsWith('misc/')) {
|
|
|
|
|
realPath = filePath.slice(5)
|
|
|
|
|
}
|
|
|
|
|
const fullPath = resolve(join(hd, 'skills', realPath))
|
2026-04-21 12:35:48 +08:00
|
|
|
if (!fullPath.startsWith(join(hd, 'skills'))) {
|
|
|
|
|
ctx.status = 403
|
|
|
|
|
ctx.body = { error: 'Access denied' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const content = await safeReadFile(fullPath)
|
|
|
|
|
if (content === null) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'File not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.body = { content }
|
|
|
|
|
}
|
2026-05-02 10:56:58 +10:00
|
|
|
|
|
|
|
|
export async function pin_(ctx: any) {
|
|
|
|
|
const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean }
|
|
|
|
|
if (!name || typeof pinned !== 'boolean') {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'Missing name or pinned flag' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await pinSkill(name, pinned)
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
}
|