UNPKG

@brianlovin/notion-skills

Version:

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

242 lines 10 kB
import chalk from "chalk"; import { input, select } from "@inquirer/prompts"; import { getScope, writeScope } from "../scope.js"; import { NotionClient } from "../notion.js"; import { assertNtnInstalled } from "../ntn.js"; import { MANIFEST_FILE } from "../paths.js"; import { readManifest, writeManifest } from "../manifest.js"; import { applySourceRemoval } from "../source-state.js"; import { defaultSource, deriveKey, findByDatabaseId, findByKey, slugifyDbTitle, validateKey, } from "../sources.js"; import { pickOrCreateDatabase } from "./_db-picker.js"; export async function sourceAddCommand(opts = {}) { await assertNtnInstalled(); const scope = await getScope(); if (!scope) { throw new Error("notion-skills isn't configured yet. Run `notion-skills init` first."); } const client = new NotionClient(); const picked = await pickOrCreateDatabase(client); if (findByDatabaseId(scope.sources, picked.databaseId)) { const existing = findByDatabaseId(scope.sources, picked.databaseId); throw new Error(`That database is already linked as source "${existing.key}". Run \`notion-skills source list\` to see all sources.`); } const existingKeys = new Set(scope.sources.map((s) => s.key)); let key; if (opts.key) { // Explicit override (--key). Validate strictly; never prompt. const e = validateKey(opts.key); if (e) throw new Error(`Invalid source key: ${e}`); if (existingKeys.has(opts.key)) { throw new Error(`key "${opts.key}" is already in use`); } key = opts.key; } else { // Auto-derive from the database title. Only prompt when the // derived key would collide with an existing source — the user // needs to disambiguate. Otherwise just use it silently. const baseSlug = slugifyDbTitle(picked.databaseTitle); const collides = existingKeys.has(baseSlug); if (!collides) { key = baseSlug; } else if (process.stdin.isTTY) { key = await input({ message: `Source key "${baseSlug}" already exists. Pick another:`, default: deriveKey(picked.databaseTitle, existingKeys), validate: (v) => { const e = validateKey(v); if (e) return e; if (existingKeys.has(v)) return `key "${v}" is already in use`; return true; }, }); } else { // Non-TTY collision: auto-suffix and proceed. key = deriveKey(picked.databaseTitle, existingKeys); } } const newSource = { key, name: picked.databaseTitle, database_id: picked.databaseId, data_source_id: picked.dataSourceId, // First source is implicitly default; subsequent ones are not (per // user spec: don't auto-promote). default: scope.sources.length === 0, added_at: new Date().toISOString(), }; scope.sources.push(newSource); await writeScope({ sources: scope.sources, targets: scope.targets, gen_agent: scope.gen_agent, }); console.log(chalk.green(`✓ Added source "${key}" → ${picked.databaseTitle}`)); if (scope.sources.length > 1) { const def = defaultSource(scope.sources); if (def) { console.log(chalk.dim(` Default source remains "${def.key}". Use \`notion-skills source default ${key}\` to switch.`)); } } } export async function sourceListCommand(opts = {}) { const scope = await getScope(); if (!scope || scope.sources.length === 0) { if (opts.json) { console.log(JSON.stringify({ sources: [] }, null, 2)); return; } console.log(chalk.dim("No sources configured. Run `notion-skills init` to add one.")); return; } if (opts.json) { console.log(JSON.stringify({ sources: scope.sources }, null, 2)); return; } for (const s of scope.sources) { const flag = s.default ? chalk.green("default ") : ""; console.log(`${flag}${chalk.bold(s.key)} ${chalk.dim(`— ${s.name}`)}`); console.log(chalk.dim(` database: https://www.notion.so/${s.database_id.replace(/-/g, "")}`)); console.log(chalk.dim(` added: ${s.added_at}`)); } } export async function sourceRemoveCommand(key, opts = {}) { const scope = await getScope(); if (!scope) throw new Error("notion-skills isn't configured yet."); const target = findByKey(scope.sources, key); if (!target) { throw new Error(`Unknown source "${key}". Configured: ${scope.sources.map((s) => s.key).join(", ") || "(none)"}.`); } const manifest = await readManifest(MANIFEST_FILE, scope.sources[0].key); const installedFromSource = manifest ? Object.entries(manifest.skills).filter(([, e]) => e.source_key === key) : []; let mode = "no-skills"; if (installedFromSource.length > 0) { if (opts.yes) { mode = opts.keepSkills ? "keep" : "uninstall"; } else if (process.stdin.isTTY) { const decision = await select({ message: `${installedFromSource.length} skills are installed from "${key}". What now?`, choices: [ { name: "Uninstall them (delete dirs + symlinks)", value: "uninstall" }, { name: "Keep them as local-only drafts", value: "keep" }, { name: "Cancel", value: "cancel" }, ], default: "uninstall", }); if (decision === "cancel") { console.log(chalk.dim("Cancelled.")); return; } mode = decision; } else { throw new Error(`${installedFromSource.length} skills are installed from "${key}". Pass --yes (uninstalls) or --yes --keep-skills (demotes to local drafts).`); } } // Apply via the shared helper. "no-skills" still drops the source // from scope; nothing to do at the manifest/filesystem level. if (mode === "no-skills") { scope.sources = scope.sources.filter((s) => s.key !== key); await writeScope({ sources: scope.sources, targets: scope.targets, gen_agent: scope.gen_agent, }); } else { await applySourceRemoval(scope, key, mode); } console.log(chalk.green(`✓ Removed source "${key}"${target.default ? " (was default — set a new one with `source default <key>`)" : ""}`)); if (mode === "uninstall") { console.log(chalk.dim(` Uninstalled ${installedFromSource.length} skills.`)); } else if (mode === "keep") { console.log(chalk.dim(` Demoted ${installedFromSource.length} skills to local drafts.`)); } } // ---------- source default ---------- export async function sourceDefaultCommand(key) { const scope = await getScope(); if (!scope) throw new Error("notion-skills isn't configured yet."); if (!findByKey(scope.sources, key)) { throw new Error(`Unknown source "${key}". Configured: ${scope.sources.map((s) => s.key).join(", ") || "(none)"}.`); } for (const s of scope.sources) s.default = s.key === key; await writeScope({ sources: scope.sources, targets: scope.targets, gen_agent: scope.gen_agent, }); console.log(chalk.green(`✓ Default source is now "${key}".`)); } // ---------- source rename ---------- export async function sourceRenameCommand(oldKey, newKey) { const scope = await getScope(); if (!scope) throw new Error("notion-skills isn't configured yet."); const source = findByKey(scope.sources, oldKey); if (!source) { throw new Error(`Unknown source "${oldKey}".`); } const e = validateKey(newKey); if (e) throw new Error(`Invalid new key: ${e}`); if (oldKey === newKey) { console.log(chalk.dim("No change.")); return; } if (findByKey(scope.sources, newKey)) { throw new Error(`key "${newKey}" is already in use`); } source.key = newKey; await writeScope({ sources: scope.sources, targets: scope.targets, gen_agent: scope.gen_agent, }); // Rewrite manifest entries' source_key field. const manifest = await readManifest(MANIFEST_FILE, newKey); if (manifest) { let touched = 0; for (const entry of Object.values(manifest.skills)) { if (entry.source_key === oldKey) { entry.source_key = newKey; touched++; } } if (touched > 0) await writeManifest(MANIFEST_FILE, manifest); console.log(chalk.green(`✓ Renamed "${oldKey}" → "${newKey}"${touched ? ` (updated ${touched} manifest entries)` : ""}.`)); } else { console.log(chalk.green(`✓ Renamed "${oldKey}" → "${newKey}".`)); } } /** * Default action when `notion-skills source` runs with no subcommand: * print a friendly summary that doubles as command help. Avoids commander's * default "missing subcommand" error which is unhelpful here since `source` * is itself a useful inspection command. */ export async function sourceHelpCommand() { await sourceListCommand(); console.log(""); console.log(chalk.dim("Subcommands:")); console.log(chalk.dim(" source add link or create another source")); console.log(chalk.dim(" source list [--json] show configured sources")); console.log(chalk.dim(" source remove <key> remove a source (prompts for installed skills)")); console.log(chalk.dim(" source default <key> set the default source")); console.log(chalk.dim(" source rename <old> <new> rename a source key")); } //# sourceMappingURL=source.js.map