UNPKG

@brianlovin/notion-skills

Version:

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

315 lines 12.8 kB
/** * GitHub source parsing + fetch helpers for `notion-skills add`. * * We mirror the syntax skills.sh users are already familiar with — * `owner/repo`, `owner/repo#ref`, `owner/repo@skill`, full URLs — but * scope to GitHub for v0. GitLab + generic git URLs land later if * users ask. We use the GitHub Trees API and raw.githubusercontent * for fetches; no `simple-git` dependency. * * Auth is best-effort: GITHUB_TOKEN / GH_TOKEN env, or shell out to * `gh auth token` if available. Anonymous requests work for public * repos but hit a 60-req/hour rate limit. */ import { spawnSync } from "node:child_process"; const HEAD_FRAGMENT_RE = /^([^#@]+)(?:#([^@]*))?(?:@(.+))?$/; // Regex for the path-only portion of a GitHub URL, after the URL // constructor has stripped the protocol/host/query/fragment. // `tree/<ref>/<path>` is the folder view; `blob/<ref>/<path>` is the // file view — users paste either depending on where they were in // the GitHub UI. const URL_PATH_RE = /^([^/]+)\/([^/]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?$/; const SHORT_RE = /^([^/]+)\/([^/#@]+)(?:\/(.+?))?$/; const SSH_RE = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/; /** * Parse one of the user-facing source shapes: * - `owner/repo` * - `owner/repo/subpath` * - `owner/repo#ref` * - `owner/repo@skill` * - `owner/repo#ref@skill` * - `github:owner/repo` (prefix) * - `https://github.com/owner/repo[/tree/<ref>[/<subpath>]]` * - `git@github.com:owner/repo.git` * * Throws with a friendly message on unrecognised input. Pure: no I/O. */ export function parseGitHubSource(input) { const trimmed = input.trim(); if (trimmed.length === 0) throw new Error("Empty source"); // `github:owner/repo` prefix → strip and re-parse. if (trimmed.startsWith("github:")) { return parseGitHubSource(trimmed.slice("github:".length)); } // Full URL form (https://github.com/...). Use the URL constructor // so query strings + URL fragments don't trip the path regex — // pasting `https://github.com/owner/repo?something=...` is common // when grabbing links from GitHub's filtered views. if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { let u; try { u = new URL(trimmed); } catch { throw new Error(`Unrecognised source "${input}". Use \`owner/repo\` or a GitHub URL.`); } if (u.hostname !== "github.com" && u.hostname !== "www.github.com") { throw new Error(`Only github.com URLs are supported (got ${u.hostname}).`); } const path = u.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); const m = path.match(URL_PATH_RE); if (!m) { throw new Error(`Unrecognised source "${input}". Expected \`/owner/repo\` or \`/owner/repo/(tree|blob)/<ref>[/<path>]\`.`); } const [, owner, repo, ref, subpath] = m; return cleanSource({ owner: owner, repo: repo, ref, subpath }); } // SSH form (git@github.com:owner/repo.git). const sshMatch = trimmed.match(SSH_RE); if (sshMatch) { const [, owner, repo] = sshMatch; return cleanSource({ owner: owner, repo: repo }); } // Shorthand: split off optional #ref and @skill, then match owner/repo[/subpath]. const fragmented = trimmed.match(HEAD_FRAGMENT_RE); if (!fragmented) { throw new Error(`Unrecognised source "${input}". Use \`owner/repo\` or a GitHub URL.`); } const [, head, ref, skillFilter] = fragmented; const m = head.match(SHORT_RE); if (!m) { throw new Error(`Unrecognised source "${input}". Use \`owner/repo\` or a GitHub URL.`); } const [, owner, repo, subpath] = m; return cleanSource({ owner: owner, repo: repo, ref, subpath, skillFilter }); } function cleanSource(source) { // Strip trailing `.git` from the repo name and any leading/trailing // slashes from the subpath. Reject `..` segments to prevent the // skills.sh-style path-traversal escape. const repo = source.repo.replace(/\.git$/, ""); const segments = source.subpath ?.replace(/^\/+|\/+$/g, "") .split("/") .filter((s) => s.length > 0); if (segments?.some((s) => s === "..")) { throw new Error(`Unsafe subpath "${source.subpath}" — segments cannot include "..".`); } // If the user pasted a `/blob/<ref>/.../SKILL.md` URL, the subpath // points at the file. Strip it so we end up scoped to the skill's // dir — that's the meaningful scope at the discovery layer. const trimmed = segments && segments[segments.length - 1] === "SKILL.md" ? segments.slice(0, -1) : segments; return { owner: source.owner, repo, ...(source.ref !== undefined ? { ref: safeDecodeURI(source.ref) } : {}), ...(trimmed && trimmed.length > 0 ? { subpath: trimmed.join("/") } : {}), ...(source.skillFilter !== undefined ? { skillFilter: source.skillFilter } : {}), }; } /** * decodeURIComponent throws on malformed percent escapes (e.g. a * lone `%`). For ref names we'd rather degrade gracefully — the * original raw string is more useful to the user than a crash. */ function safeDecodeURI(s) { try { return decodeURIComponent(s); } catch { return s; } } /** * Display string for the parsed source, used in `metadata.origin` * frontmatter and in CLI output. Round-trips through `parseGitHubSource`. */ export function formatSourceRef(source) { let out = `${source.owner}/${source.repo}`; if (source.subpath) out += `/${source.subpath}`; if (source.ref) out += `#${source.ref}`; if (source.skillFilter) out += `@${source.skillFilter}`; return out; } /** * Resolve the repo's default branch when the user didn't specify one. * Returns null if the repo is unreachable / private without auth. * * Optimisation path: 99% of public repos default to `main` or * (legacy) `master`. We try those branches' tree endpoints directly * — a successful 200 means the branch exists AND we now have the * tree we'd need next anyway, so we cache it. Only when both miss * do we hit /repos/<owner>/<repo> for the canonical default_branch. * * The cached tree is opportunistic — caller checks `cachedTree` and * skips the second fetchRepoTree call when present. */ export async function resolveDefaultBranch(source) { for (const candidate of ["main", "master"]) { const tree = await tryFetchRepoTree(source, candidate); if (tree) return { ref: candidate, cachedTree: tree }; } // Neither convention hit — the repo likely uses a non-standard // default branch (e.g. `develop`, `trunk`). Fall back to the // metadata endpoint. const url = `https://api.github.com/repos/${source.owner}/${source.repo}`; const res = await ghFetch(url); if (!res.ok) return null; const body = (await res.json()); if (!body.default_branch) return null; return { ref: body.default_branch, cachedTree: null }; } async function tryFetchRepoTree(source, ref) { const url = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/${ref}?recursive=1`; const res = await ghFetch(url); if (!res.ok) return null; const body = (await res.json()); const entries = (body.tree ?? []) .filter((e) => e.type === "blob" || e.type === "tree") .map((e) => ({ path: e.path, type: e.type, sha: e.sha, ...(e.size !== undefined ? { size: e.size } : {}), })); return { ref, entries, truncated: !!body.truncated }; } /** * Fetch the recursive tree for the resolved ref. Used to discover * skill dirs without cloning. The Trees API truncates at ~100k * entries; we throw a friendly message if that ever bites. */ export async function fetchRepoTree(source, ref) { const url = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/${ref}?recursive=1`; const res = await ghFetch(url); if (!res.ok) { throw new Error(`GitHub API returned ${res.status} for ${source.owner}/${source.repo} @ ${ref}. ${res.status === 404 ? "Repo, branch, or tag may not exist (or may be private — set GITHUB_TOKEN)." : ""}`); } const body = (await res.json()); const entries = (body.tree ?? []) .filter((e) => e.type === "blob" || e.type === "tree") .map((e) => ({ path: e.path, type: e.type, sha: e.sha, ...(e.size !== undefined ? { size: e.size } : {}), })); return { ref, entries, truncated: !!body.truncated }; } /** * Fetch a single file's UTF-8 content via raw.githubusercontent. * Returns null on 404 so callers can probe optional paths cleanly. */ export async function fetchFileContent(source, ref, path) { const url = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${ref}/${path}`; const res = await ghFetch(url); if (res.status === 404) return null; if (!res.ok) { throw new Error(`Failed to fetch ${path} from ${source.owner}/${source.repo}: ${res.status}`); } return await res.text(); } // ---------- auth + low-level fetch ---------- let cachedToken; function readGitHubToken() { if (cachedToken !== undefined) return cachedToken; const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; if (env && env.trim().length > 0) { cachedToken = env.trim(); return cachedToken; } // Shell out to `gh auth token` as a last resort. Suppresses stderr // because non-installed `gh` is a normal anonymous case. try { const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); if (result.status === 0 && result.stdout) { const token = result.stdout.trim(); if (token.length > 0) { cachedToken = token; return token; } } } catch { // gh not installed, etc. } cachedToken = null; return null; } /** * Thrown by ghFetch when GitHub responds with a rate-limit signal — * either an explicit 429 or a 403 with `x-ratelimit-remaining: 0`. * The CLI surfaces the message + a hint to set GITHUB_TOKEN, and * (in `add`'s per-skill loop) lets the rest of the batch continue. */ export class GitHubRateLimitError extends Error { constructor(message) { super(message); this.name = "GitHubRateLimitError"; } } /** * Per-fetch timeout. 10s is comfortable for SKILL.md (~10KB) over a * normal network. Override via NOTION_SKILLS_FETCH_TIMEOUT_MS for * slow connections or unusually large skill files. Clamped to * [1s, 5min] to prevent silly values from breaking things. */ const FETCH_TIMEOUT_MS = (() => { const raw = process.env.NOTION_SKILLS_FETCH_TIMEOUT_MS; if (!raw) return 10_000; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n < 1_000) return 10_000; return Math.min(n, 5 * 60 * 1_000); })(); async function ghFetch(url) { const headers = { "User-Agent": "notion-skills", Accept: "application/vnd.github+json", }; const token = readGitHubToken(); if (token) headers.Authorization = `Bearer ${token}`; // AbortSignal.timeout (Node 17.3+) caps any single fetch — a hung // CDN or stuck connection can't block the whole add forever. let res; try { res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); } catch (err) { if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) { throw new Error(`GitHub fetch timed out after ${FETCH_TIMEOUT_MS / 1000}s (${url}).`); } throw err; } // GitHub returns 403 with x-ratelimit-remaining: 0 for anonymous // rate limits, and 429 for secondary limits. Both look like // "request failed" but the user's recovery is the same: wait, or // set GITHUB_TOKEN. Distinguish them from 404 ("file missing") // which is a normal probe outcome. if (res.status === 429 || (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0")) { const reset = res.headers.get("x-ratelimit-reset"); const resetMsg = reset ? ` Resets at ${new Date(Number(reset) * 1000).toLocaleTimeString()}.` : ""; throw new GitHubRateLimitError(`GitHub rate limit hit (${res.status}).${resetMsg} Set GITHUB_TOKEN (or run \`gh auth login\`) for higher limits.`); } return res; } //# sourceMappingURL=github.js.map