@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
239 lines • 9.94 kB
JavaScript
import chalk from "chalk";
import { existsSync, lstatSync, readdirSync } from "node:fs";
import { cp, mkdir, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { confirm } from "@inquirer/prompts";
import { getScope } from "../scope.js";
import { loadManifest, writeManifest, } from "../manifest.js";
import { hashLocalSkillDir } from "../skill-files.js";
import { NotionClient, readMultiSelect, readTitle } from "../notion.js";
import { assertNtnInstalled } from "../ntn.js";
import { slugify } from "../convert.js";
import { removeSymlink, targetSkillPath, targetsForKeys, } from "../targets.js";
import { MANIFEST_FILE, ROOT_DIR, SKILLS_STORE } from "../paths.js";
import { startTask } from "./_progress.js";
import { pickSource } from "./_resolve.js";
/**
* Remove skills from this machine.
*
* - Deletes ~/.notion-skills/skills/<slug>/.
* - Drops the manifest entry if present.
* - Removes symlinks from every target dir.
*
* Surface mirrors `install`: pass slugs, `--tag <name>` (AND across
* tags), or `--all`. Bulk modes confirm before destruction. Auto-backs
* up local edits per skill before deletion if SKILL.md has drifted
* from the manifest's `local_hash`. Saves to
* ~/.notion-skills/backup/uninstall-<ts>/<slug>/. Notion pages are
* untouched (use `unpublish` to retire from the store).
*
* Works for installed skills AND drafts (central-store entries with no
* manifest record). Same operation: remove from machine.
*/
export async function uninstallCommand(slugs, opts) {
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
if (!opts.all && !opts.tag?.length && slugs.length === 0) {
throw new Error("Usage: notion-skills uninstall <slug...> | --tag <name> | --all");
}
const manifest = await loadManifest(scope.sources);
const targetSlugs = await resolveTargets(slugs, opts, manifest, scope);
if (targetSlugs.length === 0) {
console.log(chalk.dim("No matching skills to remove."));
return;
}
// Bulk modes (--all / --tag) get an explicit confirmation. Single-
// slug uninstalls (the historical interactive path) skip it for
// muscle-memory parity with `npm uninstall`.
const isBulk = !!opts.all || (opts.tag && opts.tag.length > 0);
if (isBulk && !opts.yes) {
const ok = await confirm({
message: `Remove ${targetSlugs.length} ${targetSlugs.length === 1 ? "skill" : "skills"} from this machine?`,
default: false,
});
if (!ok) {
console.log(chalk.dim("Aborted."));
return;
}
}
console.log(chalk.bold(`Removing ${targetSlugs.length} ${targetSlugs.length === 1 ? "skill" : "skills"}:`));
const targets = targetsForKeys(scope.targets);
const removedInstalled = [];
const removedDrafts = [];
const failed = [];
// Build the next-manifest copy once and mutate per skill so a partial
// failure leaves a coherent state for the survivors.
const nextManifest = manifest
? { ...manifest, skills: { ...manifest.skills } }
: null;
for (const slug of targetSlugs) {
const task = startTask(slug);
try {
const skillDir = join(SKILLS_STORE, slug);
const skillFile = join(skillDir, "SKILL.md");
const entry = manifest?.skills[slug];
if (existsSync(skillFile) && entry?.local_hash) {
try {
const currentHash = await hashLocalSkillDir(skillDir);
if (currentHash !== entry.local_hash) {
const backupDir = join(ROOT_DIR, "backup", `uninstall-${timestamp()}`, slug);
await mkdir(dirname(backupDir), { recursive: true });
// Copy the whole skill dir, so multi-file edits (siblings
// and SKILL.md alike) are preserved on uninstall, not just
// the SKILL.md.
await cp(skillDir, backupDir, { recursive: true });
}
}
catch {
// Backup is best-effort; never block the uninstall.
}
}
for (const t of targets) {
const link = targetSkillPath(t, slug);
if (existsSync(link) || symlinkExists(link)) {
await removeSymlink(link);
}
}
if (existsSync(skillDir)) {
await rm(skillDir, { recursive: true, force: true });
}
if (nextManifest && entry) {
delete nextManifest.skills[slug];
removedInstalled.push(slug);
}
else {
removedDrafts.push(slug);
}
task.done();
}
catch (err) {
failed.push(slug);
task.fail(err.message.split("\n")[0]);
}
}
if (nextManifest) {
nextManifest.last_synced_at = new Date().toISOString();
await writeManifest(MANIFEST_FILE, nextManifest);
}
console.log("");
const ok = removedInstalled.length + removedDrafts.length;
if (failed.length === 0) {
const parts = [];
if (removedInstalled.length > 0) {
parts.push(`Uninstalled ${removedInstalled.length} ${removedInstalled.length === 1 ? "skill" : "skills"}`);
}
if (removedDrafts.length > 0) {
parts.push(`removed ${removedDrafts.length} ${removedDrafts.length === 1 ? "draft" : "drafts"}`);
}
console.log(chalk.green(`✓ ${parts.join(", ")}.`));
}
else {
console.log(chalk.yellow(`Removed ${ok}; ${failed.length} failed: ${failed.join(", ")}`));
}
}
/**
* Translate a (slugs[], --tag, --all) request into the concrete set of
* slugs to remove. Validates that requested slugs are actually on this
* machine; tags are matched against installed-skill metadata pulled
* fresh from Notion (so newly-added tags are visible without sync).
*/
async function resolveTargets(slugs, opts, manifest, scope) {
// Dir-based discovery covers both installed (manifest entry) and
// drafts (no manifest entry). Both are valid uninstall targets.
const onDisk = listSkillDirs();
const installed = new Set(Object.keys(manifest?.skills ?? {}));
if (opts.all) {
if (opts.tag && opts.tag.length > 0) {
throw new Error("--all and --tag are mutually exclusive.");
}
if (slugs.length > 0) {
throw new Error("--all and explicit slugs are mutually exclusive.");
}
// --all means "every installed skill on this machine," scoped to a
// specific source if --source is given. Drafts excluded.
const all = [...installed];
if (opts.source) {
return all.filter((s) => manifest?.skills[s]?.source_key === opts.source).sort();
}
return all.sort();
}
if (opts.tag && opts.tag.length > 0) {
if (slugs.length > 0) {
throw new Error("--tag and explicit slugs are mutually exclusive.");
}
await assertNtnInstalled();
const client = new NotionClient();
// Tag operations are source-scoped (each Notion DB has its own tag
// option set; semantics differ across sources).
const source = await pickSource(opts.source, scope);
const pages = await client.queryDataSource(source.data_source_id);
const wanted = opts.tag
.flatMap((t) => t.split(","))
.map((t) => t.trim())
.filter(Boolean);
const matched = [];
for (const page of pages) {
if (page.archived || page.in_trash)
continue;
const title = readTitle(page.properties);
if (!title)
continue;
const sourceSlug = slugify(title);
const tags = readMultiSelect(page.properties, "Tags");
if (!wanted.every((w) => tags.includes(w)))
continue;
// Find the local_slug in this source whose source_slug matches.
for (const [localSlug, entry] of Object.entries(manifest?.skills ?? {})) {
if (entry.source_key === source.key && entry.source_slug === sourceSlug) {
matched.push(localSlug);
}
}
}
if (matched.length === 0) {
throw new Error(`No installed skills found with ${wanted.length === 1 ? "tag" : "all of tags"} ${wanted.join(", ")} in source "${source.key}".`);
}
return [...new Set(matched)].sort();
}
// Explicit slug list: each input is a local_slug (the dir on disk).
const valid = new Set(onDisk);
const missing = slugs.filter((s) => !valid.has(s));
if (missing.length > 0) {
throw new Error(`Cannot remove: ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not on this machine. Run \`notion-skills list --installed\` to see what's installed.`);
}
return slugs;
}
function listSkillDirs() {
if (!existsSync(SKILLS_STORE))
return [];
try {
return readdirSync(SKILLS_STORE, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
.map((d) => d.name);
}
catch {
return [];
}
}
function symlinkExists(p) {
try {
lstatSync(p);
return true;
}
catch {
return false;
}
}
function timestamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (d.getUTCFullYear().toString() +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
"_" +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()));
}
//# sourceMappingURL=uninstall.js.map