UNPKG

@brianlovin/notion-skills

Version:

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

692 lines 28.9 kB
import { ntnApi } from "./ntn.js"; import { SCHEMA, SELECT_DEFAULT, buildViewConfiguration } from "./schema.js"; const NOTION_API_VERSION = "2025-09-03"; export class NotionClient { async request(method, path, body) { return ntnApi(method, path, body, NOTION_API_VERSION); } async getDatabase(databaseId) { const json = await this.request("GET", `/v1/databases/${databaseId}`); return { id: json.id, title: (json.title ?? []).map((t) => t.plain_text).join("") || "Untitled", data_sources: json.data_sources ?? [], }; } async getDataSource(dataSourceId) { return this.request("GET", `/v1/data_sources/${dataSourceId}`); } /** * Create a page in the Skills data source with the full set of spec * properties. Body is set separately via `ntnSetPageMarkdown` because the * Notion REST API requires children-as-blocks (not markdown). */ async createSkillPage(dataSourceId, props, /** * Optional set of columns that exist on the data source — used to * filter `props.metadata` keys to only those a column exists for. * Caller fetches once per batch and passes through to avoid an * extra getDataSource call per page. */ existingColumns) { const body = { parent: { type: "data_source_id", data_source_id: dataSourceId }, properties: buildPagePropertiesPayload(props, existingColumns), children: [], }; const created = await this.request("POST", "/v1/pages", body); return created.id; } /** * Create an empty child page under a parent page. Used to publish * sibling files (e.g. LANGUAGE.md, scripts/search.ts) as child pages * on a skill row. Body is set separately via `ntnSetPageMarkdown`. * * The title is the file's relative path from the skill dir; we store * it verbatim in the page title (Notion accepts slashes in titles). */ async createChildPage(parentPageId, title) { const body = { parent: { type: "page_id", page_id: parentPageId }, properties: { title: { title: [{ type: "text", text: { content: title } }], }, }, children: [], }; const created = await this.request("POST", "/v1/pages", body); return created.id; } /** * Soft-delete a page (move to trash). Used by `unpublish` and to * retire orphaned child pages whose underlying file no longer * exists locally. * * The Notion API uses `in_trash`, not the legacy `archived` field — * a PATCH with `{archived: true}` returns 400 ("body.archived * should be not present"). Read paths handle both since the API * still returns `archived` on older pages. */ async archivePage(pageId) { await this.request("PATCH", `/v1/pages/${pageId}`, { in_trash: true }); } /** * Flip just the `Published` checkbox on a page. Used by `publish` to * mark a Notion-side draft as ready without touching its body or * other properties — the user has been editing it in Notion's UI. */ async setPublished(pageId, published) { await this.request("PATCH", `/v1/pages/${pageId}`, { properties: { Published: { checkbox: published } }, }); } /** Patch a page's properties without touching its content. */ async updateSkillPageProperties(pageId, props, existingColumns) { await this.request("PATCH", `/v1/pages/${pageId}`, { properties: buildPagePropertiesPayload(props, existingColumns), }); } /** * Create a new Skills database for the user. * * If `parentPageId` is omitted, the database lands at the workspace root * (Notion's `parent: { type: "workspace", workspace: true }` shape), which * is what most users want — no need to pre-create a parent page. * * The DB initialises with only the two properties required by every * skill — `Name` (title) and `Description`. Optional spec properties * (Model, Agent, Effort, etc.) are added progressively by `migrate` * when it sees skills that actually use them. This keeps the Notion * UI from being a wall of empty columns. */ async createSkillsDatabase(opts) { const parent = opts.parentPageId ? { type: "page_id", page_id: opts.parentPageId } : { type: "workspace", workspace: true }; // Eager: every core (spec) property + every notion-skills infra // property. Claude-tier extensions are progressive. const eagerProps = {}; for (const prop of SCHEMA) { if (prop.tier !== "core" && prop.tier !== "notion") continue; if (prop.kind === "title") { eagerProps[prop.notionName] = { title: {} }; } else { eagerProps[prop.notionName] = propertyDefinitionPayload(prop); } } const body = { parent, title: [{ type: "text", text: { content: opts.title } }], initial_data_source: { properties: eagerProps, }, }; const created = await this.request("POST", "/v1/databases", body); if (!created.data_sources?.length) { throw new Error("Notion returned a database without any data sources."); } const dataSourceId = created.data_sources[0].id; // Scaffold the All / Popular / New / Drafts default views. Fail-soft: // a missing Views API or a stricter workspace shouldn't stop the user // from getting a working database. `deleteUnmanagedViews` removes // the auto-created default view that Notion adds on database // creation — safe here because no user has had a chance to add // their own views yet. await this.ensureDefaultViews(created.id, dataSourceId, { deleteUnmanagedViews: true, }); return { id: created.id, title: (created.title ?? []).map((t) => t.plain_text).join("") || opts.title, data_sources: created.data_sources, data_source_id: dataSourceId, url: created.url ?? `https://www.notion.so/${created.id.replace(/-/g, "")}`, }; } /** * Make sure the workspace store has the four canonical views the * app-store framing relies on: * - "All" — sorted alphabetically by Name (default browse). * - "Popular" — sorted by Installs descending (which skills are * catching on across the team). * - "New" — sorted by created_time descending (recent additions). * - "Drafts" — only Published=false rows (work-in-progress that * isn't ready for team consumption yet). * * Idempotent: if a view by name already exists, PATCH its sort / * filter / column order. Otherwise POST to create it. Skips views * whose required column doesn't exist on the data source yet * (Installs for "Popular", Published for "Drafts"). * * Fail-soft: any Views-API error is swallowed (logged in debug mode). * Users still get a working database even if their workspace doesn't * support the Views API. */ async ensureDefaultViews(databaseId, dataSourceId, options = {}) { try { const dataSource = await this.getDataSource(dataSourceId); const propertiesByName = dataSource.properties; const configuration = buildViewConfiguration(propertiesByName); if (configuration.properties.length === 0) return; // List existing views; resolve each to its name via a follow-up // GET (the list endpoint returns minimal references — id only). const search = new URLSearchParams({ data_source_id: dataSourceId }); const list = await this.request("GET", `/v1/views?${search.toString()}`); const existingByName = new Map(); for (const v of list.results ?? []) { try { const detail = await this.request("GET", `/v1/views/${v.id}`); if (detail.name) existingByName.set(detail.name, v.id); } catch { // Skip views we can't fetch. } } const desired = [ { name: "All", sorts: [{ property: "Name", direction: "ascending" }], }, { name: "Popular", sorts: [{ property: "Installs", direction: "descending" }], skipIf: () => !propertiesByName["Installs"]?.id, }, { // Sort by the `Created` property (created_time kind) rather // than the meta-timestamp shape, which POST /v1/views silently // drops. name: "New", sorts: [{ property: "Created", direction: "descending" }], skipIf: () => !propertiesByName["Created"]?.id, }, { name: "Drafts", sorts: [{ property: "Created", direction: "descending" }], filter: { property: "Published", checkbox: { equals: false }, }, skipIf: () => !propertiesByName["Created"]?.id || !propertiesByName["Published"]?.id, }, ]; // For new views: POST the full payload (sorts + filter + // configuration). For existing views: PATCH only `configuration` // (column visibility/order), leaving the user's sort + filter // alone. PATCH on sorts has been rejecting the timestamp shape // that POST silently dropped, so configuration-only PATCH side- // steps the validation gap and lets us reconcile column // defaults across versions. for (const view of desired) { if (view.skipIf?.()) continue; const existingId = existingByName.get(view.name); try { if (existingId) { await this.request("PATCH", `/v1/views/${existingId}`, { configuration, }); } else { const payload = { name: view.name, type: "table", sorts: view.sorts, configuration, }; if (view.filter) payload.filter = view.filter; await this.request("POST", `/v1/views`, { database_id: databaseId, data_source_id: dataSourceId, ...payload, }); } } catch (err) { // One view's failure shouldn't block the others. if (process.env.NOTION_SKILLS_DEBUG === "1") { console.error(`view "${view.name}" failed:`, err); } } } // Optional cleanup: remove views that aren't in our managed set. // Only safe right after database creation, where the only // unmanaged view is the auto-created default. Re-listing picks // up the views we just created so we don't accidentally delete // them. if (options.deleteUnmanagedViews) { const managedNames = new Set(desired.map((v) => v.name)); const refreshed = await this.request("GET", `/v1/views?${search.toString()}`); for (const v of refreshed.results ?? []) { try { const detail = await this.request("GET", `/v1/views/${v.id}`); if (detail.name && managedNames.has(detail.name)) continue; await this.request("DELETE", `/v1/views/${v.id}`); } catch (err) { if (process.env.NOTION_SKILLS_DEBUG === "1") { console.error(`view delete ${v.id} failed:`, err); } } } } } catch (err) { if (process.env.NOTION_SKILLS_DEBUG === "1") { console.error("ensureDefaultViews failed:", err); } } } /** * Reconcile the data source schema with src/schema.ts. * Idempotent. Two flavours of change: * - Add a missing property * - Convert a property whose Notion type doesn't match the schema kind * (e.g. Agent went from rich_text to select between releases) * * Pass `options.only` to scope the reconciliation to a specific subset * of Notion column names. Migrate uses this to add only the columns * the about-to-upload skills actually need, instead of pre-populating * every spec property up front. */ async upgradeSchema(dataSourceId, options = {}) { const current = await this.getDataSource(dataSourceId); const additions = {}; const added = []; const retyped = []; for (const prop of SCHEMA) { if (prop.kind === "title") continue; if (options.only && !options.only.has(prop.notionName)) continue; const existing = current.properties[prop.notionName]; if (!existing) { additions[prop.notionName] = propertyDefinitionPayload(prop); added.push(prop.notionName); continue; } const expectedType = expectedNotionType(prop.kind); if (existing.type !== expectedType) { additions[prop.notionName] = propertyDefinitionPayload(prop); retyped.push(prop.notionName); } } if (added.length === 0 && retyped.length === 0) { return { added: [], retyped: [] }; } await this.request("PATCH", `/v1/data_sources/${dataSourceId}`, { properties: additions, }); // View configuration is owned by ensureDefaultViews — called from // createSkillsDatabase + init. We don't auto-refresh views on every // schema change because it'd mean an extra round of Notion API // calls during install (which itself runs upgradeSchema for the // Installs column). View drift in the rare case of a property-only // schema change is handled by re-running init. return { added, retyped }; } /** * For each self-healing select / multi_select property, ensure that all * the option names we're about to set on pages exist in the data * source's option list. Adds missing ones via PATCH. Used by publish * before page creation so Notion doesn't reject values for unknown * options (e.g. a new model ID, a new tag). * * `valuesByNotionName` maps Notion column name → set of values that need * to exist in that column's option list. */ async ensureSelectOptions(dataSourceId, valuesByNotionName) { if (valuesByNotionName.size === 0) return []; const current = await this.getDataSource(dataSourceId); const propertyPayload = {}; const report = []; for (const [notionName, wantedValues] of valuesByNotionName) { const def = current.properties[notionName]; if (!def) continue; const isSelect = def.type === "select"; const isMulti = def.type === "multi_select"; if (!isSelect && !isMulti) continue; const existingOptions = isSelect ? def.select?.options : def.multi_select?.options; const existing = new Set((existingOptions ?? []).map((o) => o.name)); const missing = [...wantedValues].filter((v) => !existing.has(v) && v !== ""); if (missing.length === 0) continue; const newOptions = [ ...(existingOptions ?? []).map((o) => ({ name: o.name, color: o.color })), ...missing.map((name) => ({ name, color: "default" })), ]; const optionsPayload = { options: newOptions }; propertyPayload[notionName] = isSelect ? { select: optionsPayload } : { multi_select: optionsPayload }; report.push({ column: notionName, added: missing }); } if (report.length === 0) return []; await this.request("PATCH", `/v1/data_sources/${dataSourceId}`, { properties: propertyPayload, }); return report; } async queryDataSource(dataSourceId, options = {}) { const results = []; let cursor; do { const body = { page_size: options.pageSize ?? 100 }; if (cursor) body.start_cursor = cursor; const json = await this.request("POST", `/v1/data_sources/${dataSourceId}/query`, body); results.push(...json.results); cursor = json.has_more ? (json.next_cursor ?? undefined) : undefined; } while (cursor); return results; } async getPage(pageId) { return this.request("GET", `/v1/pages/${pageId}`); } /** * Increment a number property on a page by 1. Used by `install` to * bump the Installs counter. Read-then-write (Notion has no atomic * increment); concurrent installs from two machines could step on * each other but the data fidelity is acceptable for v1 — popular * skills get more installs and that's what matters, exact counts * within ±1 don't. * * Fail-soft: returns the new count or null if the page or property * doesn't exist. Never throws — install shouldn't fail because the * counter couldn't be bumped. */ async incrementPageNumber(pageId, propertyName) { try { const page = await this.getPage(pageId); const prop = page.properties[propertyName]; const current = prop && prop.type === "number" && typeof prop.number === "number" ? prop.number : 0; const next = current + 1; await this.request("PATCH", `/v1/pages/${pageId}`, { properties: { [propertyName]: { number: next } }, }); return next; } catch { return null; } } async getBlockChildren(blockId) { const results = []; let cursor; do { const search = new URLSearchParams({ page_size: "100" }); if (cursor) search.set("start_cursor", cursor); const json = await this.request("GET", `/v1/blocks/${blockId}/children?${search.toString()}`); results.push(...json.results); cursor = json.has_more ? (json.next_cursor ?? undefined) : undefined; } while (cursor); return results; } /** * List page-level comments oldest-first. Notion returns them in * ascending creation order, which is also chronological reading * order — feedback callers reverse for newest-first display. */ async listComments(pageId) { const results = []; let cursor; do { const search = new URLSearchParams({ block_id: pageId, page_size: "100", }); if (cursor) search.set("start_cursor", cursor); const json = await this.request("GET", `/v1/comments?${search.toString()}`); results.push(...json.results); cursor = json.has_more ? (json.next_cursor ?? undefined) : undefined; } while (cursor); return results; } /** * Post a top-level comment on a page. Returns the created comment. * `body` is plain text; we wrap it in a single rich_text run. Markdown * isn't rendered (Notion's API only takes rich_text), but URLs auto- * link in the UI. */ async postComment(pageId, body) { return this.request("POST", `/v1/comments`, { parent: { page_id: pageId }, rich_text: [{ type: "text", text: { content: body } }], }); } } // ---------- Property accessors ---------- export function readTitle(props) { for (const v of Object.values(props)) { if (v.type === "title" && Array.isArray(v.title)) { return v.title.map((r) => r.plain_text).join("").trim(); } } return ""; } export function readRichText(props, name) { const p = props[name]; if (!p || p.type !== "rich_text" || !Array.isArray(p.rich_text)) return ""; return p.rich_text.map((r) => r.plain_text).join("").trim(); } export function readSelect(props, name) { const p = props[name]; if (!p || p.type !== "select") return null; const sel = p.select; return sel?.name ?? null; } export function readMultiSelect(props, name) { const p = props[name]; if (!p || p.type !== "multi_select" || !Array.isArray(p.multi_select)) return []; return p.multi_select.map((opt) => opt.name).filter((s) => !!s); } export function readNumber(props, name) { const p = props[name]; if (!p || p.type !== "number") return 0; const n = p.number; return typeof n === "number" ? n : 0; } /** * Read a checkbox property. Treats missing or wrong-typed properties as * `false`. Use `dataSourceHasProperty` to disambiguate "column missing" * from "column present, value unchecked" when the distinction matters * (e.g. backward-compat for stores without a Published column). */ export function readCheckbox(props, name) { const p = props[name]; if (!p || p.type !== "checkbox") return false; return p.checkbox === true; } // ---------- Schema payload builders ---------- /** * Build the `properties` block for POST /v1/pages or PATCH /v1/pages/<id> * from a SkillProperties value. * * Only emits properties that have a value to set; absent fields are left * untouched (so partial updates work). */ export function buildPagePropertiesPayload(props, /** * Optional: set of column names that exist on the data source. When * provided, metadata keys are matched against this set — keys that * don't match an existing column are silently skipped (we don't * auto-create columns from metadata). When omitted, all metadata * keys are written (caller's choice; usually only safe at create * time). */ existingColumns) { const payload = { Name: { title: [{ type: "text", text: { content: props.name } }] }, Description: { rich_text: [{ type: "text", text: { content: props.description } }], }, }; // core (Agent Skills spec) pushRichText(payload, "License", props.license); pushRichText(payload, "Compatibility", props.compatibility); pushList(payload, "Allowed Tools", props["allowed-tools"], " "); // claude pushRichText(payload, "When To Use", props.when_to_use); pushRichText(payload, "Argument Hint", props["argument-hint"]); pushList(payload, "Arguments", props.arguments, " "); pushList(payload, "Paths", props.paths, ", "); pushSelect(payload, "Disable Model Invocation", props["disable-model-invocation"]); pushSelect(payload, "User Invocable", props["user-invocable"]); pushSelect(payload, "Model", props.model); pushSelect(payload, "Effort", props.effort); pushSelect(payload, "Context", props.context); pushSelect(payload, "Agent", props.agent); pushSelect(payload, "Shell", props.shell); // notion-side pushMultiSelect(payload, "Tags", props.tags); pushCheckbox(payload, "Published", props.published); // metadata — spec extension point. Each metadata key is matched // against an existing Notion column (when existingColumns is // provided) and written if a matching column exists. if (props.metadata) { for (const [key, value] of Object.entries(props.metadata)) { if (existingColumns && !existingColumns.has(key)) continue; const cell = metadataValueToPropertyCell(value); if (cell !== undefined) payload[key] = cell; } } return payload; } /** * Coerce a metadata value into a Notion property-cell shape. We don't * know the column's actual type, so we make a best-effort guess from * the JS type. Strings → rich_text, numbers → number, booleans → * checkbox, arrays → multi_select. The caller already filtered by * existing columns, so an unsuitable type just fails the API call — * which is the right error (loud) rather than silent-and-wrong. */ function metadataValueToPropertyCell(value) { if (value === undefined || value === null) return undefined; if (typeof value === "string") { if (value === "") return undefined; return { rich_text: [{ type: "text", text: { content: value } }] }; } if (typeof value === "number" && Number.isFinite(value)) { return { number: value }; } if (typeof value === "boolean") { return { checkbox: value }; } if (Array.isArray(value)) { const names = value .filter((v) => typeof v === "string" && v.trim() !== "") .map((v) => ({ name: v })); if (names.length === 0) return undefined; return { multi_select: names }; } return undefined; } function pushRichText(payload, notionName, value) { if (value === undefined || value === "") return; payload[notionName] = { rich_text: [{ type: "text", text: { content: value } }], }; } function pushList(payload, notionName, value, separator) { if (value === undefined || value.length === 0) return; payload[notionName] = { rich_text: [{ type: "text", text: { content: value.join(separator) } }], }; } function pushSelect(payload, notionName, value) { if (value === undefined || value === "") return; payload[notionName] = { select: { name: value } }; } function pushMultiSelect(payload, notionName, value) { if (value === undefined || value.length === 0) return; payload[notionName] = { multi_select: value.filter((v) => v && v.trim() !== "").map((name) => ({ name })), }; } function pushCheckbox(payload, notionName, value) { if (value === undefined) return; payload[notionName] = { checkbox: value }; } /** * Build the `initial_data_source.properties` block for creating a new * database with the full schema. */ export function buildInitialDataSourceProperties() { const out = {}; for (const prop of SCHEMA) { out[prop.notionName] = propertyDefinitionPayload(prop); } return out; } /** Notion's `properties.<name>.type` string for each schema kind. */ function expectedNotionType(kind) { switch (kind) { case "title": return "title"; case "rich_text": case "list_text": return "rich_text"; case "checkbox": return "checkbox"; case "number": return "number"; case "select": return "select"; case "multi_select": return "multi_select"; case "created_time": return "created_time"; } } /** * Notion property *definition* (vs. a property *value*) for use in a * create-database or PATCH /v1/data_sources call. */ export function propertyDefinitionPayload(prop) { switch (prop.kind) { case "title": return { title: {} }; case "rich_text": case "list_text": return { rich_text: {} }; case "checkbox": return { checkbox: {} }; case "number": return { number: { format: "number" } }; case "select": return { select: { options: prop.options ?? [{ name: SELECT_DEFAULT }] }, }; case "multi_select": return { multi_select: { options: prop.options ?? [] }, }; case "created_time": return { created_time: {} }; } } //# sourceMappingURL=notion.js.map