UNPKG

@brianlovin/notion-skills

Version:

Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.

363 lines 14 kB
import { createHash } from "node:crypto"; import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { parse as yamlParse } from "yaml"; import { slugify, splitToolsRespectingParens } from "./convert.js"; import { KNOWN_TARGETS } from "./known-targets.js"; import { SKILLS_STORE } from "./paths.js"; export async function discoverSkills(opts) { const centralStore = opts.centralStore ?? SKILLS_STORE; // Pass 1: walk every source dir and produce one classification per // realpath we haven't already seen. Same-realpath dedup handles symlinks // pointing at the same target. We also dedup managed entries by name — // a single skill symlinked from N target dirs + the real entry in the // central store is N+1 hits, but only one entry's worth of meaning. const out = []; const seenRealpaths = new Set(); const seenManagedNames = new Set(); for (const dir of opts.sourceDirs) { if (!existsSync(dir)) continue; let entries; try { entries = readdirSync(dir); } catch { continue; } for (const entry of entries) { if (entry.startsWith(".")) continue; const sourceDisplay = join(dir, entry); let realpath; try { realpath = realpathSync(sourceDisplay); } catch { out.push({ kind: "invalid", sourceDisplay, reason: "broken symlink" }); continue; } const inCentralStore = realpath.startsWith(centralStore + "/") || realpath === centralStore; const sourceInCentralStore = sourceDisplay.startsWith(centralStore + "/") || sourceDisplay === centralStore; // Symlink from a target dir into central store → already synced // (managed). The actual entry sitting in the central store is // handled by the central-store branch below. if (inCentralStore && !sourceInCentralStore) { if (!seenManagedNames.has(entry)) { seenManagedNames.add(entry); out.push({ kind: "managed", sourceDisplay, name: entry }); } continue; } // Real entry inside the central store. If it's tracked by the // manifest, it was synced from Notion — managed. Otherwise treat // as a local-only skill that's a candidate for upload. if (sourceInCentralStore && opts.trackedNames?.has(entry)) { if (!seenManagedNames.has(entry)) { seenManagedNames.add(entry); out.push({ kind: "managed", sourceDisplay, name: entry }); } continue; } // Dedup by realpath: same skill symlinked from multiple targets. if (seenRealpaths.has(realpath)) continue; seenRealpaths.add(realpath); try { if (!lstatSync(realpath).isDirectory()) { out.push({ kind: "invalid", sourceDisplay, reason: "not a directory" }); continue; } } catch { out.push({ kind: "invalid", sourceDisplay, reason: "stat failed" }); continue; } const skillMdPath = join(realpath, "SKILL.md"); if (!existsSync(skillMdPath)) { out.push({ kind: "invalid", sourceDisplay, reason: "no SKILL.md" }); continue; } const parsed = await parseSkillFile(skillMdPath, realpath, sourceDisplay, entry); if ("error" in parsed) { out.push({ kind: "invalid", sourceDisplay, reason: parsed.error }); } else { out.push({ kind: "new", skill: parsed.skill }); } } } // Pass 2: collapse any "new" classifications that share the same slug. // // Two scenarios: // - Same content (hash equal) → collapse to one canonical, record other // paths in additionalSources. Migrate will back them ALL up. // - Different content → collapse to one canonical, record losers in // conflictingSources for a warning + backup. // // Canonical pick is by KNOWN_TARGETS order (claude beats codex etc.) so // the choice is deterministic and matches user intuition: "the one your // primary agent reads from wins". return collapseDuplicateSlugs(out); } function collapseDuplicateSlugs(input) { const news = input.filter((c) => c.kind === "new"); const others = input.filter((c) => c.kind !== "new"); const groups = new Map(); for (const c of news) { const list = groups.get(c.skill.name) ?? []; list.push(c.skill); groups.set(c.skill.name, list); } const merged = [...others]; for (const [, group] of groups) { if (group.length === 1) { merged.push({ kind: "new", skill: group[0] }); continue; } // Sort by KNOWN_TARGETS priority — earlier in the registry wins. const ordered = [...group].sort((a, b) => priorityOf(a.source) - priorityOf(b.source)); const canonical = ordered[0]; const canonicalHash = hashBody(canonical.body); const sameContent = []; const differentContent = []; for (const s of ordered.slice(1)) { if (hashBody(s.body) === canonicalHash) sameContent.push(s); else differentContent.push(s); } canonical.additionalSources = sameContent.map((s) => s.source); canonical.additionalSourceDisplays = sameContent.map((s) => s.sourceDisplay); if (differentContent.length > 0) { canonical.conflictingSources = differentContent.map((s) => s.source); canonical.conflictingSourceDisplays = differentContent.map((s) => s.sourceDisplay); } merged.push({ kind: "new", skill: canonical }); } return merged; } function priorityOf(realpath) { for (let i = 0; i < KNOWN_TARGETS.length; i++) { if (realpath.startsWith(KNOWN_TARGETS[i].dir)) return i; } return KNOWN_TARGETS.length; // unknown source dirs sort last } function hashBody(body) { return createHash("sha256").update(body).digest("hex"); } export async function parseSkillFile(skillMdPath, sourceRealpath, sourceDisplay, fallbackName) { let raw; try { raw = await readFile(skillMdPath, "utf8"); } catch (err) { return { error: `read failed: ${err.message}` }; } const fm = extractFrontmatter(raw); if (!fm) { return { error: "no frontmatter — expected `---` delimited YAML at top of SKILL.md", }; } let parsedFm; try { parsedFm = (yamlParse(fm.text) ?? {}); } catch (err) { return { error: `frontmatter YAML parse error: ${err.message}` }; } const titleRaw = parsedFm.name ?? fallbackName; const description = String(parsedFm.description ?? "").trim(); if (!description) { return { error: "frontmatter has no `description`" }; } const body = stripLeadingTitle(fm.body, titleRaw).trim(); const slug = slugify(titleRaw); if (!slug) return { error: "could not derive a valid slug from name" }; const properties = { name: slug, description, // core (Agent Skills spec) license: optionalString(parsedFm.license), compatibility: optionalString(parsedFm.compatibility), "allowed-tools": optionalToolsList(parsedFm["allowed-tools"]), // claude when_to_use: optionalString(parsedFm.when_to_use), "argument-hint": optionalString(parsedFm["argument-hint"]), arguments: optionalList(parsedFm.arguments, /\s+/), paths: optionalList(parsedFm.paths, /\s*,\s*/), "disable-model-invocation": optionalBoolString(parsedFm["disable-model-invocation"]), "user-invocable": optionalBoolString(parsedFm["user-invocable"]), model: optionalString(parsedFm.model), effort: optionalString(parsedFm.effort), context: optionalString(parsedFm.context), agent: optionalString(parsedFm.agent), shell: optionalString(parsedFm.shell), // notion tags: optionalList(parsedFm.tags, /\s*,\s*/), // spec extension point: arbitrary metadata surfaces back to matching // Notion columns on push (silently skipped if no column exists). metadata: optionalMetadata(parsedFm.metadata), }; return { skill: { name: slug, title: String(titleRaw), description, body, source: sourceRealpath, sourceDisplay, properties, }, }; } function optionalString(v) { if (v === undefined || v === null) return undefined; const s = String(v).trim(); return s === "" ? undefined : s; } function optionalMetadata(v) { if (v === undefined || v === null) return undefined; if (typeof v !== "object" || Array.isArray(v)) return undefined; const obj = v; if (Object.keys(obj).length === 0) return undefined; return obj; } function optionalBoolString(v) { if (v === undefined || v === null) return undefined; if (typeof v === "boolean") return v ? "true" : "false"; const s = String(v).trim().toLowerCase(); if (s === "true" || s === "false") return s; return undefined; } function optionalList(v, splitOn) { if (v === undefined || v === null) return undefined; if (Array.isArray(v)) { const items = v.map((x) => String(x).trim()).filter(Boolean); return items.length === 0 ? undefined : items; } const s = String(v).trim(); if (!s) return undefined; const items = s.split(splitOn).map((x) => x.trim()).filter(Boolean); return items.length === 0 ? undefined : items; } /** * Like optionalList but uses a paren-aware splitter for the * `allowed-tools` field, where tools like `Bash(git *)` contain spaces. */ function optionalToolsList(v) { if (v === undefined || v === null) return undefined; if (Array.isArray(v)) { const items = v.map((x) => String(x).trim()).filter(Boolean); return items.length === 0 ? undefined : items; } const s = String(v).trim(); if (!s) return undefined; const items = splitToolsRespectingParens(s); return items.length === 0 ? undefined : items; } function extractFrontmatter(raw) { // Allow optional BOM and leading whitespace before the opening `---`. const normalized = raw.replace(/^/, ""); if (!normalized.startsWith("---")) return null; // Find the matching closing delimiter on its own line. const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return null; return { text: match[1] ?? "", body: match[2] ?? "" }; } /** * If the body's first non-blank line is `# <Title>` matching the skill name, * drop it. ntn strips the first H1 server-side when we set page content, so * this avoids the round-trip writing-then-losing the H1. */ function stripLeadingTitle(body, title) { const lines = body.split("\n"); let i = 0; while (i < lines.length && lines[i].trim() === "") i++; if (i >= lines.length) return body; const first = lines[i].trim(); const m = first.match(/^#\s+(.+)$/); if (!m) return body; // Drop if the H1 text roughly matches the title (case-insensitive, slug-equal). const h1 = m[1].trim(); if (h1.toLowerCase() === title.toLowerCase() || slugify(h1) === slugify(title)) { lines.splice(i, 1); // Also eat one blank line after the heading. if (i < lines.length && lines[i].trim() === "") lines.splice(i, 1); return lines.join("\n"); } return body; } // ---------- conflict detection ---------- /** * Given a set of new skills and a map of name → page_id from the live data * source, mark conflicts. Mutates and returns the input list. */ export function markConflicts(classifications, existingPagesByName) { return classifications.map((c) => { if (c.kind !== "new") return c; const existing = existingPagesByName.get(c.skill.name); if (existing) { return { kind: "conflict", skill: c.skill, existingPageId: existing.pageId, existingTitle: existing.title, }; } return c; }); } // ---------- safety check ---------- /** * True iff the source path lives inside one of the configured scope target * dirs (e.g. ~/.claude/skills). Migration may safely move such sources to * a backup because the user expects skills there to be replaced by symlinks. * * Sources outside the scope (--from paths, symlinks reaching into a separate * repo) must never be moved — that would silently delete the user's * authoritative content. */ export function sourceIsInScope(source, scopeTargetDirs) { return scopeTargetDirs.some((d) => { const normalized = d.endsWith("/") ? d : d + "/"; return source === d || source.startsWith(normalized); }); } // ---------- source resolution ---------- /** * Resolve which dirs to scan: the scope's configured target dirs plus any * extras from `--from` flags. Returned list is deduped. */ export function resolveSourceDirs(options) { const dirs = [...options.targetDirs]; for (const extra of options.extras ?? []) { dirs.push(resolve(extra)); } return [...new Set(dirs)]; } //# sourceMappingURL=migrate.js.map