UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

158 lines (157 loc) • 6.69 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import fs from "node:fs"; import path from "node:path"; import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" }; import { getDirname } from "../../runtime.js"; const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md"; const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md"; /** * Directory containing per-version release notes. Resolved relative to * `import.meta.dir` so the lookup works whether this module is running * from source (`<repo>/src`) or from the published build (`<pkg>/dist`). * The `docs/migration/release-notes/` directory is shipped via the * `files[]` array in `package.json`. */ function releaseNotesDir() { return path.resolve(getDirname(import.meta.url), "../../../docs/migration/release-notes"); } function loadChangelog() { try { const changelogPath = path.resolve(getDirname(import.meta.url), "../../../CHANGELOG.md"); if (fs.existsSync(changelogPath)) { return fs.readFileSync(changelogPath, "utf8"); } } catch { // fall through to the embedded copy } // In the `bun build --compile` standalone binary, import.meta.url points into // the virtual /$bunfs tree and every existsSync above misses, so `akm help // migrate <version>` degraded to the generic "no dedicated note" message for // EVERY version. Only assets imported `with { type: "text" }` are embedded. return embeddedChangelog.length > 0 ? embeddedChangelog : undefined; } /** * Load the bundled migration note for a specific version, if one exists. * Returns the file body verbatim (no transformations). Missing files, * permission errors, and non-file entries all return `undefined` — * callers are responsible for the fallback message. */ function loadReleaseNote(version) { if (!isSafeVersionComponent(version)) return undefined; const notePath = path.join(releaseNotesDir(), `${version}.md`); try { if (!fs.existsSync(notePath)) return undefined; const stat = fs.statSync(notePath); if (!stat.isFile()) return undefined; return fs.readFileSync(notePath, "utf8"); } catch { return undefined; } } /** * Restrict version lookups to strings that are safe as a single path * segment. Accepts typical semver forms (`0.6.0`, `0.6.0-rc1`, * `0.6.0+build.5`) and rejects anything with slashes, `..`, or control * characters that would let a crafted input escape the release-notes * directory. */ function isSafeVersionComponent(version) { if (!version || version.length > 64) return false; return /^[A-Za-z0-9._+-]+$/.test(version) && !version.includes(".."); } function normalizeRequestedVersion(input) { const value = input.trim(); if (!value) return value; if (value.toLowerCase() === "latest") return "latest"; const withoutV = value.replace(/^v/i, ""); return withoutV; } function versionCandidates(requested) { if (requested === "latest") return ["latest"]; const exact = requested; const stable = requested.replace(/[-+].*$/, ""); return stable === exact ? [exact] : [exact, stable]; } function resolveLatestVersion(changelog) { for (const match of changelog.matchAll(/^## \[([^\]]+)\]/gm)) { const version = match[1]; if (version !== "Unreleased") return version; } return undefined; } function extractChangelogSection(changelog, version) { // `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated // the section at the first capital Z in the body (and failed outright for the // last entry). `$` with the `m` flag would stop at the first line end, so the // end-of-input alternative has to be an explicit lookahead for the input end. const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m"); const match = changelog.match(pattern); if (!match) return undefined; return `## [${version}]\n${match[1].trim()}\n`; } function escapeRegexString(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function fallbackGuide(version) { const bundled = loadReleaseNote(version); if (bundled) return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`; const available = listBundledReleaseVersions(); const availableLine = available.length ? `\nAvailable bundled notes: ${available.join(", ")}\n` : ""; return (`No dedicated migration note is bundled for akm v${version}.\n${availableLine}\n` + `See the full changelog: ${CHANGELOG_URL}\n` + `Longform migration guide: ${MIGRATION_DOC_URL}\n`); } export function renderMigrationHelp(versionInput, changelogText = loadChangelog()) { const requested = normalizeRequestedVersion(versionInput); if (!requested) { return `Version is required.\n\nUsage: akm help migrate <version>\n`; } const resolvedLatest = changelogText ? resolveLatestVersion(changelogText) : undefined; const candidates = requested === "latest" && resolvedLatest ? [resolvedLatest] : versionCandidates(requested); if (changelogText) { for (const candidate of candidates) { const section = extractChangelogSection(changelogText, candidate); const bundled = loadReleaseNote(candidate); if (bundled) { if (!section) return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`; return `${bundled.trim()}\n\nRelease notes\n-------------\n${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`; } if (section) { return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`; } } } const fallbackVersion = candidates.find((candidate) => candidate !== "latest") ?? requested; return fallbackGuide(fallbackVersion); } /** Test-only helper — list every version with a bundled release note. */ export function listBundledReleaseVersions() { try { const dir = releaseNotesDir(); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((name) => name.endsWith(".md") && name !== "README.md") .map((name) => name.slice(0, -".md".length)) .sort(); } catch { return []; } }