@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
363 lines • 14 kB
JavaScript
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