@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
350 lines • 13.9 kB
JavaScript
/**
* Single source of truth for the Notion → SKILL.md frontmatter mapping.
*
* Each entry describes how a Notion property column corresponds to a key in
* one of three tiers of the skills ecosystem:
*
* - "core" — canonical Agent Skills spec (https://agentskills.io).
* Required: name, description. Optional: license,
* compatibility, allowed-tools, metadata.
* - "claude" — Claude Code's frontmatter conventions on top of the spec
* (when_to_use, model, agent, effort, etc.). Other agent
* providers may add their own tier in the future.
* - "notion" — notion-skills infrastructure that lives only in Notion
* (Tags, Installs, Published) and never round-trips to
* SKILL.md frontmatter.
*
* Anything a user adds to the Notion data source that isn't in SCHEMA is
* surfaced as `metadata.<column-name>` in SKILL.md frontmatter — the spec's
* official extension point.
*
* `kind` controls the type-specific read/write logic:
* - "title" → page title; the only required, name-bearing property
* - "rich_text" → plain string; empty cell omits the frontmatter key
* - "checkbox" → boolean
* - "number" → integer; used for metric-only props like Installs
* - "select" → string from a fixed option set; "default" / empty omit
* - "multi_select" → list of strings from an open option set; new options
* auto-added on publish
* - "list_text" → list serialised as space- or comma-separated rich_text
*
* Selects that have a spec default expose a "default" option; the empty cell
* also maps to "default" for users who haven't picked anything.
*/
export const SELECT_DEFAULT = "default";
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
/**
* Order matters for two reasons:
* 1. The first entry must be the title property.
* 2. Frontmatter is emitted in this order, giving stable output.
*/
export const SCHEMA = [
// ---------- core (Agent Skills spec) ----------
{
notionName: "Name",
frontmatterKey: "name",
kind: "title",
tier: "core",
defaultVisibleInListView: true,
description: "Skill slug (page title). Spec-required.",
},
{
notionName: "Description",
frontmatterKey: "description",
kind: "rich_text",
tier: "core",
defaultVisibleInListView: true,
description: "What the skill does and when to use it. Spec-required, max 1024 chars.",
},
{
notionName: "License",
frontmatterKey: "license",
kind: "rich_text",
tier: "core",
description: "License name or reference to a bundled LICENSE file. Spec-optional.",
},
{
notionName: "Compatibility",
frontmatterKey: "compatibility",
kind: "rich_text",
tier: "core",
description: "Environment requirements (intended product, system packages, etc.). Spec-optional, max 500 chars.",
},
{
notionName: "Allowed Tools",
frontmatterKey: "allowed-tools",
kind: "list_text",
tier: "core",
listSeparator: " ",
description: "Pre-approved tools the skill may use. Spec-experimental.",
},
// ---------- claude (Claude Code conventions) ----------
{
notionName: "When To Use",
frontmatterKey: "when_to_use",
kind: "rich_text",
tier: "claude",
description: "Additional trigger context appended to description (Claude Code).",
},
{
notionName: "Argument Hint",
frontmatterKey: "argument-hint",
kind: "rich_text",
tier: "claude",
description: "Autocomplete hint, e.g. [filename] [format] (Claude Code).",
},
{
notionName: "Arguments",
frontmatterKey: "arguments",
kind: "list_text",
tier: "claude",
listSeparator: " ",
description: "Named positional arguments, space-separated (Claude Code).",
},
{
notionName: "Paths",
frontmatterKey: "paths",
kind: "list_text",
tier: "claude",
listSeparator: ", ",
description: "Glob patterns to scope auto-activation, comma-separated (Claude Code).",
},
{
notionName: "Disable Model Invocation",
frontmatterKey: "disable-model-invocation",
kind: "select",
tier: "claude",
options: [
{ name: SELECT_DEFAULT, color: "default" },
{ name: "true", color: "red" },
{ name: "false", color: "gray" },
],
description: "Set to true to make manual-only (Claude Code; default: false).",
},
{
notionName: "User Invocable",
frontmatterKey: "user-invocable",
kind: "select",
tier: "claude",
options: [
{ name: SELECT_DEFAULT, color: "default" },
{ name: "true", color: "gray" },
{ name: "false", color: "red" },
],
description: "Set to false to hide from / menu (Claude Code; default: true).",
},
{
notionName: "Model",
frontmatterKey: "model",
kind: "select",
tier: "claude",
// Options start empty; real model IDs are added when migrate encounters
// them. We don't ship a default list because we can't know which models
// a user wants to pin to. (selfHealing fills it as you go.)
options: [{ name: SELECT_DEFAULT, color: "default" }],
selfHealing: true,
description: "Model override (Claude Code). Self-healing — model IDs auto-added on publish.",
},
{
notionName: "Effort",
frontmatterKey: "effort",
kind: "select",
tier: "claude",
options: [
{ name: SELECT_DEFAULT, color: "default" },
...EFFORTS.map((e) => ({ name: e, color: "yellow" })),
],
description: "Effort level when this skill is active (Claude Code).",
},
{
notionName: "Context",
frontmatterKey: "context",
kind: "select",
tier: "claude",
options: [
{ name: SELECT_DEFAULT, color: "default" },
{ name: "fork", color: "purple" },
],
description: "Set to 'fork' to run in a forked subagent context (Claude Code).",
},
{
notionName: "Agent",
frontmatterKey: "agent",
kind: "select",
tier: "claude",
// Options start empty; subagent type names are added when migrate
// encounters them. We don't ship a default list because subagent
// names are project-specific.
options: [{ name: SELECT_DEFAULT, color: "default" }],
selfHealing: true,
description: "Subagent type (Claude Code; used with context: fork). Self-healing.",
},
{
notionName: "Shell",
frontmatterKey: "shell",
kind: "select",
tier: "claude",
options: [
{ name: SELECT_DEFAULT, color: "default" },
{ name: "bash", color: "default" },
{ name: "powershell", color: "blue" },
],
description: "Shell for inline command injection (Claude Code; default: bash).",
},
// ---------- notion (notion-skills infrastructure; never round-tripped) ----------
{
// Discovery / curation primitive for the app-store layer. Workspace
// admins use tags like `featured`, `engineering`, `productivity` to
// group skills; users filter on them via `notion-skills list --tag`
// and `notion-skills install --tag`. Options auto-grow on publish
// (selfHealing) so any new tag a user types becomes a real Notion
// multi-select option without an upgrade step.
notionName: "Tags",
frontmatterKey: "tags",
kind: "multi_select",
tier: "notion",
options: [],
selfHealing: true,
taxonomyOnly: true,
defaultVisibleInListView: true,
description: "Discovery tags. Self-healing — new tags auto-added on publish.",
},
{
// Per-skill install counter. Incremented +1 by `notion-skills install`
// on each successful install. Surfaced in `list` so users can spot
// popular skills. Metric-only — never round-trips into SKILL.md
// frontmatter (it's store-managed data, not user content).
notionName: "Installs",
frontmatterKey: "installs",
kind: "number",
tier: "notion",
metricOnly: true,
defaultVisibleInListView: true,
description: "Install count. Auto-incremented by `notion-skills install`.",
},
{
// Page creation timestamp. Notion auto-fills this on every page;
// metric-only so it never round-trips into SKILL.md. Hidden from
// default views (the "New" view sorts by it but column visibility
// is independent of sort). Why a real column instead of the
// built-in `timestamp: created_time` view sort: Notion's POST
// /v1/views silently drops the meta-timestamp shape, so the New
// view never actually sorted by created date. Sorting by a
// property-name we created ourselves is the reliable path.
notionName: "Created",
frontmatterKey: "created",
kind: "created_time",
tier: "notion",
metricOnly: true,
description: "Auto-populated by Notion on page creation. Drives the \"New\" view sort.",
},
{
// Draft / ready gate. Unchecked = draft (hidden from `--available`,
// skipped by bulk install, sorted last in `list`). The act of
// running `notion-skills publish` checks this; `unpublish` archives
// the page entirely (separate verb). Backward-compat: when this
// column is absent from the data source, every row is treated as
// ready — teams who haven't added the column see no behavior
// change.
notionName: "Published",
frontmatterKey: "published",
kind: "checkbox",
tier: "notion",
metricOnly: true,
description: "Mark a skill as ready for team consumption. Unchecked = draft.",
},
];
/**
* Spec defaults. Sync omits the frontmatter key when the read value matches
* the default; migrate leaves the property unset when the frontmatter omits
* the key. Booleans are spec-canonical strings so they parse cleanly from
* select options.
*/
export const SPEC_DEFAULTS = {
"disable-model-invocation": "false",
"user-invocable": "true",
shell: "bash",
};
export function findProperty(notionName) {
return SCHEMA.find((p) => p.notionName === notionName);
}
export function findPropertyByFrontmatterKey(key) {
return SCHEMA.find((p) => p.frontmatterKey === key);
}
/**
* Properties that get created eagerly when a fresh database is set up
* (or when `init` / `upgrade` runs against a linked DB). The set covers
* the canonical spec fields plus our notion-skills infrastructure
* properties — anything users would expect to "just be there" without
* having to publish a skill that uses each one.
*
* Claude-tier properties (when_to_use, model, etc.) are progressive:
* they get added on demand the first time a skill uses them. This
* keeps the database UI from looking like a wall of empty Claude-
* specific columns for users who don't use Claude Code.
*/
export const EAGERLY_CREATED_PROPERTIES = SCHEMA
.filter((p) => p.tier === "core" || p.tier === "notion")
.map((p) => p.notionName);
export function buildViewConfiguration(propertiesByName) {
const visible = [];
const hidden = [];
for (const prop of SCHEMA) {
const found = propertiesByName[prop.notionName];
if (!found?.id)
continue;
if (prop.defaultVisibleInListView) {
visible.push({ property_id: found.id, visible: true });
}
else {
hidden.push({ property_id: found.id, visible: false });
}
}
// Visible columns first (so they sort in SCHEMA order at the left),
// then the hidden ones. `frozen_column_index` is the 0-based index of
// the last frozen column (inclusive per Notion's API): index 1 freezes
// through Description, leaving Tags + Installs scrollable.
return {
type: "table",
properties: [...visible, ...hidden],
frozen_column_index: 1,
};
}
/**
* Given a single skill's frontmatter values, return the Notion column
* names that must exist on the data source for those values to be
* writable.
*
* - `name` and `description` are always present (the database is
* created with them) so they're never returned.
* - Empty / unset values don't need a column.
* - Values matching a spec default also don't need a column — there's
* no point in surfacing a column where every row repeats the default.
*
* Used by migrate to grow the schema progressively: users only see
* columns they're actually using.
*/
export function notionPropsForSkill(properties) {
const out = new Set();
for (const [key, value] of Object.entries(properties)) {
if (key === "name" || key === "description")
continue;
if (value === undefined || value === null || value === "")
continue;
if (Array.isArray(value) && value.length === 0)
continue;
if (value === SELECT_DEFAULT)
continue;
const specDefault = SPEC_DEFAULTS[key];
if (specDefault !== undefined && value === specDefault)
continue;
const def = SCHEMA.find((p) => p.frontmatterKey === key);
if (!def)
continue;
if (def.metricOnly)
continue;
out.add(def.notionName);
}
return out;
}
//# sourceMappingURL=schema.js.map