feat: add agent skills tab and local dev helpers
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user