- /data/skills mount (host: /opt/skills) - skill_find, skill_use, skill_resource added to default read-only tool set; opt-in for agents with explicit tools: whitelist - AGENTS.md builtin agents drop explicit tools: arrays to inherit the new default (now includes skill tools) - POST /api/chats/:id/skill_invoke for slash-command flow - 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import { join, isAbsolute, basename } from 'node:path';
|
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
|
|
|
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
|
|
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
|
|
// body. Three tools expose the library: skill_find (search), skill_use (load
|
|
// body), skill_resource (read a support file inside the folder).
|
|
//
|
|
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
|
|
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
|
|
// subfolders and are NOT themselves skills. Support files inside skill
|
|
// folders are reachable via skill_resource, never auto-parsed.
|
|
//
|
|
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
|
|
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
|
// is re-read without a restart. No watcher.
|
|
|
|
const SKILLS_ROOT = '/data/skills';
|
|
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
|
const LIST_CACHE_TTL_MS = 60_000;
|
|
|
|
export interface Skill {
|
|
name: string;
|
|
description: string;
|
|
path: string;
|
|
mtime: number;
|
|
}
|
|
|
|
interface CachedSkill extends Skill {
|
|
body: string;
|
|
}
|
|
|
|
const cache = new Map<string, CachedSkill>();
|
|
let lastWalkedAt = 0;
|
|
|
|
// ---- Frontmatter parser ----------------------------------------------------
|
|
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
|
|
// honored; other frontmatter keys are silently ignored for forward-compat
|
|
// with the anthropics/skills upstream spec.
|
|
|
|
interface Frontmatter {
|
|
name?: string;
|
|
description?: string;
|
|
}
|
|
|
|
function stripQuotes(s: string): string {
|
|
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
|
|
return s.slice(1, -1);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function parseFrontmatter(yaml: string): Frontmatter {
|
|
const fm: Frontmatter = {};
|
|
for (const raw of yaml.split('\n')) {
|
|
const line = raw.trim();
|
|
if (line.length === 0) continue;
|
|
const colon = line.indexOf(':');
|
|
if (colon < 0) continue;
|
|
const key = line.slice(0, colon).trim();
|
|
const val = stripQuotes(line.slice(colon + 1).trim());
|
|
if (key === 'name') fm.name = val;
|
|
else if (key === 'description') fm.description = val;
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
interface ParsedSkillFile {
|
|
name: string;
|
|
description: string;
|
|
body: string;
|
|
}
|
|
|
|
function parseSkillFile(content: string): ParsedSkillFile {
|
|
const lines = content.split('\n');
|
|
let openIdx = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const t = lines[i]!.trim();
|
|
if (t === '') continue;
|
|
if (t === '---') openIdx = i;
|
|
break;
|
|
}
|
|
if (openIdx < 0) throw new Error('missing opening --- fence');
|
|
let closeIdx = -1;
|
|
for (let i = openIdx + 1; i < lines.length; i++) {
|
|
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
|
|
}
|
|
if (closeIdx < 0) throw new Error('missing closing --- fence');
|
|
|
|
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
|
|
const body = lines.slice(closeIdx + 1).join('\n');
|
|
|
|
const fm = parseFrontmatter(yamlText);
|
|
if (!fm.name) throw new Error('frontmatter missing name');
|
|
if (!fm.description) throw new Error('frontmatter missing description');
|
|
return { name: fm.name, description: fm.description, body };
|
|
}
|
|
|
|
// ---- Tree walk -------------------------------------------------------------
|
|
|
|
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
|
|
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
|
|
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
|
|
// Returns all parseable skills as-found — dedup + collision logging happens
|
|
// in ensureCache where the sort order is established.
|
|
async function walkSkills(root: string): Promise<CachedSkill[]> {
|
|
const found: CachedSkill[] = [];
|
|
let groups;
|
|
try {
|
|
groups = await fs.readdir(root, { withFileTypes: true });
|
|
} catch {
|
|
return found;
|
|
}
|
|
for (const group of groups) {
|
|
if (!group.isDirectory() || group.name.startsWith('.')) continue;
|
|
const groupPath = join(root, group.name);
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(groupPath, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
const skillFolder = join(groupPath, entry.name);
|
|
const skillFile = join(skillFolder, 'SKILL.md');
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(skillFile);
|
|
} catch {
|
|
continue; // folder without SKILL.md — silent skip
|
|
}
|
|
if (!stat.isFile()) continue;
|
|
try {
|
|
const content = await fs.readFile(skillFile, 'utf8');
|
|
const parsed = parseSkillFile(content);
|
|
found.push({
|
|
name: parsed.name,
|
|
description: parsed.description,
|
|
path: skillFolder,
|
|
mtime: stat.mtimeMs,
|
|
body: parsed.body,
|
|
});
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
console.warn(`skills: failed to parse ${skillFile} — ${reason}`);
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// ---- Cache ----------------------------------------------------------------
|
|
|
|
async function ensureCache(): Promise<void> {
|
|
const now = Date.now();
|
|
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(SKILLS_ROOT);
|
|
} catch {
|
|
cache.clear();
|
|
lastWalkedAt = now;
|
|
return;
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
cache.clear();
|
|
lastWalkedAt = now;
|
|
return;
|
|
}
|
|
const found = await walkSkills(SKILLS_ROOT);
|
|
// Sort by name asc, then path asc — gives alphabetically-first-wins on
|
|
// collision and stable, deterministic ordering for /api/skills + skill_find.
|
|
found.sort((a, b) => {
|
|
const n = a.name.localeCompare(b.name);
|
|
return n !== 0 ? n : a.path.localeCompare(b.path);
|
|
});
|
|
cache.clear();
|
|
const winnerPath = new Map<string, string>();
|
|
for (const skill of found) {
|
|
const prev = winnerPath.get(skill.name);
|
|
if (prev) {
|
|
console.warn(
|
|
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
|
|
);
|
|
continue;
|
|
}
|
|
winnerPath.set(skill.name, skill.path);
|
|
cache.set(skill.name, skill);
|
|
}
|
|
lastWalkedAt = now;
|
|
}
|
|
|
|
// ---- Public API -----------------------------------------------------------
|
|
|
|
export async function listSkills(): Promise<Skill[]> {
|
|
await ensureCache();
|
|
return Array.from(cache.values()).map((s) => ({
|
|
name: s.name,
|
|
description: s.description,
|
|
path: s.path,
|
|
mtime: s.mtime,
|
|
}));
|
|
}
|
|
|
|
export interface SkillSummary {
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
export async function findSkills(query: string): Promise<SkillSummary[]> {
|
|
await ensureCache();
|
|
const all = Array.from(cache.values());
|
|
const q = (query ?? '').trim().toLowerCase();
|
|
if (q === '' || q === '*') {
|
|
return all.map((s) => ({ name: s.name, description: s.description }));
|
|
}
|
|
// name match weighted 2x description match. No fancy ranking — substring
|
|
// scoring is enough for ≤20 skills.
|
|
const scored = all
|
|
.map((s) => {
|
|
let score = 0;
|
|
if (s.name.toLowerCase().includes(q)) score += 2;
|
|
if (s.description.toLowerCase().includes(q)) score += 1;
|
|
return { s, score };
|
|
})
|
|
.filter((x) => x.score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5);
|
|
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
|
|
}
|
|
|
|
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
|
|
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
|
|
export async function getSkillBody(name: string): Promise<string | null> {
|
|
await ensureCache();
|
|
const cached = cache.get(name);
|
|
if (!cached) return null;
|
|
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(join(cached.path, 'SKILL.md'));
|
|
} catch {
|
|
cache.delete(name);
|
|
return null;
|
|
}
|
|
if (stat.mtimeMs === cached.mtime) return cached.body;
|
|
try {
|
|
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
|
|
const parsed = parseSkillFile(raw);
|
|
if (parsed.name !== name) {
|
|
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
|
|
cache.delete(name);
|
|
return null;
|
|
}
|
|
cached.body = parsed.body;
|
|
cached.description = parsed.description;
|
|
cached.mtime = stat.mtimeMs;
|
|
return cached.body;
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
console.warn(`skills: re-parse failed for ${name} — ${reason}`);
|
|
cache.delete(name);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
|
|
|
|
export type SkillResourceResult =
|
|
| { ok: true; content: string }
|
|
| { ok: false; code: SkillResourceErrorCode; message: string };
|
|
|
|
export async function getSkillResource(
|
|
name: string,
|
|
relativePath: string,
|
|
): Promise<SkillResourceResult> {
|
|
await ensureCache();
|
|
const cached = cache.get(name);
|
|
if (!cached) {
|
|
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
|
|
}
|
|
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
|
|
return { ok: false, code: 'unknown_resource', message: 'path is required' };
|
|
}
|
|
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
|
|
// before realpath dereferences any symlinks.
|
|
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
|
|
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
|
|
}
|
|
// SKILL.md is the manifest — skill_use is the right tool to read it.
|
|
if (basename(relativePath) === 'SKILL.md') {
|
|
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
|
|
}
|
|
let real: string;
|
|
try {
|
|
real = await pathGuard(cached.path, relativePath);
|
|
} catch (err) {
|
|
if (err instanceof PathScopeError) {
|
|
const code: SkillResourceErrorCode = err.message.includes('escapes')
|
|
? 'path_escape'
|
|
: 'unknown_resource';
|
|
return { ok: false, code, message: err.message };
|
|
}
|
|
throw err;
|
|
}
|
|
const stat = await fs.stat(real);
|
|
if (!stat.isFile()) {
|
|
return { ok: false, code: 'unknown_resource', message: 'not a file' };
|
|
}
|
|
if (stat.size > MAX_RESOURCE_BYTES) {
|
|
return {
|
|
ok: false,
|
|
code: 'unknown_resource',
|
|
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
|
|
};
|
|
}
|
|
const content = await fs.readFile(real, 'utf8');
|
|
return { ok: true, content };
|
|
}
|