// Skill catalog reader + star (copy-to-active) helper. The other // half of the catalog/active split established by #1335 PR-A — the // preset-sync writer in `server/workspace/skills-preset.ts` // populates `data/skills/catalog/preset/`, and this module is what // the UI reads from + writes through when the user ★ Stars an entry // to bring it into `.claude/skills/`. // // Why a separate module from `discovery.ts`: catalog entries are // not yet in Claude Code's discovery scope (that's the whole point // — they're not in `.claude/skills/`). Treating them as a different // shape (CatalogEntry vs Skill) keeps the type system honest about // which entries are prompt-active. The two converge once an entry // is starred: it gets copied into `.claude/skills//`, after // which `discoverSkills()` picks it up as a normal project-scope // skill on the next listing. import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { workspacePath } from "../workspace.js"; // WORKSPACE_DIRS — relative segments (e.g. "data/skills/catalog/preset"). // We deliberately do NOT use WORKSPACE_PATHS here: those are absolute // paths rooted at the live `workspacePath`, so joining one with a // caller-supplied `workspaceRoot` would silently discard `workspaceRoot` // (Node `path.join` drops everything before an absolute argument). import { WORKSPACE_DIRS } from "../paths.js"; // The slug whitelist: the slug becomes a directory name under // `.claude/skills/`, so anything that could escape (`..`, path // separators, leading dots) is forbidden. The pattern is shared with the // collections engine — one definition, no copies to keep in sync. import { SAFE_SLUG_PATTERN } from "@mulmoclaude/core/collection"; import { parseSkillFrontmatter } from "./parser.js"; import { log } from "../../system/logger/index.js"; import { listExternalCatalogEntries, readExternalCatalogDetail, starExternalCatalogEntry, type ExternalCatalogDetailResult, type ExternalStarResult, } from "./external/catalog.js"; // Catalog sources. PR-B shipped `preset` (the `mc-*` skills bundled // with the launcher). PR-C adds `external` — skills installed from // arbitrary GitHub repos (Anthropic's `skills/` collection ships as // the seed). Both sources expose the same `CatalogEntry` shape; the // star/preview endpoints branch on `source` to route the request to // the matching backing module. export type CatalogSource = "preset" | "external"; export const CATALOG_SOURCES: readonly CatalogSource[] = ["preset", "external"] as const; export function isCatalogSource(value: unknown): value is CatalogSource { return CATALOG_SOURCES.some((source) => source === value); } export interface CatalogEntry { slug: string; /** The slug doubles as the displayed name today — frontmatter has * no separate `name` field. */ name: string; description: string; source: CatalogSource; /** `/.claude/skills//` exists. UI uses this to * render "★ Starred" instead of "★ Star" and to disable the * star button on already-active entries. */ alreadyActive: boolean; /** External entries only: id of the source repo (also the directory * name under `data/skills/catalog/external/`). Needed so the UI * can group entries by repo and pass `(repoId, skillFolder)` back * to the star / preview endpoints. */ repoId?: string; /** External entries only: subdirectory name under * `//` containing the SKILL.md. `"."` * indicates a single-skill-at-root repo (the SKILL.md is directly * under the repo dir). */ skillFolder?: string; /** External entries only: source repo URL, surfaced for display. */ repoUrl?: string; } // Maps catalog source → on-disk root for the slug-keyed scan path. // External entries live under nested `///` // and aren't slug-keyed, so they take a different code path entirely // (`scanExternalEntries` → delegates to `external/catalog.ts`). The // preset branch is kept here for symmetry. function catalogDirForSource(source: "preset", workspaceRoot: string): string { if (source === "preset") { return path.join(workspaceRoot, WORKSPACE_DIRS.skillsCatalogPreset); } const exhaustive: never = source; throw new Error(`unknown catalog source: ${String(exhaustive)}`); } function activeDir(workspaceRoot: string): string { return path.join(workspaceRoot, WORKSPACE_DIRS.claudeSkills); } export interface CatalogOptions { /** Override the workspace root. Default: live `workspacePath` * (`~/mulmoclaude`). Tests point this at a `mkdtempSync` tree so * they don't touch the user's real home dir. */ workspaceRoot?: string; } async function isDirectory(absPath: string): Promise { try { const info = await stat(absPath); return info.isDirectory(); } catch { return false; } } async function readCatalogEntry(slugDir: string, safeName: string, source: "preset", workspaceRoot: string): Promise { // `slugDir` was built from a `safeSlugName`-laundered name, so // joining a fixed `"SKILL.md"` keeps the path inside the catalog // tree and stays clear of CodeQL's path-injection trace. const skillMdPath = path.join(slugDir, "SKILL.md"); let raw: string; try { raw = await readFile(skillMdPath, "utf-8"); } catch { return null; } const parsed = parseSkillFrontmatter(raw); if (!parsed) return null; const activeSlugDir = joinUnderRoot(activeDir(workspaceRoot), safeName); const alreadyActive = await isDirectory(activeSlugDir); return { slug: safeName, name: safeName, description: parsed.description, source, alreadyActive }; } async function scanCatalogSource(source: "preset", workspaceRoot: string): Promise { const dir = catalogDirForSource(source, workspaceRoot); let entries: string[]; try { entries = await readdir(dir); } catch { // ENOENT is normal — workspace may be freshly created and the // catalog dir hasn't been populated yet (the preset sync runs // first, but defensive). Return []. return []; } const results: CatalogEntry[] = []; for (const slug of entries) { if (slug.startsWith(".")) continue; // Slugs come from `readdir`, which CodeQL flags as tainted even // though the directory is launcher-managed. `safeSlugName` // applies the slug whitelist + a `path.basename` round-trip — // CodeQL's recognised path-injection sanitiser. A catalog entry // with an unexpected name is skipped rather than crashing the // listing. const safeName = safeSlugName(slug); if (safeName === null) continue; const slugDir = joinUnderRoot(dir, safeName); if (!(await isDirectory(slugDir))) continue; const entry = await readCatalogEntry(slugDir, safeName, source, workspaceRoot); if (entry) results.push(entry); } results.sort((left, right) => left.slug.localeCompare(right.slug)); return results; } async function scanExternalEntries(workspaceRoot: string): Promise { const entries = await listExternalCatalogEntries({ workspaceRoot }); return entries.map((entry) => ({ slug: entry.activeId, name: entry.activeId, description: entry.description, source: "external" as const, alreadyActive: entry.alreadyActive, repoId: entry.repoId, skillFolder: entry.skillFolder, repoUrl: entry.repoUrl, })); } export async function listCatalogEntries(opts: CatalogOptions = {}): Promise { const workspaceRoot = opts.workspaceRoot ?? workspacePath; const preset = await scanCatalogSource("preset", workspaceRoot); const external = await scanExternalEntries(workspaceRoot); return [...preset, ...external]; } export interface CatalogEntryDetail { slug: string; source: CatalogSource; description: string; /** Full SKILL.md body, post-frontmatter, with leading blank lines * trimmed. Used by: * - 📖 Preview (rendered as markdown in a modal) * - ▶ Run once (fed verbatim into a new chat as the user input). */ body: string; } export type CatalogDetailResult = { kind: "ok"; detail: CatalogEntryDetail } | { kind: "not-found"; source: CatalogSource; slug: string } | { kind: "invalid-slug"; slug: string }; /** Read one catalog entry's SKILL.md and return the description + * body. The same `safeSlugName` taint-launder used by the star * action gates the path here. * * External entries are NOT routed through this — they use * `(repoId, skillFolder)` as their primary key and go through * `readExternalCatalogDetail` instead. The route handler dispatches * on `source`. */ export async function readCatalogEntryDetail(source: "preset", slug: string, opts: CatalogOptions = {}): Promise { const safeName = safeSlugName(slug); if (safeName === null) return { kind: "invalid-slug", slug }; const workspaceRoot = opts.workspaceRoot ?? workspacePath; const slugDir = joinUnderRoot(catalogDirForSource(source, workspaceRoot), safeName); if (!(await isDirectory(slugDir))) return { kind: "not-found", source, slug: safeName }; const skillMdPath = path.join(slugDir, "SKILL.md"); let raw: string; try { raw = await readFile(skillMdPath, "utf-8"); } catch { return { kind: "not-found", source, slug: safeName }; } const parsed = parseSkillFrontmatter(raw); if (!parsed) return { kind: "not-found", source, slug: safeName }; return { kind: "ok", detail: { slug: safeName, source, description: parsed.description, body: parsed.body }, }; } /** Read an external catalog entry's SKILL.md, returned in the same * `CatalogDetailResult` shape so route handlers can dispatch without * shape-juggling. The `slug` field in the OK detail is the derived * `activeId` (same value the merged listing emits). */ export async function readExternalDetailAsCatalog(repoId: string, skillFolder: string, opts: CatalogOptions = {}): Promise { const workspaceRoot = opts.workspaceRoot ?? workspacePath; const result = await readExternalCatalogDetail(repoId, skillFolder, { workspaceRoot }); return adaptExternalDetail(result, repoId, skillFolder); } function adaptExternalDetail(result: ExternalCatalogDetailResult, repoId: string, skillFolder: string): CatalogDetailResult { if (result.kind === "ok") { return { kind: "ok", detail: { slug: result.detail.activeId, source: "external", description: result.detail.description, body: result.detail.body, }, }; } if (result.kind === "invalid-id") { return { kind: "invalid-slug", slug: `${repoId}/${skillFolder}` }; } return { kind: "not-found", source: "external", slug: `${result.repoId}/${result.skillFolder}` }; } /** Sanitise a user-supplied slug into a safe directory-name leaf. * Returns `null` for anything that fails the slug whitelist OR is * not a basename (i.e. survives `path.basename` round-trip * unchanged). Returning a `path.basename` result is the pattern * CodeQL recognises as a `js/path-injection` sanitiser — once a * slug has been passed through `path.basename`, downstream * `path.join` / `stat` / `readFile` calls are no longer flagged. * * Belt-and-suspenders on top of `SAFE_SLUG_PATTERN`: the regex * already rejects every problematic shape, but the basename * round-trip catches edge cases the regex might miss on platforms * with different separators (Windows `\\`) and lets the type * system express "this value has been laundered". */ function safeSlugName(slug: string): string | null { if (!SAFE_SLUG_PATTERN.test(slug)) return null; // `path.basename` strips anything that looks like a directory // component and is CodeQL's recognised sanitiser for // `js/path-injection`. On a slug that already passed the regex // this is an identity transform. const basename = path.basename(slug); if (basename !== slug) return null; return basename; } /** Compose a path inside `rootDir` using a `safeSlugName`-laundered * slug. The taint flow ends at `safeSlugName`, so the joined path * is no longer flagged. */ function joinUnderRoot(rootDir: string, safeName: string): string { return path.join(rootDir, safeName); } export type StarResult = | { kind: "starred"; slug: string } | { kind: "not-found"; source: CatalogSource; slug: string } | { kind: "already-active"; slug: string } | { kind: "invalid-slug"; slug: string }; async function copyDirTree(srcDir: string, destDir: string): Promise { await mkdir(destDir, { recursive: true }); const entries = await readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, entry.name); if (entry.isDirectory()) { await copyDirTree(srcPath, destPath); } else if (entry.isFile()) { await copyFile(srcPath, destPath); } // Symlinks / sockets / FIFOs are intentionally skipped — the // catalog is launcher-managed and shouldn't contain them. } } /** Copy `data/skills/catalog/preset//` → `.claude/skills//`. * Returns a discriminated result so the route can map to clean * HTTP status codes. Slug is laundered via `safeSlugName` (regex * whitelist + `path.basename` round-trip) before any `path.join`, * which is CodeQL's recognised pattern for clearing * `js/path-injection` taint. A separator-bearing or escaping slug * yields `invalid-slug` and never reaches the filesystem. * * External entries take a different code path — * `starExternalAsCatalog(repoId, skillFolder)` — because they're * keyed by `(repoId, skillFolder)` rather than slug. */ export async function starCatalogEntry(source: "preset", slug: string, opts: CatalogOptions = {}): Promise { const safeName = safeSlugName(slug); if (safeName === null) return { kind: "invalid-slug", slug }; const workspaceRoot = opts.workspaceRoot ?? workspacePath; const catalogSlugDir = joinUnderRoot(catalogDirForSource(source, workspaceRoot), safeName); const activeSlugDir = joinUnderRoot(activeDir(workspaceRoot), safeName); if (!(await isDirectory(catalogSlugDir))) return { kind: "not-found", source, slug: safeName }; if (await isDirectory(activeSlugDir)) return { kind: "already-active", slug: safeName }; await copyDirTree(catalogSlugDir, activeSlugDir); log.info("skills", "starred catalog entry", { source, slug: safeName }); return { kind: "starred", slug: safeName }; } /** Star an external catalog entry, returned in the same `StarResult` * shape so the route handler can branch on `source` without * diverging downstream. */ export async function starExternalAsCatalog(repoId: string, skillFolder: string, opts: CatalogOptions = {}): Promise { const workspaceRoot = opts.workspaceRoot ?? workspacePath; const result = await starExternalCatalogEntry(repoId, skillFolder, { workspaceRoot }); return adaptExternalStar(result, repoId, skillFolder); } function adaptExternalStar(result: ExternalStarResult, repoId: string, skillFolder: string): StarResult { if (result.kind === "starred") return { kind: "starred", slug: result.activeId }; if (result.kind === "already-active") return { kind: "already-active", slug: result.activeId }; if (result.kind === "invalid-id") return { kind: "invalid-slug", slug: `${repoId}/${skillFolder}` }; return { kind: "not-found", source: "external", slug: `${result.repoId}/${result.skillFolder}` }; }