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
246 lines (245 loc) • 10.7 kB
JavaScript
// 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 { isWithin } from "../core/common.js";
import { warnOnce } from "../core/warn.js";
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Key to check in package.json for akm include configuration. */
const INCLUDE_CONFIG_KEYS = ["akm"];
function readPackageJsonAt(dirPath) {
try {
const raw = fs.readFileSync(path.join(dirPath, "package.json"), "utf8");
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
return undefined;
}
catch {
return undefined;
}
}
function extractIncludeList(pkg) {
if (!pkg)
return undefined;
for (const key of INCLUDE_CONFIG_KEYS) {
const config = pkg[key];
if (typeof config !== "object" || config === null || Array.isArray(config))
continue;
const { include } = config;
if (!Array.isArray(include))
continue;
const list = include
.filter((v) => typeof v === "string")
.map((v) => v.trim())
.filter(Boolean);
if (list.length > 0)
return list;
}
return undefined;
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Walk up the directory tree from `startDir` to `boundary` (inclusive) looking
* for a package.json that declares an `akm.include` list.
* Returns the first config found, or `undefined` if none is found within the
* boundary.
*/
export function findNearestIncludeConfig(startDir, boundary) {
let current = path.resolve(startDir);
const resolvedBoundary = path.resolve(boundary);
while (isWithin(current, resolvedBoundary)) {
const pkg = readPackageJsonAt(current);
const include = extractIncludeList(pkg);
if (include && include.length > 0) {
return { baseDir: current, include };
}
if (current === resolvedBoundary)
break;
const parent = path.dirname(current);
if (parent === current)
break;
current = parent;
}
return undefined;
}
/**
* Does `entry` use glob syntax (`*`/`?`)? Everything else is a literal path,
* resolved and copied exactly as before — this only changes behavior for
* entries that actually contain a wildcard.
*/
function isIncludeEntryPattern(entry) {
return /[*?]/.test(entry);
}
/**
* Translate one `akm.include` entry to a regular expression matched against a
* `/`-joined path relative to `sourceDir`. Supports the subset package.json
* authors reach for in practice: `*` (any run of characters within one path
* segment), `?` (one character), and `**` (any run of characters across
* segments, including none) — e.g. `skills/*` or `skills/**\/*.md`. Not a
* full glob implementation (no brace expansion, character classes, or
* negation); those fall through to being treated as a literal, almost
* certainly non-matching path, same as an ordinary typo would.
*/
function includeEntryToRegExp(entry) {
const normalized = entry.split(path.sep).join("/");
let pattern = "";
for (let i = 0; i < normalized.length;) {
if (normalized.startsWith("**", i)) {
const consumesSlash = normalized[i + 2] === "/";
pattern += consumesSlash ? "(?:.*/)?" : ".*";
i += consumesSlash ? 3 : 2;
continue;
}
const char = normalized[i];
if (char === "*")
pattern += "[^/]*";
else if (char === "?")
pattern += "[^/]";
else
pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
i += 1;
}
return new RegExp(`^${pattern}$`);
}
/** A symlink whose lstat succeeds but whose target does not exist — `copyPath` reports this case with its own specific warning. */
function isBrokenSymlink(candidate) {
try {
return fs.lstatSync(candidate).isSymbolicLink();
}
catch {
return false;
}
}
/** Every path under `dir`, `/`-joined and relative to `base`, without following symlinked directories. */
function listAllRelativePaths(dir, base) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch {
return out;
}
for (const entry of entries) {
if (entry.name === ".git")
continue;
const absolute = path.join(dir, entry.name);
out.push(path.relative(base, absolute).split(path.sep).join("/"));
// `isDirectory()` on a `Dirent` reflects lstat, so a symlinked directory
// is listed as an entry (and handled by copyPath's own symlink handling)
// but never recursed into here.
if (entry.isDirectory())
out.push(...listAllRelativePaths(absolute, base));
}
return out;
}
/**
* Resolve one `akm.include` entry to the absolute candidate paths it names.
* A literal entry resolves to itself, whether or not it exists on disk — the
* caller checks that (after re-verifying containment, which needs to see
* the literal candidate regardless). A pattern entry expands to every
* existing matching path under `sourceDir`, so matching nothing already
* naturally comes back as an empty array.
*/
function resolveIncludeEntry(entry, sourceDir) {
if (!isIncludeEntryPattern(entry))
return [path.resolve(sourceDir, entry)];
const pattern = includeEntryToRegExp(entry);
return listAllRelativePaths(sourceDir, sourceDir)
.filter((relative) => pattern.test(relative))
.map((relative) => path.join(sourceDir, relative));
}
/**
* Copy each glob/path in `includeGlobs` from `sourceDir` to `destDir`.
*
* Uses `isWithin()` to prevent path-traversal attacks: any entry that escapes
* `sourceDir` throws immediately — this is the operator's own config
* (`akm.include` in a package.json under a source they added), not
* discovered content, so a deliberate `../` here is treated as the mistake
* (or attack) it looks like, not skipped.
*
* A missing entry, or a pattern that matches nothing, is skipped with a
* warning instead of failing the whole install — one bad entry in a large
* `include` list should not leave zero assets installed.
*/
export function copyIncludedPaths(includeGlobs, sourceDir, destDir) {
for (const entry of includeGlobs) {
const resolvedPaths = resolveIncludeEntry(entry, sourceDir);
if (resolvedPaths.length === 0) {
warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
continue;
}
for (const resolvedSource of resolvedPaths) {
if (!isWithin(resolvedSource, sourceDir)) {
throw new Error(`Path in akm.include escapes the package root: ${entry}`);
}
if (!fs.existsSync(resolvedSource) && !isBrokenSymlink(resolvedSource)) {
warnOnce(`akm-include-missing:${sourceDir}:${entry}`, `[akm] Skipping "${entry}" in akm.include: no matching file or directory.`);
continue;
}
if (path.basename(resolvedSource) === ".git")
continue;
const relativePath = path.relative(sourceDir, resolvedSource);
if (!relativePath || relativePath === ".") {
copyDirectoryContents(sourceDir, destDir, sourceDir);
continue;
}
copyPath(resolvedSource, path.join(destDir, relativePath), sourceDir);
}
}
}
// ── Private helpers ─────────────────────────────────────────────────────────
function copyDirectoryContents(sourceDir, destinationDir, containmentRoot) {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (entry.name === ".git")
continue;
copyPath(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name), containmentRoot);
}
}
/**
* Copy one path. A symlink is followed and its TARGET's content copied
* (never re-materialized as a symlink) rather than refused outright — a
* published package with, say, `README.md -> docs/README.md` is ordinary,
* not an attack. The resolved target must still stay within
* `containmentRoot`: this is the same containment guarantee `isWithin`
* enforces on every literal `akm.include` entry, just applied to a target we
* discovered by following a link rather than one the operator typed — a
* symlink escaping the package (to `/etc/passwd`, say) is skipped with a
* warning, not copied.
*/
function copyPath(sourcePath, destinationPath, containmentRoot) {
const stat = fs.lstatSync(sourcePath);
if (stat.isSymbolicLink()) {
let resolved;
try {
resolved = fs.realpathSync(sourcePath);
}
catch {
warnOnce(`akm-include-broken-symlink:${sourcePath}`, `[akm] Skipping broken symlink in akm.include: ${path.relative(containmentRoot, sourcePath)}`);
return;
}
if (!isWithin(resolved, containmentRoot)) {
warnOnce(`akm-include-symlink-escape:${sourcePath}`, `[akm] Skipping symlink in akm.include that points outside the package: ${path.relative(containmentRoot, sourcePath)}`);
return;
}
const resolvedStat = fs.statSync(resolved);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
if (resolvedStat.isDirectory()) {
fs.mkdirSync(destinationPath, { recursive: true });
copyDirectoryContents(resolved, destinationPath, containmentRoot);
return;
}
fs.copyFileSync(resolved, destinationPath);
return;
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
if (stat.isDirectory()) {
fs.mkdirSync(destinationPath, { recursive: true });
copyDirectoryContents(sourcePath, destinationPath, containmentRoot);
return;
}
fs.copyFileSync(sourcePath, destinationPath);
}