UNPKG

@brianlovin/notion-skills

Version:

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

586 lines 26.7 kB
import chalk from "chalk"; import { lstatSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { checkbox, confirm } from "@inquirer/prompts"; import { getScope } from "../scope.js"; import { SKILLS_STORE } from "../paths.js"; import { loadManifest } from "../manifest.js"; import { ensureSymlink, targetSkillPath, targetsForKeys } from "../targets.js"; import { migrateCommand } from "./migrate.js"; import { fetchFileContent, fetchRepoTree, formatSourceRef, parseGitHubSource, resolveDefaultBranch, } from "../github.js"; import { auditSkill, loadAuditTarget, summariseIssues } from "../audit.js"; import { pickSource } from "./_resolve.js"; import { injectMetadataKey, parseFrontmatter } from "../frontmatter.js"; import { classifyExtension } from "../skill-files.js"; import { withSpinner } from "./_progress.js"; /** * Pull a public skill from a GitHub repo into the central store as a * local draft. Mirrors skills.sh syntax for cross-ecosystem * familiarity but lands the skill in notion-skills' world: the user * can review locally, then `publish` to a configured Notion source * (or pass `--publish` to chain straight through). */ export async function addCommand(refs, opts = {}) { const scope = await getScope(); if (!scope) { throw new Error("No scope configured. Run `notion-skills init` first."); } if (refs.length !== 1) { throw new Error("Usage: notion-skills add <ref> (e.g. `vercel-labs/agent-skills`)"); } const source = parseGitHubSource(refs[0]); // Resolve the ref. If the user pinned, honour it; otherwise probe // for the repo's default branch (main/master/canonical fallback). // resolveDefaultBranch may opportunistically return the tree it // already had to fetch in the process — we reuse it to avoid a // second roundtrip. let ref; let tree; if (source.ref !== undefined) { ref = source.ref; tree = null; } else { const resolved = await resolveDefaultBranch(source); if (!resolved) { throw new Error(`Couldn't reach ${source.owner}/${source.repo}. Check the spelling, or set GITHUB_TOKEN if it's private.`); } ref = resolved.ref; tree = resolved.cachedTree; } if (!tree) { tree = await withSpinner(`Fetching ${formatSourceRef({ ...source, ref })}`, () => fetchRepoTree(source, ref)); } if (tree.truncated) { console.log(chalk.yellow("⚠ GitHub returned a truncated tree (>100k entries). Some skills may not appear.")); } const candidates = discoverSkillCandidates(tree.entries, source); if (candidates.length === 0) { throw new Error(`No skills found in ${formatSourceRef({ ...source, ref })}. Looked for SKILL.md at the root and under skills/<name>/.`); } // Hydrate frontmatter on every candidate so we can filter, display, // and slug from the canonical `name` field. Filtering against the // dir name alone misses cases where dir != frontmatter name (e.g. // anthropics/skills/template/ has `name: template-skill` inside). // For repos with hundreds of skills this means many parallel fetches // up front, but `add` is interactive and the cost is bounded. const hydratedAll = await withSpinner(`Reading ${candidates.length} ${candidates.length === 1 ? "skill" : "skills"}`, () => hydrateCandidates(source, ref, candidates)); const skillNames = collectSkillFilter(opts, source); const hydrated = filterHydratedByName(hydratedAll, skillNames); if (hydrated.length === 0) { const known = hydratedAll.map((c) => c.frontmatterName ?? c.skillName).sort().join(", "); throw new Error(`No matches for ${skillNames.join(", ")}. Available: ${known}`); } if (opts.preview) { await renderPreview(hydrated, source, ref); return; } const picked = await pickSkillsForAdd(hydrated, opts, formatSourceRef({ ...source, ref })); if (picked.length === 0) { console.log(chalk.dim("Nothing to add.")); return; } if (picked.length > 1 && opts.as) { throw new Error("--as only applies when adding exactly one skill."); } const manifest = await loadManifest(scope.sources); // Classify every picked candidate up front. The user sees the full // plan in a summary and confirms once; we never surprise them // skill-by-skill. const plan = buildAddPlan(picked, source, manifest, opts); const sourceRef = formatSourceRef({ ...source, ref }); renderAddPlan(plan, sourceRef); // Two failure modes from confirmAddPlan: // - "nothing to do": plan reduced to zero work (e.g. --skip-existing // with all collisions). That's a success — exit clean. // - "user declined": they pressed n at the prompt. Print Aborted. const willActOn = plan.filter((p) => p.action !== "skip").length; if (willActOn === 0) { console.log(chalk.dim("Nothing to add.")); return; } if (!(await confirmAddPlan(plan, opts))) { console.log(chalk.dim("Aborted.")); return; } const addedSlugs = []; const failed = []; for (const item of plan) { if (item.action === "skip") continue; try { await materialiseSkill(source, ref, item.hydrated, item.proposedSlug); await fanoutSymlinks(item.proposedSlug, scope.targets); addedSlugs.push(item.proposedSlug); const note = renderActionNote(item); console.log(` ${chalk.green("+")} ${item.proposedSlug}${note}`); } catch (err) { const partial = join(SKILLS_STORE, item.proposedSlug); try { await rm(partial, { recursive: true, force: true }); } catch { // best-effort cleanup } const reason = err.message.split("\n")[0] ?? "unknown error"; failed.push({ name: item.proposedSlug, reason }); console.log(` ${chalk.red("✗")} ${item.proposedSlug} ${chalk.dim(`(${reason})`)}`); } } if (failed.length > 0) { console.log(""); const total = plan.filter((p) => p.action !== "skip").length; console.log(chalk.yellow(`${failed.length} of ${total} ${total === 1 ? "skill" : "skills"} failed. Re-run with the same source to retry.`)); } if (addedSlugs.length === 0) return; // Audit each added draft and surface counts inline. Errors don't // block — the draft is on disk and re-runnable; we just inform. const auditCounts = await runAuditSummary(addedSlugs); if (opts.publish) { // Publish gate: errors block. Draft stays on disk so the user // can fix in place (`open <slug> --local`) and `publish <slug>` // when ready. This matches the principle that destructive / // irrecoverable operations (pushing to a shared Notion store) // should require a clean skill. if (auditCounts.errors > 0) { const slugs = auditCounts.slugsWithErrors.join(", "); console.log(""); console.log(chalk.red(`✗ Refusing to publish: ${auditCounts.errors} audit ${auditCounts.errors === 1 ? "error" : "errors"} in ${slugs}.`)); console.log(chalk.dim(` Fix with \`notion-skills audit ${auditCounts.slugsWithErrors[0]}\` (or open + edit), then \`notion-skills publish <slug>\`.`)); return; } const target = await pickSource(opts.source, scope); console.log(""); console.log(chalk.dim(`Publishing ${addedSlugs.length} ${addedSlugs.length === 1 ? "skill" : "skills"} to "${target.name}"…`)); await migrateCommand({ yes: true, only: addedSlugs, source: target.key }); } else { console.log(""); if (addedSlugs.length === 1) { console.log(chalk.dim("→ run ") + chalk.bold(`notion-skills publish ${addedSlugs[0]}`) + chalk.dim(" to share with your team.")); } else { console.log(chalk.dim("→ run ") + chalk.bold(`notion-skills publish --all`) + chalk.dim(" to share with your team.")); } } } /** * Find SKILL.md occurrences in the tree. Two layouts are supported: * - `SKILL.md` at the (sub)root → single-skill repo * - `skills/<name>/SKILL.md` → multi-skill repo (also `<subpath>/<name>/SKILL.md`) */ function discoverSkillCandidates(entries, source) { const root = source.subpath ? source.subpath.replace(/\/+$/, "") : ""; const prefix = root ? `${root}/` : ""; const skillMdPaths = []; for (const entry of entries) { if (entry.type !== "blob") continue; if (!entry.path.endsWith("/SKILL.md") && entry.path !== "SKILL.md") continue; // Subpath-scoped: only include SKILL.md files at or under the // resolved subpath. The "<subpath>/SKILL.md" case is the user // pointing directly at a single skill via tree URL. if (root && !entry.path.startsWith(prefix) && entry.path !== `${root}/SKILL.md`) continue; if (root && entry.path === "SKILL.md") continue; skillMdPaths.push(entry.path); } const out = []; for (const skillMdPath of skillMdPaths) { const skillDir = skillMdPath === "SKILL.md" ? "" : dirname(skillMdPath); const skillName = deriveSkillName(skillDir, root, source.repo); const siblings = entries.filter((e) => isSibling(e, skillDir)); out.push({ skillMdPath, skillDir, skillName, siblingFiles: siblings }); } // De-dupe by skill name; longer skillDir wins (more specific). const dedup = new Map(); for (const c of out) { const existing = dedup.get(c.skillName); if (!existing || c.skillDir.length > existing.skillDir.length) { dedup.set(c.skillName, c); } } return [...dedup.values()].sort((a, b) => a.skillName.localeCompare(b.skillName)); } function deriveSkillName(skillDir, root, repoName) { // Root-of-repo SKILL.md → use the repo name as the skill name. // Skill nested under a (sub)root → use its dir's last segment. if (skillDir === "" || skillDir === root) { // For a subpath like "skills/foo" pointing directly at a skill, // skillDir === root. The leaf segment is the skill name. if (root && skillDir === root) { return root.split("/").pop() || repoName; } return repoName; } return skillDir.split("/").pop() || skillDir; } function isSibling(entry, skillDir) { if (entry.type !== "blob") return false; const base = skillDir === "" ? "" : `${skillDir}/`; if (!entry.path.startsWith(base)) return false; const rel = entry.path.slice(base.length); if (rel === "SKILL.md") return false; if (rel.length === 0) return false; return true; } // ---------- filtering + hydration ---------- function collectSkillFilter(opts, source) { const fromFlag = opts.skill ?? []; const fromSource = source.skillFilter ? [source.skillFilter] : []; return [...new Set([...fromFlag, ...fromSource])]; } function filterHydratedByName(hydrated, wanted) { if (wanted.length === 0) return hydrated; const wantedLower = wanted.map((s) => s.toLowerCase()); return hydrated.filter((c) => { const fm = c.frontmatterName?.toLowerCase(); const dir = c.skillName.toLowerCase(); const subdir = c.skillDir.toLowerCase(); return wantedLower.some((w) => w === "*" || w === fm || w === dir || w === subdir); }); } async function hydrateCandidates(source, ref, candidates) { // Parallelise the SKILL.md fetches — each is an independent HTTP // call to raw.githubusercontent and the network is the bottleneck. // For an 18-skill repo this drops the wall time from ~15s to ~1s. const results = await Promise.all(candidates.map(async (c) => { const raw = await fetchFileContent(source, ref, c.skillMdPath); if (raw === null) return null; const fm = readFrontmatter(raw); return { ...c, frontmatterName: typeof fm["name"] === "string" ? fm["name"] : null, description: typeof fm["description"] === "string" ? fm["description"] : null, rawSkillMd: raw, }; })); return results.filter((r) => r !== null); } function readFrontmatter(text) { return parseFrontmatter(text).frontmatter; } // ---------- preview ---------- async function renderPreview(skills, source, ref) { console.log(chalk.bold(`\n${formatSourceRef({ ...source, ref })}`) + chalk.dim(` — ${skills.length} ${skills.length === 1 ? "skill" : "skills"}:`)); console.log(""); if (skills.length === 1) { const s = skills[0]; console.log(chalk.bold(s.frontmatterName ?? s.skillName)); if (s.description) console.log(chalk.dim(s.description)); console.log(""); console.log(chalk.dim(`Path: ${s.skillMdPath}`)); console.log(chalk.dim(`Siblings: ${s.siblingFiles.length}`)); console.log(""); console.log(s.rawSkillMd.trim()); return; } const maxName = Math.max(...skills.map((s) => (s.frontmatterName ?? s.skillName).length)); const namePad = Math.min(40, Math.max(maxName + 2, 12)); for (const s of skills) { const name = (s.frontmatterName ?? s.skillName).padEnd(namePad); const desc = oneLine(s.description ?? ""); console.log(` ${chalk.bold(name)} ${chalk.dim(truncate(desc, 80))}`); } console.log(""); console.log(chalk.dim("→ run ") + chalk.bold(`notion-skills add ${formatSourceRef({ ...source, ref })} --skill <name>`) + chalk.dim(" to add a specific one.")); } // ---------- picker ---------- async function pickSkillsForAdd(skills, opts, sourceRef) { if (skills.length === 1) return skills; if (opts.yes) return skills; // bulk-confirm = "all" // Non-TTY refuses to pick silently for multi-skill repos. Auto- // adding 18 skills under a script's nose is the kind of surprise // that kicks off Slack threads. Force the operator to be explicit: // pick a single skill via @<name>/--skill, or pass --yes to claim // "all of them". if (!process.stdin.isTTY) { const names = skills.map((s) => s.frontmatterName ?? s.skillName).join(", "); throw new Error([ `${skills.length} skills found in this repo and stdin isn't a TTY.`, ` → pass \`--skill <name>\` (or \`@<name>\` in the source) to add a specific one`, ` → or pass \`--yes\` to add all of them: ${names}`, ].join("\n")); } // Inquirer's checkbox already handles 'a' (toggle-all) and 'i' // (invert) — we just have to mention them in the help text so // users discover the shortcuts. With every skill pre-checked, the // common "I want all of them" path is just <enter>; the "I want // just two" path is 'a' (deselect all) then <space> the picks. const cols = process.stdout.columns ?? 100; const longestName = Math.max(...skills.map((s) => (s.frontmatterName ?? s.skillName).length)); const namePad = Math.min(40, longestName + 2); const descMax = Math.max(20, cols - namePad - 12); const choices = skills.map((s) => { const name = (s.frontmatterName ?? s.skillName).padEnd(namePad); const desc = s.description ? chalk.dim(` ${truncate(oneLine(s.description), descMax)}`) : ""; return { name: `${name}${desc}`, value: s, checked: true }; }); return (await checkbox({ message: `Skills from ${chalk.bold(sourceRef)} ${chalk.dim(`(${skills.length} found, all selected)`)}`, instructions: chalk.dim(" ↑↓ navigate · <space> toggle · 'a' all/none · 'i' invert · <enter> confirm · ^C cancel"), choices, pageSize: Math.min(25, choices.length + 2), theme: { // Show the help line on every render — by default inquirer // only shows it on first paint, and the shortcuts are exactly // what we want users to discover as they navigate. helpMode: "always", }, })); } // ---------- materialise ---------- /** * Classify every picked candidate into an explicit action — never * silently destructive. The default for a colliding skill is "rename" * (auto-namespace, both versions kept). Users opt out via: * --skip-existing → drop the colliding ones, install only news * uninstall + add → the explicit two-step for "replace existing" * * `--as` is a single-skill override: if it doesn't collide, use it. * If it DOES collide, we error rather than fall back — the user * explicitly named the skill, and silently producing a different * name (`<owner>-<source-slug>`) would be surprising. */ function buildAddPlan(picked, source, manifest, opts) { const norm = (s) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); const inFlight = new Set(); const taken = (slug) => { if (inFlight.has(slug)) return true; if (manifest?.skills[slug]) return true; const dir = join(SKILLS_STORE, slug); try { lstatSync(dir); return true; } catch { return false; } }; const plan = []; for (const c of picked) { const baseSlug = norm(c.frontmatterName ?? c.skillName); const override = picked.length === 1 ? opts.as : undefined; if (override) { // `--as` is the user's explicit name. We honour it OR refuse — // never silently pick a different one. Pass through the same // normalisation as auto-derived slugs so case-insensitive // filesystems don't see `Foo` and `foo` as different. const normedOverride = norm(override); if (taken(normedOverride)) { throw new Error(`--as "${normedOverride}" is already taken on this machine. Pick a different name, or run \`notion-skills uninstall ${normedOverride}\` first.`); } inFlight.add(normedOverride); plan.push({ hydrated: c, action: "install-new", proposedSlug: normedOverride }); continue; } const desiredSlug = baseSlug; if (!taken(desiredSlug)) { inFlight.add(desiredSlug); plan.push({ hydrated: c, action: "install-new", proposedSlug: desiredSlug }); continue; } // Collision. --skip-existing drops it; otherwise default to rename. if (opts.skipExisting) { plan.push({ hydrated: c, action: "skip", proposedSlug: desiredSlug, conflictWith: desiredSlug }); continue; } const namespaced = norm(`${source.owner}-${baseSlug}`); const renamed = !taken(namespaced) ? namespaced : appendUntilFree(namespaced, taken); inFlight.add(renamed); plan.push({ hydrated: c, action: "rename", proposedSlug: renamed, conflictWith: desiredSlug }); } return plan; } function appendUntilFree(base, taken) { for (let i = 2; i < 1000; i++) { const candidate = `${base}-${i}`; if (!taken(candidate)) return candidate; } return `${base}-${Date.now().toString(36)}`; } /** * Print the plan summary so the user sees what's about to happen * before we touch any files. Quiet when there are no collisions — * a flat add of new skills doesn't need ceremony. */ /** * Render the pre-flight plan, but only when there's something * non-trivial to surface. A flat add of new skills doesn't need a * summary header — the per-skill `+ slug` lines after confirm are * the result, and a "+ N new" summary on top is just noise. */ function renderAddPlan(plan, sourceRef) { const renames = plan.filter((p) => p.action === "rename"); const skips = plan.filter((p) => p.action === "skip"); const hasCollisions = renames.length > 0 || skips.length > 0; if (!hasCollisions) return; console.log(""); console.log(chalk.bold(`Adding from ${sourceRef}:`)); if (renames.length > 0) { console.log(chalk.yellow(` ⚠ ${renames.length} ${renames.length === 1 ? "collides" : "collide"} with existing — will be renamed:`)); const widest = Math.max(...renames.map((r) => (r.conflictWith ?? "").length)); for (const r of renames) { console.log(` ${chalk.dim(r.conflictWith?.padEnd(widest) ?? "")}${r.proposedSlug}`); } } if (skips.length > 0) { console.log(chalk.dim(` ⊘ ${skips.length} skipped (already exists): ${skips.map((s) => s.conflictWith).join(", ")}`)); } // Hint only on the default rename path. If --skip-existing was // given, no hint needed (they already made a choice). if (renames.length > 0 && skips.length === 0) { console.log(chalk.dim(" (use --skip-existing to drop collisions, or `uninstall <slug>` then add to refresh in place)")); } } async function confirmAddPlan(plan, opts) { const renames = plan.some((p) => p.action === "rename"); const needsConfirm = renames; if (opts.yes) return true; // No collisions + --yes-eligible flat add → no need to nag. if (!needsConfirm) return true; if (!process.stdin.isTTY) { // Non-TTY without --yes when collisions exist: refuse rather // than proceed with a surprising rename no one signed off on. throw new Error([ "Collisions detected and stdin isn't a TTY. Choose explicitly:", " --yes accept the rename plan above", " --skip-existing drop collisions, install only the new ones", " uninstall + add explicit two-step to refresh in place", ].join("\n")); } return await confirm({ message: "Continue?", default: true }); } function renderActionNote(item) { if (item.action === "rename") { return chalk.yellow(` (was ${item.conflictWith} — kept alongside existing)`); } return ""; } async function materialiseSkill(source, ref, skill, localSlug) { const dir = join(SKILLS_STORE, localSlug); await mkdir(dir, { recursive: true }); // Inject metadata.origin so the SKILL.md carries provenance forward // through publish + sync. Round-trips as a Notion column once // published (per existing metadata round-trip mechanics) so // teammates see "this is from <owner>/<repo>" without us inventing // a new schema property. const originRef = formatSourceRef({ ...source, ref }); const transformed = injectOriginMetadata(skill.rawSkillMd, originRef); await writeFile(join(dir, "SKILL.md"), transformed, "utf8"); // Write every sibling file at its repo-relative path. We strip the // `<skillDir>/` prefix so relative paths land correctly under the // local skill dir. // // Skip files whose extension we don't recognise as markdown or // source code: fetching a PDF / PNG / .docx / etc. via raw.github // returns the bytes as a UTF-8 string, which would corrupt the // file when we write back. Same classifier publish uses to // round-trip multi-file skills, so what we accept here matches // what we can later push to Notion. for (const sibling of skill.siblingFiles) { const rel = skill.skillDir === "" ? sibling.path : sibling.path.slice(skill.skillDir.length + 1); if (rel.startsWith("..") || rel.length === 0) continue; const cls = classifyExtension(rel); if (cls.kind === "unsupported") { console.log(chalk.yellow(` ⚠ ${rel}: unsupported file type — skipped (binary content would corrupt on text fetch).`)); continue; } const dest = join(dir, rel); await mkdir(dirname(dest), { recursive: true }); const content = await fetchFileContent(source, ref, sibling.path); if (content === null) { console.log(chalk.yellow(` ⚠ ${rel}: not fetched (404). Skill may be incomplete.`)); continue; } await writeFile(dest, content, "utf8"); } } /** * Inject `metadata.Origin = "<source ref>"` so the SKILL.md carries * provenance forward through publish (where it round-trips as a * Notion column). Delegates to the shared frontmatter helper, which * preserves the user's hand-authored `Origin`/`origin` if present. */ function injectOriginMetadata(text, originRef) { return injectMetadataKey(text, "Origin", originRef); } async function fanoutSymlinks(localSlug, targetKeys) { const targets = targetsForKeys(targetKeys); const real = join(SKILLS_STORE, localSlug); for (const t of targets) { await ensureSymlink(real, targetSkillPath(t, localSlug)); } } async function runAuditSummary(localSlugs) { let any = false; const totals = { errors: 0, warnings: 0, slugsWithErrors: [] }; for (const slug of localSlugs) { const target = await loadAuditTarget(slug, join(SKILLS_STORE, slug)); if (!target) continue; const issues = auditSkill(target); const s = summariseIssues(issues); totals.errors += s.errors; totals.warnings += s.warnings; if (s.errors > 0) totals.slugsWithErrors.push(slug); // Suppress info-only audits — most public skills lack the // "Use when…" trigger phrasing (a notion-skills convention, // not a spec requirement), so info-only would fire on every // import. Warnings + errors actually need attention. if (s.errors === 0 && s.warnings === 0) continue; if (!any) { console.log(""); any = true; } const tag = s.errors > 0 ? chalk.red(`✗ ${slug}`) : chalk.yellow(`⚠ ${slug}`); const counts = [ s.errors > 0 ? `${s.errors} ${s.errors === 1 ? "error" : "errors"}` : "", s.warnings > 0 ? `${s.warnings} ${s.warnings === 1 ? "warning" : "warnings"}` : "", ] .filter(Boolean) .join(", "); console.log(` ${tag} ${chalk.dim(`(${counts} — run \`notion-skills audit ${slug}\`)`)}`); } return totals; } // ---------- shared mini-helpers ---------- function oneLine(s) { return s.replace(/\s+/g, " ").trim(); } function truncate(s, max) { if (s.length <= max) return s; return s.slice(0, max - 3).trimEnd() + "..."; } //# sourceMappingURL=add.js.map