@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
141 lines • 6.59 kB
JavaScript
import chalk from "chalk";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getScope } from "../scope.js";
import { loadManifest } from "../manifest.js";
import { SKILLS_STORE } from "../paths.js";
import { NotionClient } from "../notion.js";
import { assertNtnInstalled } from "../ntn.js";
import { parseSkillRef } from "../sources.js";
import { findPageInSource } from "./_resolve.js";
import { withSpinner } from "./_progress.js";
import { formatRelativeTime } from "../time.js";
/**
* Read or post comments on a skill's Notion page. Comments are how
* teammates and agents leave feedback without leaving their workflow —
* the maintainer sees them where they already live (in Notion).
*
* - `feedback <slug>` — list existing comments newest-first
* - `feedback <slug> "<message>"` — post a comment
*
* Slug resolution mirrors `open`: prefer the manifest's local_slug,
* fall back to scanning configured sources by slugified title. Drafts
* (no Notion page) error out with a hint to publish first.
*
* Author attribution is intentionally absent from the CLI output —
* Personal Access Tokens (which `ntn login` produces) can only resolve
* the calling user's identity, so we'd be rendering "you" vs "someone"
* which is more misleading than informative. Notion's UI shows the
* full author of every comment; jump there with `notion-skills open`
* if attribution matters.
*/
export async function feedbackCommand(slug, messageParts, opts) {
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
if (scope.sources.length === 0) {
throw new Error("No sources configured. Run `notion-skills source add` to link a Notion database.");
}
await assertNtnInstalled();
// Slugs are lowercase by convention (slugify lowercases titles); be
// permissive on input so case mismatches don't cause "not found."
const normalisedSlug = slug.toLowerCase();
// Distinguish read-mode (no message provided at all) from a typo'd
// post (message provided but whitespace-only after trim). Falling
// through to read silently would surprise the user who clearly
// wanted to post.
const messageProvided = messageParts.length > 0;
const message = messageParts.join(" ").trim();
if (messageProvided && !message) {
throw new Error("Comment is empty after trimming. Pass a non-empty message, or drop the argument to read existing comments.");
}
const resolved = await resolvePage(normalisedSlug, scope.sources, opts.source);
const client = new NotionClient();
if (message) {
await postFeedback(client, resolved, message);
return;
}
await readFeedback(client, resolved);
}
async function resolvePage(slug, sources, sourceFlag) {
const ref = parseSkillRef(slug);
// Explicit `--source` or `team/deploy` qualified ref both narrow
// search to a single source.
const sourceKey = sourceFlag ?? ref.sourceKey;
const candidates = sourceKey
? sources.filter((s) => s.key === sourceKey)
: sources;
if (sourceKey && candidates.length === 0) {
throw new Error(`Unknown source "${sourceKey}". Configured sources: ${sources.map((s) => s.key).join(", ")}.`);
}
// Fast path: manifest hit on local_slug. The manifest's source_key
// is what tells us which Source the page lives in.
const manifest = await loadManifest(sources);
const entry = manifest?.skills[ref.slug];
if (entry) {
const source = sources.find((s) => s.key === entry.source_key);
if (source && (!sourceKey || source.key === sourceKey)) {
return {
pageId: entry.page_id,
source,
ref: `${source.key}/${entry.source_slug}`,
};
}
}
// Local draft (on disk, no manifest entry) → no Notion page yet.
if (existsSync(join(SKILLS_STORE, ref.slug))) {
throw new Error(`${ref.slug} is a local draft — there's no Notion page to comment on yet. Run \`notion-skills publish ${ref.slug}\` first.`);
}
// Slow path: scan source data sources for a matching slugified title.
const client = new NotionClient();
for (const s of candidates) {
const found = await withSpinner(`Searching ${s.key} for "${ref.slug}"`, () => findPageInSource(client, s, ref.slug));
if (found) {
return { pageId: found, source: s, ref: `${s.key}/${ref.slug}` };
}
}
throw new Error(`Skill "${slug}" not found. Run \`notion-skills list\` to see what's in the store.`);
}
async function postFeedback(client, resolved, message) {
const comment = await withSpinner(`Posting comment on ${resolved.ref}`, () => client.postComment(resolved.pageId, message));
const url = notionPageUrl(resolved.pageId);
console.log();
console.log(chalk.green("✓") + ` Posted to ${chalk.bold(resolved.ref)}`);
console.log(` ${chalk.dim(url)}`);
// Suppress unused warning — `comment` is the postComment response;
// we don't need its id beyond confirming the call succeeded.
void comment;
}
async function readFeedback(client, resolved) {
const comments = await withSpinner(`Reading comments on ${resolved.ref}`, () => client.listComments(resolved.pageId), { noteFor: (c) => `${c.length} comment${c.length === 1 ? "" : "s"}` });
console.log();
console.log(chalk.bold(`Comments on ${resolved.ref}`));
console.log(chalk.dim(notionPageUrl(resolved.pageId)));
console.log();
if (comments.length === 0) {
console.log(chalk.dim(" No comments yet."));
console.log();
}
else {
// Newest-first reads more naturally — most recent feedback is what
// the user wants to see when they're triaging a skill.
for (const c of [...comments].reverse())
renderComment(c);
}
console.log(chalk.dim(`To post: ${chalk.cyan(`notion-skills feedback ${resolved.ref} "<message>"`)}`));
}
function renderComment(comment) {
const when = formatRelativeTime(new Date(comment.created_time));
const body = comment.rich_text.map((r) => r.plain_text).join("").trim();
console.log(` ${chalk.dim(when)}`);
// Indent each line of the body so multi-line comments stay grouped.
for (const line of body.split("\n")) {
console.log(` ${line}`);
}
console.log();
}
function notionPageUrl(pageId) {
return `https://www.notion.so/${pageId.replace(/-/g, "")}`;
}
//# sourceMappingURL=feedback.js.map