UNPKG

@brianlovin/notion-skills

Version:

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

256 lines 10.5 kB
import chalk from "chalk"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { confirm } from "@inquirer/prompts"; import { getScope } from "../scope.js"; import { NotionClient, readCheckbox, readMultiSelect, readTitle, } from "../notion.js"; import { assertNtnInstalled } from "../ntn.js"; import { buildSkillMarkdown, convertPageToSkill, slugify, } from "../convert.js"; import { emptyManifest, loadManifest, writeManifest, } from "../manifest.js"; import { HASH_V, hashBehaviorProperties, hashSkillContent, } from "../page-hash.js"; import { ensureSymlink, targetSkillPath, targetsForKeys, } from "../targets.js"; import { MANIFEST_FILE, SKILLS_STORE } from "../paths.js"; import { materializeFiles } from "../skill-files.js"; import { collidingSlugSet, detectSlugCollisions, } from "../slug-collisions.js"; import { startTask } from "./_progress.js"; import { parseSkillRef } from "../sources.js"; import { chooseLocalSlug } from "../resolvers.js"; import { pickSource } from "./_resolve.js"; /** * Pull a skill (or set of skills) from the workspace store onto this * machine. Single-skill mode resolves refs cross-source; bulk modes * (--tag, --all) scope to a single source via the resolver. */ export async function installCommand(refs, opts) { const scope = await getScope(); if (!scope) { throw new Error("No scope configured. Run `notion-skills init` first."); } if (!opts.all && !opts.tag && refs.length === 0) { throw new Error("Usage: notion-skills install <slug...> | --tag <name> | --all"); } if (opts.as && refs.length !== 1) { throw new Error("--as only applies when installing exactly one skill."); } await assertNtnInstalled(); const client = new NotionClient(); const manifest = (await loadManifest(scope.sources)) ?? emptyManifest(); // Decide which sources to query and how candidates get matched. let candidates; if (opts.tag || opts.all) { const source = await pickSource(opts.source, scope); candidates = await collectCandidates(client, [source]); candidates = filterBulk(candidates, opts); } else { // Single-skill mode: parse each ref, fan out to required sources. candidates = await resolveSingleRefs(client, scope.sources, refs); } if (candidates.length === 0) { console.log(chalk.dim("No matching skills found.")); return; } // Filter out already-installed (by source + source_slug match against // existing manifest entries). const installedBySource = new Map(); for (const e of Object.values(manifest.skills)) { let bag = installedBySource.get(e.source_key); if (!bag) installedBySource.set(e.source_key, (bag = new Set())); bag.add(e.source_slug); } const fresh = []; const already = []; for (const c of candidates) { if (installedBySource.get(c.source.key)?.has(c.sourceSlug)) { already.push(c); } else { fresh.push(c); } } if (fresh.length === 0) { if (already.length === 1) { console.log(chalk.dim(`${already[0].sourceSlug} is already installed.`)); } else { console.log(chalk.dim(`All ${already.length} matching skills are already installed.`)); } return; } if (fresh.length > 5 && refs.length === 0) { const ok = await confirm({ message: `Install ${fresh.length} ${fresh.length === 1 ? "skill" : "skills"}?`, default: true, }); if (!ok) { console.log(chalk.dim("Aborted.")); return; } } console.log(chalk.bold(`Installing ${fresh.length} ${fresh.length === 1 ? "skill" : "skills"}:`)); // Pre-warm Installs column on every source we're about to install from. const sourcesTouched = new Set(fresh.map((c) => c.source)); for (const s of sourcesTouched) { await client.upgradeSchema(s.data_source_id, { only: new Set(["Installs"]) }); } const nextManifest = { ...manifest, last_synced_at: new Date().toISOString(), hash_v: HASH_V, skills: { ...manifest.skills }, }; for (const c of fresh) { const sourceTag = scope.sources.length > 1 ? chalk.dim(` [${c.source.key}]`) : ""; const task = startTask(c.sourceSlug + sourceTag); try { const converted = await convertPageToSkill(client, c.page); if (!converted.ok) { task.fail(converted.reason); continue; } const skill = converted.skill; const md = buildSkillMarkdown({ properties: skill.properties, body: skill.body, }); // Choose the on-disk slug. Source-slug if free; else // <source-key>-<slug>; else numeric suffix. --as overrides. const choice = chooseLocalSlug(c.source.key, c.sourceSlug, nextManifest, opts.as); const dir = join(SKILLS_STORE, choice.slug); await mkdir(dir, { recursive: true }); await writeFile(join(dir, "SKILL.md"), md, "utf8"); await materializeFiles(dir, skill.files); nextManifest.skills[choice.slug] = { source_key: c.source.key, source_slug: c.sourceSlug, page_id: skill.pageId, last_edited_time: skill.lastEditedTime, props_hash: hashBehaviorProperties(c.page), body_hash: hashSkillContent(skill.body, skill.files), local_hash: hashSkillContent(md, skill.files), files: skill.files.map((f) => f.path).sort(), }; const targets = targetsForKeys(scope.targets); for (const t of targets) { const link = targetSkillPath(t, choice.slug); await ensureSymlink(dir, link); } await client.incrementPageNumber(skill.pageId, "Installs"); if (choice.autoNamespaced) { task.done(`(installed as '${choice.slug}' to avoid collision)`); } else { task.done(); } } catch (err) { task.fail(err.message.split("\n")[0]); } } await writeManifest(MANIFEST_FILE, nextManifest); console.log(""); console.log(chalk.green(`✓ Installed ${fresh.length} ${fresh.length === 1 ? "skill" : "skills"}.`)); if (already.length > 0) { console.log(chalk.dim(` (${already.length} already installed, skipped.)`)); } } // ---------- helpers ---------- /** * Query every given source's data source and return a flat list of * candidates, each tagged with the originating Source. Skips archived/ * trashed pages and ones missing a title. */ async function collectCandidates(client, sources) { const out = []; for (const source of sources) { const pages = await client.queryDataSource(source.data_source_id); const collisions = detectSlugCollisions(pages); const colliding = collidingSlugSet(collisions); const publishedColumn = pages.some((p) => p.properties.Published !== undefined); for (const page of pages) { if (page.archived || page.in_trash) continue; const title = readTitle(page.properties); if (!title) continue; const sourceSlug = slugify(title); if (colliding.has(sourceSlug)) continue; const isDraft = publishedColumn && !readCheckbox(page.properties, "Published"); out.push({ source, page, sourceSlug, isDraft }); } } return out; } /** * Bulk-mode filter: --tag and --all both skip drafts and (for --tag) * apply tag set intersection. */ function filterBulk(candidates, opts) { const out = candidates.filter((c) => !c.isDraft); if (!opts.tag || opts.tag.length === 0) return out; const wanted = opts.tag.flatMap((t) => t.split(",")).map((t) => t.trim()).filter(Boolean); return out.filter((c) => { const tags = readMultiSelect(c.page.properties, "Tags"); return wanted.every((w) => tags.includes(w)); }); } /** * Resolve a list of refs against all configured sources. Each ref can * be: * - bare slug (`deploy`): search all sources, error on ambiguity * - qualified (`team/deploy`): scope to that source * * Drafts are allowed in single-skill mode (the user named them). */ async function resolveSingleRefs(client, sources, refs) { // Cache pages-by-source — avoid refetching on multiple refs. const pagesBySource = new Map(); async function load(s) { const cached = pagesBySource.get(s.key); if (cached) return cached; const cs = await collectCandidates(client, [s]); pagesBySource.set(s.key, cs); return cs; } const out = []; for (const ref of refs) { const { sourceKey, slug } = parseSkillRef(ref); if (sourceKey !== undefined) { const source = sources.find((s) => s.key === sourceKey); if (!source) { throw new Error(`Unknown source "${sourceKey}". Configured: ${sources.map((s) => s.key).join(", ")}.`); } const pool = await load(source); const hit = pool.find((c) => c.sourceSlug === slug); if (!hit) { throw new Error(`${ref}: not found in source "${sourceKey}".`); } out.push(hit); } else { // Bare slug — search every source. const matches = []; for (const source of sources) { const pool = await load(source); for (const c of pool) { if (c.sourceSlug === slug) matches.push(c); } } if (matches.length === 0) { throw new Error(`${ref}: not found in any source. Run \`notion-skills list\` to see what's available.`); } if (matches.length > 1) { const refs = matches.map((m) => `${m.source.key}/${m.sourceSlug}`).join(", "); throw new Error(`${ref}: ambiguous, exists in multiple sources (${refs}). Qualify with <source>/<slug>.`); } out.push(matches[0]); } } return out; } //# sourceMappingURL=install.js.map