UNPKG

@brianlovin/notion-skills

Version:

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

317 lines 12.5 kB
import chalk from "chalk"; import { existsSync, lstatSync, readlinkSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { confirm } from "@inquirer/prompts"; import { getScope } from "../scope.js"; import { ntnDoctor, ntnVersion } from "../ntn.js"; import { loadManifest } from "../manifest.js"; import { SKILLS_STORE } from "../paths.js"; import { findTargetByKey, KNOWN_TARGETS } from "../known-targets.js"; import { NotionClient } from "../notion.js"; import { SCHEMA } from "../schema.js"; import { detectSlugCollisions } from "../slug-collisions.js"; export async function doctorCommand(opts) { const checks = []; // 1. ntn auth checks.push(...(await checkNtn())); // 2. Scope const scope = await getScope(); if (!scope || scope.sources.length === 0) { checks.push({ status: "fail", label: "No scope configured", detail: "Run `notion-skills init` to connect a Notion database.", }); printAndExit(checks); return; } checks.push({ status: "ok", label: `Scope: ${scope.sources.length} ${scope.sources.length === 1 ? "source" : "sources"} (${scope.sources.map((s) => s.key).join(", ")})`, }); // 3. Per-source schema + collision health (only if ntn is authenticated) const ntnOk = checks.every((c) => !c.label.startsWith("ntn") || c.status === "ok"); if (ntnOk) { for (const source of scope.sources) { checks.push(...(await checkSchema(source))); checks.push(...(await checkSlugCollisions(source))); } } // 4. Manifest / disk consistency checks.push(...(await checkManifestVsDisk(scope))); // 5. Symlinks checks.push(...(await checkSymlinks(scope))); // Run fixes if asked. if (opts.fix) { const fixable = checks.filter((c) => c.fix && c.status !== "ok"); if (fixable.length === 0) { console.log(chalk.dim("Nothing to fix.")); } else { console.log(chalk.bold(`\nApplying ${fixable.length} fix(es):`)); for (const c of fixable) { if (!c.fix) continue; if (process.stdin.isTTY) { const ok = await confirm({ message: `Fix: ${c.label}?`, default: true, }); if (!ok) continue; } try { const msg = await c.fix(); console.log(` ${chalk.green("✓")} ${msg}`); } catch (err) { console.log(` ${chalk.red("✗")} ${c.label}: ${err.message}`); } } } } printAndExit(checks); } // ---------- checks ---------- async function checkNtn() { const version = await ntnVersion(); if (!version) { return [ { status: "fail", label: "ntn is not installed", detail: "Install: https://github.com/makenotion/ntn-cli", }, ]; } const versionLabel = version.replace(/^ntn\s+/, ""); const doc = await ntnDoctor(); if (!doc.ok) { return [ { status: "fail", label: `ntn ${versionLabel} not authenticated`, detail: "Run `ntn login` to refresh.", }, ]; } return [{ status: "ok", label: `ntn ${versionLabel} authenticated` }]; } async function checkSchema(source) { try { const client = new NotionClient(); const ds = await client.getDataSource(source.data_source_id); const present = new Set(Object.keys(ds.properties)); // Hard requirements: Name + Description. Without these the store // can't function. Everything else is added progressively when a // skill that uses it gets published — absent properties aren't a // problem, they're the design. const required = ["Name", "Description"]; const missingRequired = required.filter((name) => !present.has(name)); if (missingRequired.length > 0) { return [ { status: "fail", label: `${source.key}: missing required ${missingRequired.length === 1 ? "property" : "properties"}: ${missingRequired.join(", ")}`, detail: `Run \`notion-skills upgrade --source ${source.key}\` to repair.`, }, ]; } const optionalPresent = SCHEMA.filter((p) => p.kind !== "title" && p.notionName !== "Description" && present.has(p.notionName)).length; return [ { status: "ok", label: `${source.key}: schema healthy (Name + Description + ${optionalPresent} optional)`, }, ]; } catch (err) { return [ { status: "fail", label: `${source.key}: couldn't reach the Notion database`, detail: err.message.split("\n")[0], }, ]; } } async function checkSlugCollisions(source) { try { const client = new NotionClient(); const pages = await client.queryDataSource(source.data_source_id); const collisions = detectSlugCollisions(pages); if (collisions.length === 0) { return [{ status: "ok", label: `${source.key}: no slug collisions` }]; } const lines = collisions.map((c) => ` ${c.slug}: ${c.titles.join(", ")}`); return [ { status: "warn", label: `${source.key}: ${collisions.length} slug ${collisions.length === 1 ? "collision" : "collisions"}: ${collisions.map((c) => c.slug).join(", ")}`, detail: [ "Multiple Notion pages slugify to the same name and are skipped by sync / refused by install.", ...lines, "Rename one page in each group to disambiguate.", ].join("\n"), }, ]; } catch (err) { return [ { status: "warn", label: `${source.key}: couldn't check slug collisions`, detail: err.message.split("\n")[0], }, ]; } } async function checkManifestVsDisk(scope) { const contentRoot = SKILLS_STORE; const manifest = await loadManifest(scope.sources); if (!manifest) { return [ { status: "warn", label: "Manifest missing — never synced from this machine", detail: "Run `notion-skills sync` to create one.", }, ]; } const onDisk = existsSync(contentRoot) ? new Set(readdirSync(contentRoot).filter((n) => existsSync(join(contentRoot, n, "SKILL.md")))) : new Set(); const inManifest = new Set(Object.keys(manifest.skills)); const orphans = [...onDisk].filter((n) => !inManifest.has(n)); const phantoms = [...inManifest].filter((n) => !onDisk.has(n)); const out = []; out.push({ status: "ok", label: `Manifest tracks ${inManifest.size} ${inManifest.size === 1 ? "skill" : "skills"}; central store has ${onDisk.size}`, }); if (orphans.length > 0) { // Under the v0.5 app-store model these aren't orphans — they're // drafts (local skills not yet published). Surface as info, not a // warning, and don't suggest removing them. out.push({ status: "ok", label: `${orphans.length} ${orphans.length === 1 ? "draft" : "drafts"} (local-only, not yet published): ${orphans.slice(0, 5).join(", ")}${orphans.length > 5 ? "…" : ""}`, detail: `Run \`notion-skills publish <slug>\` to share with your team, or \`notion-skills uninstall <slug>\` to discard.`, }); } if (phantoms.length > 0) { out.push({ status: "warn", label: `${phantoms.length} manifest ${phantoms.length === 1 ? "entry points" : "entries point"} to missing files: ${phantoms.slice(0, 5).join(", ")}${phantoms.length > 5 ? "…" : ""}`, detail: "Re-run sync to repopulate.", }); } return out; } async function checkSymlinks(scope) { const manifest = await loadManifest(scope.sources); if (!manifest) return []; const out = []; for (const key of scope.targets) { const target = findTargetByKey(key); if (!target) { out.push({ status: "warn", label: `Unknown target in scope: "${key}"`, detail: "Edit ~/.notion-skills/scope.json or run init again.", }); continue; } const expected = new Set(Object.keys(manifest.skills)); const dir = target.dir; let ok = 0; let dangling = []; let foreign = []; let missing = []; for (const name of expected) { const link = join(dir, name); if (!existsSync(link)) { // Existence checks the link target, so a dangling symlink fails too. try { lstatSync(link); // Link exists but target doesn't — dangling. dangling.push(name); } catch { missing.push(name); } continue; } const stat = lstatSync(link); if (!stat.isSymbolicLink()) { foreign.push(name); continue; } const realPath = readlinkSync(link); const expectedReal = join(SKILLS_STORE, name); if (realPath === expectedReal) ok++; else dangling.push(name); } out.push({ status: missing.length + dangling.length + foreign.length > 0 ? "warn" : "ok", label: `${target.label}: ${ok}/${expected.size} symlinks ok${missing.length ? `, ${missing.length} missing` : ""}${dangling.length ? `, ${dangling.length} broken` : ""}${foreign.length ? `, ${foreign.length} non-symlink` : ""}`, detail: missing.length + dangling.length + foreign.length > 0 ? "Run `notion-skills sync` to repair." : undefined, fix: missing.length + dangling.length > 0 ? async () => { const { runSync } = await import("../sync.js"); await runSync(scope); return `Re-synced into ${target.label}`; } : undefined, }); } return out; } // ---------- helpers ---------- function printAndExit(checks) { console.log(chalk.bold("notion-skills doctor")); console.log(chalk.dim("─".repeat(50))); for (const c of checks) { const mark = c.status === "ok" ? chalk.green("✓") : c.status === "warn" ? chalk.yellow("⚠") : chalk.red("✗"); console.log(`${mark} ${c.label}`); if (c.detail) { for (const line of c.detail.split("\n")) { console.log(chalk.dim(` → ${line}`)); } } } const counts = { ok: checks.filter((c) => c.status === "ok").length, warn: checks.filter((c) => c.status === "warn").length, fail: checks.filter((c) => c.status === "fail").length, }; const totalIssues = counts.warn + counts.fail; console.log(""); if (totalIssues === 0) { console.log(chalk.green(`✓ All ${counts.ok} checks passed.`)); } else { const fixable = checks.filter((c) => c.fix && c.status !== "ok").length; const warnWord = counts.warn === 1 ? "warning" : "warnings"; const failWord = counts.fail === 1 ? "failure" : "failures"; console.log(`${counts.ok} ok, ${counts.warn} ${warnWord}, ${counts.fail} ${failWord}.`); if (fixable > 0) { console.log(chalk.dim(`${fixable} ${fixable === 1 ? "issue" : "issues"} can be auto-fixed: run \`notion-skills doctor --fix\``)); } } // Don't exit non-zero for warnings; only failures. if (counts.fail > 0) process.exit(1); } // Avoid unused-import errors for KNOWN_TARGETS (used only in the symlink check above). void KNOWN_TARGETS; //# sourceMappingURL=doctor.js.map