@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
165 lines • 6.25 kB
JavaScript
import chalk from "chalk";
import { existsSync, readdirSync, statSync } 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 { auditSkill, loadAuditTarget, summariseIssues, } from "../audit.js";
/**
* Run the audit rules against every skill on this machine. Default
* scope: every local skill (installed + drafts). `--drafts` /
* `--installed` / `--source` narrow the scope. JSON output for
* automation; otherwise a tabular summary.
*
* Hard errors exit non-zero; warnings + infos do not (matches `npm
* audit` behavior).
*/
export async function auditCommand(args, options = {}) {
const scope = await getScope();
if (!scope) {
throw new Error("No scope configured. Run `notion-skills init` first.");
}
const manifest = await loadManifest(scope.sources);
const targets = await collectTargets(args, options, manifest);
if (targets.length === 0) {
console.log(chalk.dim("No skills match the audit scope."));
return;
}
const reports = targets.map((t) => ({
localSlug: t.target.localSlug,
state: t.state,
source_key: t.source_key,
issues: auditSkill(t.target),
}));
if (options.json) {
console.log(JSON.stringify(reports, null, 2));
if (reports.some((r) => r.issues.some((i) => i.severity === "error"))) {
process.exit(1);
}
return;
}
renderReports(reports);
if (reports.some((r) => r.issues.some((i) => i.severity === "error"))) {
process.exit(1);
}
}
async function collectTargets(args, options, manifest) {
// Explicit slugs: audit just those (one or more).
if (args.length > 0) {
const out = [];
for (const slug of args) {
const dir = join(SKILLS_STORE, slug);
if (!existsSync(join(dir, "SKILL.md"))) {
throw new Error(`Skill "${slug}" not found on this machine. Run \`notion-skills list\` to see what's installed.`);
}
const target = await loadAuditTarget(slug, dir);
if (!target)
continue;
const entry = manifest?.skills[slug];
out.push({
target,
state: entry ? "installed" : "draft",
source_key: entry?.source_key ?? null,
});
}
return out;
}
// No explicit slugs: walk the central store.
if (!existsSync(SKILLS_STORE))
return [];
const entries = readdirSync(SKILLS_STORE).filter((name) => {
if (name.startsWith("."))
return false;
try {
return statSync(join(SKILLS_STORE, name)).isDirectory();
}
catch {
return false;
}
});
const out = [];
for (const localSlug of entries) {
const dir = join(SKILLS_STORE, localSlug);
const target = await loadAuditTarget(localSlug, dir);
if (!target)
continue;
const entry = manifest?.skills[localSlug];
const state = entry ? "installed" : "draft";
const source_key = entry?.source_key ?? null;
if (options.drafts && state !== "draft")
continue;
if (options.installed && state !== "installed")
continue;
if (options.source && source_key !== options.source)
continue;
out.push({ target, state, source_key });
}
return out;
}
function renderReports(reports) {
console.log(chalk.bold("notion-skills audit"));
console.log(chalk.dim("─".repeat(50)));
console.log("");
let totalErrors = 0;
let totalWarnings = 0;
let totalInfos = 0;
let cleanCount = 0;
for (const r of reports) {
const summary = summariseIssues(r.issues);
totalErrors += summary.errors;
totalWarnings += summary.warnings;
totalInfos += summary.infos;
if (r.issues.length === 0) {
cleanCount++;
// Only show clean rows when total skill count is small; otherwise
// they crowd out the issues. Still print under -v in the future.
if (reports.length <= 10) {
console.log(`${chalk.green("✓")} ${r.localSlug} ${chalk.dim("clean")}`);
}
continue;
}
const tags = [];
if (summary.errors > 0)
tags.push(chalk.red(`${summary.errors} ${pluralise("error", summary.errors)}`));
if (summary.warnings > 0)
tags.push(chalk.yellow(`${summary.warnings} ${pluralise("warning", summary.warnings)}`));
if (summary.infos > 0)
tags.push(chalk.cyan(`${summary.infos} info`));
console.log(`${markFor(r.issues)} ${chalk.bold(r.localSlug)} ${chalk.dim(`[${tags.join(", ")}]`)}`);
for (const issue of r.issues) {
const sev = severityLabel(issue.severity);
const id = chalk.dim(issue.ruleId.padEnd(28));
const lineHint = issue.line ? chalk.dim(` (line ${issue.line})`) : "";
console.log(` ${sev} ${id} ${issue.message}${lineHint}`);
}
}
console.log("");
const parts = [`${reports.length} ${pluralise("skill", reports.length)} audited`];
if (cleanCount > 0)
parts.push(`${cleanCount} clean`);
if (totalErrors > 0)
parts.push(chalk.red(`${totalErrors} ${pluralise("error", totalErrors)}`));
if (totalWarnings > 0)
parts.push(chalk.yellow(`${totalWarnings} ${pluralise("warning", totalWarnings)}`));
if (totalInfos > 0)
parts.push(chalk.cyan(`${totalInfos} info`));
console.log(chalk.dim(parts.join(" · ")));
}
function severityLabel(s) {
if (s === "error")
return chalk.red("error");
if (s === "warning")
return chalk.yellow("warn ");
return chalk.cyan("info ");
}
function markFor(issues) {
if (issues.some((i) => i.severity === "error"))
return chalk.red("✗");
if (issues.some((i) => i.severity === "warning"))
return chalk.yellow("⚠");
return chalk.cyan("ℹ");
}
function pluralise(word, n) {
return n === 1 ? word : `${word}s`;
}
//# sourceMappingURL=audit.js.map