@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
316 lines • 14.2 kB
JavaScript
import chalk from "chalk";
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { migrateCommand } from "./migrate.js";
import { MANIFEST_FILE, SKILLS_STORE } from "../paths.js";
import { buildSkillMarkdown, convertPageToSkill, slugify, } from "../convert.js";
import { loadManifest, writeManifest, } from "../manifest.js";
import { HASH_V, hashBehaviorProperties, hashSkillContent, } from "../page-hash.js";
import { NotionClient, readCheckbox, readTitle } from "../notion.js";
import { childPageIdsFrom, hashLocalSkillDir, readLocalSkillFiles, upsertSkillFilePages, } from "../skill-files.js";
import { assertNtnInstalled, ntnSetPageMarkdown } from "../ntn.js";
import { parseSkillFile } from "../migrate.js";
import { SCHEMA, notionPropsForSkill } from "../schema.js";
import { getScope } from "../scope.js";
import { startTask } from "./_progress.js";
import { findByKey } from "../sources.js";
import { pickSource } from "./_resolve.js";
/**
* Push local skills to the workspace skill store.
*
* Three flavors of push, all routed through this verb:
* - **First-time publish** (no manifest entry, local SKILL.md exists):
* create a new Notion page in the chosen source. Source picker
* fires when 2+ sources exist and `--source` is not set.
* - **Update publish** (manifest entry exists): PATCH the existing
* Notion page. Re-publishes to the entry's recorded source — never
* prompts. `--source` is ignored here.
* - **Notion-side draft** (page exists in Notion with Published=false,
* no local presence): flip Published=true. Search scoped to the
* picked source.
*/
export async function publishCommand(slugs, opts) {
if (!opts.all && slugs.length === 0) {
throw new Error("Usage: notion-skills publish <slug...> | --all\n" +
"Pass one or more skill slugs, or --all to publish every local-only skill.");
}
if (opts.all && slugs.length > 0) {
throw new Error("Pass either <slug>... or --all, not both.");
}
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
const manifest = await loadManifest(scope.sources);
const trackedNames = new Set(manifest ? Object.keys(manifest.skills) : []);
if (slugs.length > 0) {
const updateSlugs = slugs.filter((s) => trackedNames.has(s));
const localDraftSlugs = slugs.filter((s) => !trackedNames.has(s) &&
existsSync(join(SKILLS_STORE, s, "SKILL.md")));
const unresolved = slugs.filter((s) => !trackedNames.has(s) && !existsSync(join(SKILLS_STORE, s, "SKILL.md")));
// For unresolved slugs, search Notion as potential Notion-side drafts.
// Tag/source semantics differ per source, so this is scoped via the
// picker (uses default unless --source is set).
const notionDraftSlugs = [];
const stillMissing = [];
if (unresolved.length > 0) {
await assertNtnInstalled();
const client = new NotionClient();
const draftSource = await pickSource(opts.source, scope);
const pages = await client.queryDataSource(draftSource.data_source_id);
const wantedSet = new Set(unresolved);
const found = new Map(); // slug → pageId
for (const page of pages) {
if (page.archived || page.in_trash)
continue;
const title = readTitle(page.properties);
if (!title)
continue;
const pageSlug = slugify(title);
if (!wantedSet.has(pageSlug))
continue;
if (!readCheckbox(page.properties, "Published")) {
found.set(pageSlug, page.id);
}
}
for (const slug of unresolved) {
const pageId = found.get(slug);
if (pageId)
notionDraftSlugs.push({ slug, pageId, source: draftSource });
else
stillMissing.push(slug);
}
}
if (stillMissing.length > 0) {
throw new Error(`Cannot publish: ${stillMissing.join(", ")} ${stillMissing.length === 1 ? "is" : "are"} not in the central store and not a draft in any source.\n` +
`Run \`notion-skills gen\` to create a draft locally, or check \`notion-skills list --drafts\` for what's available.`);
}
if (updateSlugs.length > 0 && manifest) {
// Group by source: each entry knows its own source from manifest.
const bySource = new Map();
for (const slug of updateSlugs) {
const key = manifest.skills[slug].source_key;
let bag = bySource.get(key);
if (!bag)
bySource.set(key, (bag = []));
bag.push(slug);
}
for (const [sourceKey, slugsForSource] of bySource) {
const source = findByKey(scope.sources, sourceKey);
if (!source) {
console.log(chalk.yellow(`! Skipping ${slugsForSource.length} skills from unknown source "${sourceKey}". Configure it via \`source add\` or \`source rename\`.`));
continue;
}
await pushUpdates(source, manifest, slugsForSource);
}
}
if (localDraftSlugs.length > 0) {
const target = await pickSource(opts.source, scope);
await migrateCommand({ yes: true, only: localDraftSlugs, source: target.key });
}
if (notionDraftSlugs.length > 0) {
await flipPublishedForDrafts(notionDraftSlugs);
}
return;
}
// --all mode. Push all drafts + all installed-with-drift.
if (manifest) {
const drifted = await detectDriftedInstalled(manifest);
if (drifted.length > 0) {
// Group by source.
const bySource = new Map();
for (const slug of drifted) {
const key = manifest.skills[slug].source_key;
let bag = bySource.get(key);
if (!bag)
bySource.set(key, (bag = []));
bag.push(slug);
}
for (const [sourceKey, slugsForSource] of bySource) {
const source = findByKey(scope.sources, sourceKey);
if (!source)
continue;
await pushUpdates(source, manifest, slugsForSource);
}
}
}
// For local drafts, route through migrate. Picks a single source.
const target = await pickSource(opts.source, scope);
await migrateCommand({ yes: opts.yes, source: target.key });
console.log(chalk.dim("\nRun `notion-skills list` to verify."));
}
/**
* For each slug already in the manifest, push the local SKILL.md to
* its existing Notion page (PATCH properties + body), then re-fetch
* to capture Notion's normalised formatting and update the manifest.
*/
async function pushUpdates(source, manifest, slugs) {
await assertNtnInstalled();
const client = new NotionClient();
console.log(chalk.bold(`Publishing ${slugs.length} ${slugs.length === 1 ? "update" : "updates"} → ${source.key}:`));
const parsed = [];
const failed = [];
for (const localSlug of slugs) {
const dir = join(SKILLS_STORE, localSlug);
const file = join(dir, "SKILL.md");
const result = await parseSkillFile(file, dir, file, localSlug);
if ("error" in result) {
failed.push(localSlug);
console.log(` ${chalk.red("✗")} ${localSlug} ${chalk.dim(`(${result.error})`)}`);
continue;
}
const entry = manifest.skills[localSlug];
const files = await readLocalSkillFiles(dir);
const unsupported = files.filter((f) => f.kind === "unsupported");
if (unsupported.length > 0) {
console.log(chalk.yellow(` ⚠ ${localSlug}: ${unsupported.length} unsupported file ${unsupported.length === 1 ? "type" : "types"} (${unsupported.map((f) => f.path).join(", ")}) — skipping.`));
}
parsed.push({
localSlug,
sourceSlug: entry.source_slug,
pageId: entry.page_id,
body: result.skill.body,
properties: result.skill.properties,
files: files.filter((f) => f.kind !== "unsupported"),
});
}
if (parsed.length === 0)
return;
const neededProps = new Set();
const selectValues = new Map();
for (const p of parsed) {
for (const c of notionPropsForSkill(p.properties))
neededProps.add(c);
for (const def of SCHEMA) {
if (def.kind !== "select" && def.kind !== "multi_select")
continue;
if (!def.selfHealing)
continue;
const v = p.properties[def.frontmatterKey];
const values = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
for (const value of values) {
if (!value || value === "" || value === "default")
continue;
let bag = selectValues.get(def.notionName);
if (!bag) {
bag = new Set();
selectValues.set(def.notionName, bag);
}
bag.add(value);
}
}
}
if (neededProps.size > 0) {
await client.upgradeSchema(source.data_source_id, { only: neededProps });
}
if (selectValues.size > 0) {
await client.ensureSelectOptions(source.data_source_id, selectValues);
}
const dataSource = await client.getDataSource(source.data_source_id);
const existingColumns = new Set(Object.keys(dataSource.properties));
const pushed = [];
for (const p of parsed) {
const task = startTask(p.localSlug);
try {
await client.updateSkillPageProperties(p.pageId, { ...p.properties, published: true }, existingColumns);
// Snapshot the sibling-file pages BEFORE replacing the body: the
// body push must carry them forward, or Notion deletes them and
// the upsert below rebuilds all of them from scratch.
const existingChildPageIds = childPageIdsFrom(await client.getBlockChildren(p.pageId));
if (p.body.trim()) {
await ntnSetPageMarkdown(p.pageId, p.body, {
preserveChildPageIds: existingChildPageIds,
});
}
await upsertSkillFilePages(client, ntnSetPageMarkdown, p.pageId, p.files);
pushed.push({ localSlug: p.localSlug, sourceSlug: p.sourceSlug, pageId: p.pageId });
task.done();
}
catch (err) {
failed.push(p.localSlug);
task.fail(err.message.split("\n")[0]);
}
}
// Round-trip: refetch and update manifest entries.
const nextManifest = {
...manifest,
last_synced_at: new Date().toISOString(),
hash_v: HASH_V,
skills: { ...manifest.skills },
};
for (const p of pushed) {
try {
const fresh = await client.getPage(p.pageId);
const converted = await convertPageToSkill(client, fresh);
if (!converted.ok)
continue;
const md = buildSkillMarkdown({
properties: converted.skill.properties,
body: converted.skill.body,
});
const dir = join(SKILLS_STORE, p.localSlug);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "SKILL.md"), md, "utf8");
nextManifest.skills[p.localSlug] = {
source_key: source.key,
source_slug: converted.skill.properties.name,
page_id: p.pageId,
last_edited_time: converted.skill.lastEditedTime,
props_hash: hashBehaviorProperties(fresh),
body_hash: hashSkillContent(converted.skill.body, converted.skill.files),
local_hash: hashSkillContent(md, converted.skill.files),
files: converted.skill.files.map((f) => f.path).sort(),
};
}
catch {
// Refresh failure is non-fatal.
}
}
await writeManifest(MANIFEST_FILE, nextManifest);
console.log("");
if (failed.length === 0) {
console.log(chalk.green(`✓ Published ${pushed.length} ${pushed.length === 1 ? "update" : "updates"}.`));
}
else {
console.log(chalk.yellow(`Published ${pushed.length} of ${pushed.length + failed.length} (${failed.length} failed):`));
for (const name of failed)
console.log(` ${chalk.red("✗")} ${name}`);
}
}
async function flipPublishedForDrafts(drafts) {
const client = new NotionClient();
console.log(chalk.bold(`Publishing ${drafts.length} Notion ${drafts.length === 1 ? "draft" : "drafts"}:`));
for (const d of drafts) {
const task = startTask(`${d.slug} ${chalk.dim(`[${d.source.key}]`)}`);
try {
await client.setPublished(d.pageId, true);
task.done();
}
catch (err) {
task.fail(err.message.split("\n")[0]);
}
}
console.log("");
console.log(chalk.green(`✓ Marked ${drafts.length} ${drafts.length === 1 ? "draft" : "drafts"} as ready.`));
}
async function detectDriftedInstalled(manifest) {
const drifted = [];
for (const [localSlug, entry] of Object.entries(manifest.skills)) {
if (entry.local_hash === undefined)
continue;
const skillDir = join(SKILLS_STORE, localSlug);
if (!existsSync(join(skillDir, "SKILL.md")))
continue;
try {
const currentHash = await hashLocalSkillDir(skillDir);
if (currentHash !== entry.local_hash)
drifted.push(localSlug);
}
catch {
// unreadable; skip
}
}
return drifted;
}
//# sourceMappingURL=publish.js.map