feat: add agent skills tab and local dev helpers

This commit is contained in:
Dotta
2026-03-17 09:10:40 -05:00
parent 5a9a4170e8
commit 4323d4bbda
6 changed files with 221 additions and 6 deletions

View File

@@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null {
if (
normalized !== "paperclip" &&
normalized !== "paperclip-create-agent" &&
normalized !== "paperclip-create-plugin" &&
normalized !== "para-memory-files"
)
return null;
@@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null {
return null;
}
/** Resolve the Paperclip repo skills directory (built-in / managed skills). */
function resolvePaperclipSkillsDir(): string | null {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(moduleDir, "../../skills"), // published
path.resolve(process.cwd(), "skills"), // cwd (monorepo root)
path.resolve(moduleDir, "../../../skills"), // dev
];
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) return candidate;
} catch { /* skip */ }
}
return null;
}
/** Parse YAML frontmatter from a SKILL.md file to extract the description. */
function parseSkillFrontmatter(markdown: string): { description: string } {
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { description: "" };
const yaml = match[1];
// Extract description — handles both single-line and multi-line YAML values
const descMatch = yaml.match(
/^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m
);
if (!descMatch) return { description: "" };
const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? "";
return {
description: raw
.split("\n")
.map((l: string) => l.trim())
.filter(Boolean)
.join(" ")
.trim(),
};
}
interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}
/** Discover all available Claude Code skills from ~/.claude/skills/. */
function listAvailableSkills(): AvailableSkill[] {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const claudeSkillsDir = path.join(homeDir, ".claude", "skills");
const paperclipSkillsDir = resolvePaperclipSkillsDir();
// Build set of Paperclip-managed skill names
const paperclipSkillNames = new Set<string>();
if (paperclipSkillsDir) {
try {
for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory()) paperclipSkillNames.add(entry.name);
}
} catch { /* skip */ }
}
const skills: AvailableSkill[] = [];
try {
const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".")) continue;
const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md");
let description = "";
try {
const md = fs.readFileSync(skillMdPath, "utf8");
description = parseSkillFrontmatter(md).description;
} catch { /* no SKILL.md or unreadable */ }
skills.push({
name: entry.name,
description,
isPaperclipManaged: paperclipSkillNames.has(entry.name),
});
}
} catch { /* ~/.claude/skills/ doesn't exist */ }
skills.sort((a, b) => a.name.localeCompare(b.name));
return skills;
}
function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) {
const { claimSecretHash: _claimSecretHash, ...safe } = row;
return safe;
@@ -1610,6 +1695,10 @@ export function accessRoutes(
return { token, created, normalizedAgentMessage };
}
router.get("/skills/available", (_req, res) => {
res.json({ skills: listAvailableSkills() });
});
router.get("/skills/index", (_req, res) => {
res.json({
skills: [