@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
150 lines • 6.02 kB
JavaScript
import chalk from "chalk";
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
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 { findPageInSource } from "./_resolve.js";
/**
* Open a skill — in Notion (default), in a local editor, or reveal
* its directory in the OS file manager.
*
* Modes (mutually exclusive; at most one):
* - default : open the Notion page URL in the user's browser
* - --local : open SKILL.md with $VISUAL || $EDITOR (fallback `vi`)
* - --with <cmd> : open SKILL.md with `<cmd> <path>` (shell parses cmd)
* - -a / --app <n> : macOS-only sugar for `open -a "<n>" <path>`
* - --reveal : open the skill's directory in Finder / xdg-open
*
* The default targets Notion because that's what only this CLI can do
* — `vi`, `code`, `xdg-open` are already on the user's PATH.
*/
export async function openCommand(slug, opts) {
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
const modes = [
opts.local ? "local" : null,
opts.with ? "with" : null,
opts.app ? "app" : null,
opts.reveal ? "reveal" : null,
].filter(Boolean);
if (modes.length > 1) {
throw new Error(`--local, --with, -a/--app, and --reveal are mutually exclusive (got ${modes.join(", ")}).`);
}
const skillDir = join(SKILLS_STORE, slug);
const skillFile = join(skillDir, "SKILL.md");
// Default mode: open the Notion page. Fast path uses the manifest
// (no network call). For uninstalled skills we fall back to a
// Notion query so users can preview store skills before installing.
// Drafts can't be opened in Notion (they don't exist there yet) —
// point the user at --local.
if (modes.length === 0) {
const manifest = await loadManifest(scope.sources);
const entry = manifest?.skills[slug];
let pageId = entry?.page_id;
if (!pageId) {
if (existsSync(skillDir)) {
throw new Error(`${slug} is a local draft — there's no Notion page yet. Open the file with \`notion-skills open ${slug} --local\` (or publish first).`);
}
// Search every configured source for a matching slug.
await assertNtnInstalled();
const client = new NotionClient();
let found = null;
for (const s of scope.sources) {
found = await findPageInSource(client, s, slug);
if (found)
break;
}
if (!found) {
throw new Error(`Skill "${slug}" not found. Run \`notion-skills list\` to see what's in the store, or check the slug.`);
}
pageId = found;
}
const url = notionPageUrl(pageId);
console.log(chalk.dim(`Opening ${url}`));
await launch(platformOpener(), [url]);
return;
}
// All other modes operate on the local file. Slug must be on disk.
if (!existsSync(skillDir)) {
throw new Error(`${slug} is not on this machine. Run \`notion-skills install ${slug}\` first.`);
}
if (opts.reveal) {
console.log(chalk.dim(`Revealing ${skillDir}`));
await launch(platformOpener(), [skillDir]);
return;
}
if (!existsSync(skillFile)) {
throw new Error(`${skillFile} does not exist (skill directory is empty).`);
}
if (opts.app) {
if (process.platform !== "darwin") {
throw new Error(`-a/--app is macOS-only. Use \`--with <command>\` instead (e.g. \`--with cursor\`).`);
}
console.log(chalk.dim(`Opening ${skillFile} in ${opts.app}`));
await launch("open", ["-a", opts.app, skillFile]);
return;
}
if (opts.with) {
const cmd = `${opts.with} ${shellQuote(skillFile)}`;
console.log(chalk.dim(`$ ${opts.with} ${skillFile}`));
await launchShell(cmd);
return;
}
if (opts.local) {
const editor = process.env.VISUAL ?? process.env.EDITOR ?? "vi";
const cmd = `${editor} ${shellQuote(skillFile)}`;
console.log(chalk.dim(`$ ${editor} ${skillFile}`));
await launchShell(cmd);
return;
}
}
function notionPageUrl(pageId) {
return `https://www.notion.so/${pageId.replace(/-/g, "")}`;
}
function platformOpener() {
return process.platform === "darwin" ? "open" : "xdg-open";
}
function shellQuote(p) {
// Single-quote and escape embedded single quotes (POSIX-safe).
return `'${p.replace(/'/g, "'\\''")}'`;
}
/**
* Spawn a command with explicit args and inherit stdio so terminal
* editors (vim, nano) work cleanly. Resolves on exit; rejects on
* non-zero. Used for `open <url>`, `open -a App file`, `xdg-open dir`.
*/
function launch(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0 || code === null)
resolve();
else
reject(new Error(`${command} exited with code ${code}`));
});
});
}
/**
* Shell-mode spawn, used for `--with` and `--local` so users can pass
* complex commands like `--with "code -n"` or `EDITOR='code -n'`.
*/
function launchShell(cmd) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, [], { stdio: "inherit", shell: true });
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0 || code === null)
resolve();
else
reject(new Error(`Command exited with code ${code}`));
});
});
}
//# sourceMappingURL=open.js.map