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
476 lines (475 loc) • 22.8 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/.
/**
* Centralized path resolution for all akm directories.
*
* Provides platform-aware paths for config, cache, and stash directories,
* following XDG Base Directory conventions on Unix and standard locations
* on Windows.
*/
import os from "node:os";
import path from "node:path";
import { shortHash } from "./bundle-id.js";
import { ConfigError } from "./errors.js";
import { IS_WINDOWS } from "./platform.js";
import { warnOnce } from "./warn.js";
/**
* Returns true when the current process appears to be running under
* `bun test` (either via the BUN_TEST sentinel Bun sets on the test
* worker, or via the conventional NODE_ENV=test).
*
* Used by getDataDir to enforce that every test which resolves a data
* directory ALSO sets XDG_DATA_HOME (or the AKM_DATA_DIR override) to a
* temp directory. Without that pairing, tests silently write SQLite
* databases, lockfiles, and task history into the developer's real
* `~/.local/share/akm`.
*/
function isUnderBunTest(env) {
return env.BUN_TEST === "1" || env.NODE_ENV === "test";
}
/**
* Returns true when the given path is in a directory family the OS may
* reap (or that the user has clearly designated as a sandbox by virtue
* of placing it under `/tmp` or a macOS per-user temp dir). Used to
* decide whether `AKM_BUNDLE_DIR=$tmpdir` should also isolate config +
* cache writes (so a test harness's `akm setup --yes --dir .` cannot
* silently clobber the user's `~/.config/akm/config.json`). See
* the 2026-05-23 setup-clobbers-user-config incident
* for the incident that motivated this.
*
* Both `/var/folders/*` and `/private/var/folders/*` are matched because
* `os.tmpdir()` on macOS may return either form depending on whether the
* caller has canonicalised the path (the realpath of `/var/folders` is
* `/private/var/folders`, but `path.resolve()` does not follow symlinks).
*/
export function isTransientStashPath(p) {
return (p.startsWith("/tmp/") ||
p === "/tmp" ||
p.startsWith("/var/tmp/") ||
p === "/var/tmp" ||
p.startsWith("/private/tmp/") ||
p.startsWith("/private/var/folders/") ||
p.startsWith("/var/folders/"));
}
/**
* Build a TEST_ISOLATION_MISSING ConfigError describing which env var(s)
* must be set so the data path resolves into a temp dir instead of the
* user's real XDG home.
*/
function testIsolationError() {
return new ConfigError("Refusing to resolve data directory under bun test: neither XDG_DATA_HOME nor AKM_DATA_DIR is set. " +
"This guards against tests writing into the developer's real ~/.local/share/akm. " +
"Set XDG_DATA_HOME (or AKM_DATA_DIR) to a mktemp-d directory in this test's env block.", "TEST_ISOLATION_MISSING");
}
// ── Config directory ─────────────────────────────────────────────────────────
export function getConfigDir(env = process.env, platform = process.platform) {
const override = env.AKM_CONFIG_DIR?.trim();
if (override)
return override;
// Explicit XDG override wins next — tests and operators that pre-arrange
// an isolated config dir via XDG_CONFIG_HOME (or %APPDATA% on Windows)
// must be honored as set, so the AKM_BUNDLE_DIR transient-isolation rule
// below does not silently move config away from where they pointed it.
if (platform === "win32") {
const appData = env.APPDATA?.trim();
if (appData)
return path.join(appData, "akm");
}
else {
const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
if (xdgConfigHome)
return path.join(xdgConfigHome, "akm");
}
// Isolation safety: when AKM_BUNDLE_DIR points at a transient/sandbox path
// (/tmp, /var/tmp, /private/var/folders) AND no explicit config dir
// override is set, route config writes into `${AKM_BUNDLE_DIR}/.akm`
// instead of the user's host ~/.config/akm. This prevents the documented
// isolation pattern
// AKM_DATA_DIR=/tmp/x AKM_BUNDLE_DIR=/tmp/x akm setup --yes --dir .
// from silently clobbering the host config. See
// the 2026-05-23 setup-clobbers-user-config incident.
// Daily users with a persistent AKM_BUNDLE_DIR=~/my-stash are unaffected.
const stashOverride = env.AKM_BUNDLE_DIR?.trim();
if (stashOverride && isTransientStashPath(stashOverride)) {
return path.join(stashOverride, ".akm");
}
if (platform === "win32") {
const appData = env.APPDATA?.trim();
if (appData)
return path.join(appData, "akm");
const userProfile = env.USERPROFILE?.trim();
if (!userProfile) {
throw new ConfigError("Unable to determine config directory. Set APPDATA or USERPROFILE.", "CONFIG_DIR_UNRESOLVABLE");
}
return path.join(userProfile, "AppData", "Roaming", "akm");
}
const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
if (xdgConfigHome)
return path.join(xdgConfigHome, "akm");
const home = env.HOME?.trim();
if (!home) {
throw new ConfigError("Unable to determine config directory. Set XDG_CONFIG_HOME or HOME.", "CONFIG_DIR_UNRESOLVABLE");
}
return path.join(home, ".config", "akm");
}
export function getConfigPath(env = process.env) {
return path.join(getConfigDir(env), "config.json");
}
// ── Cache directory ──────────────────────────────────────────────────────────
export function getCacheDir(env = process.env) {
const override = env.AKM_CACHE_DIR?.trim();
if (override)
return override;
// Explicit XDG/platform overrides win before the transient-stash isolation
// rule below — tests and operators that pre-arrange XDG_CACHE_HOME (or
// %LOCALAPPDATA% / %USERPROFILE% / %APPDATA% on Windows) must be honored
// as set, so the AKM_BUNDLE_DIR transient rule does not silently move cache
// writes away from where they pointed them.
if (IS_WINDOWS) {
const localAppData = env.LOCALAPPDATA?.trim();
if (localAppData)
return path.join(localAppData, "akm");
const userProfile = env.USERPROFILE?.trim();
if (userProfile)
return path.join(userProfile, "AppData", "Local", "akm");
const appData = env.APPDATA?.trim();
if (appData) {
// Heuristic fallback: APPDATA points to %APPDATA% (Roaming), so
// navigate to the sibling "Local" directory. This is typically
// C:\Users\<name>\AppData\Roaming → C:\Users\<name>\AppData\Local\akm.
// Preferred: set LOCALAPPDATA to avoid this navigation.
return path.join(appData, "..", "Local", "akm");
}
}
else {
const xdgCacheHome = env.XDG_CACHE_HOME?.trim();
if (xdgCacheHome)
return path.join(xdgCacheHome, "akm");
}
// Isolation safety (mirrors getConfigDir): when AKM_BUNDLE_DIR points at a
// transient path AND no explicit cache override is set, route cache writes
// into `${AKM_BUNDLE_DIR}/.akm/cache` so that config backups, registry-index
// cache, and other regenerable artifacts do not pollute the user's host
// ~/.cache/akm directory.
const stashOverride = env.AKM_BUNDLE_DIR?.trim();
if (stashOverride && isTransientStashPath(stashOverride)) {
return path.join(stashOverride, ".akm", "cache");
}
if (IS_WINDOWS) {
// None of LOCALAPPDATA / USERPROFILE / APPDATA were set above.
throw new ConfigError("Unable to determine cache directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
}
const home = env.HOME?.trim();
if (!home)
return homelessFallbackDir("akm-cache");
return path.join(home, ".cache", "akm");
}
/**
* Last-resort directory when neither the XDG variable nor HOME is set.
*
* Scoped by uid. A fixed `/tmp/akm-<kind>` path is world-shared and entirely
* predictable: on a multi-user host the first uid to run akm owns the
* directory and every other user then reads and writes the same databases, and
* any local user can pre-create the path (or a symlink at it) and wait. Adding
* the uid gives each account its own path; the caller still creates it with
* restrictive permissions.
*/
function homelessFallbackDir(kind) {
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
return path.join(os.tmpdir(), uid === undefined ? kind : `${kind}-${uid}`);
}
// ── Data directory ───────────────────────────────────────────────────────────
/**
* Returns the XDG data directory for akm (`~/.local/share/akm` on Linux/macOS,
* `%LOCALAPPDATA%\akm\data` on Windows).
*
* Holds durable, non-regenerable application data: SQLite databases
* (`index.db`, `state.db`), `akm.lock`, and current data artifacts.
* Losing this directory loses history and installed state.
*
* Env overrides (in priority order):
* AKM_DATA_DIR — point to any directory
* XDG_DATA_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
*/
export function getDataDir(env = process.env, platform = process.platform) {
const override = env.AKM_DATA_DIR?.trim();
if (override)
return override;
// Defense-in-depth: under `bun test`, refuse to fall through to the
// user's real $XDG_DATA_HOME / ~/.local/share/akm under any condition.
// Any test that needs a data dir must point it at a mktemp-d directory
// via XDG_DATA_HOME (or AKM_DATA_DIR). The previous carve-out that only
// fired when AKM_BUNDLE_DIR was set was a loophole: tests calling
// openDatabase() or getDbPath() without overriding any env var silently
// wrote into ~/.local/share/akm/index.db (observed: 4,183-row
// registry-cache pollution). Item 5 of the 0.8.x critical-review plan.
if (isUnderBunTest(env) && !env.XDG_DATA_HOME?.trim()) {
throw testIsolationError();
}
if (platform === "win32") {
const localAppData = env.LOCALAPPDATA?.trim();
if (localAppData)
return path.join(localAppData, "akm", "data");
const userProfile = env.USERPROFILE?.trim();
if (userProfile)
return path.join(userProfile, "AppData", "Local", "akm", "data");
const appData = env.APPDATA?.trim();
if (!appData) {
throw new ConfigError("Unable to determine data directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
}
return path.join(appData, "..", "Local", "akm", "data");
}
const xdgDataHome = env.XDG_DATA_HOME?.trim();
if (xdgDataHome)
return path.join(xdgDataHome, "akm");
const home = env.HOME?.trim();
if (!home)
return homelessFallbackDir("akm-data");
return path.join(home, ".local", "share", "akm");
}
export function getDbPath(env = process.env) {
return path.join(getDataDir(env), "index.db");
}
export function getIndexWriterLockPath() {
return path.join(getDataDir(), "index.db.write.lock");
}
/**
* Path to the opt-in, PID-liveness-only rebuild lock an explicit `akm index`
* run acquires (#956). Distinct from {@link getIndexWriterLockPath}'s
* `index.db.write.lock`, which is the asset-mutation lease and unrelated to
* indexing since #872 — this lock never blocks and is never required, it
* only lets a scheduled/opportunistic `akm index --skip-if-locked` step
* aside instead of contending with a run already in progress.
*/
export function getIndexRebuildLockPath() {
return path.join(getDataDir(), "index.rebuild.lock");
}
/** Path to the state.db file in $DATA. */
export function getStateDbPathInDataDir() {
return path.join(getDataDir(), "state.db");
}
/** Path to the akm.lock file in $DATA. */
export function getLockfilePath() {
return path.join(getDataDir(), "akm.lock");
}
/** Path to the akm.lock.lck write-sentinel in $DATA. */
export function getLockfileLockPath() {
return path.join(getDataDir(), "akm.lock.lck");
}
export function getRegistryCacheDir() {
return path.join(getCacheDir(), "registry");
}
export function getRegistryIndexCacheDir() {
return path.join(getCacheDir(), "registry-index");
}
// ── State directory ──────────────────────────────────────────────────────────
/**
* Returns the XDG state directory for akm (`~/.local/state/akm` on
* Linux/macOS, `%LOCALAPPDATA%\akm\state` on Windows).
*
* Per-machine runtime state that is neither durable application data
* (`$DATA`), a purely regenerable cache (`$CACHE`), nor state that must
* travel with a bundle's own content (`$STASH/.akm`; see "What May Live in
* $STASH/.akm" in docs/architecture/internals/storage-locations.md).
*
* Env overrides (in priority order):
* AKM_STATE_DIR — point to any directory
* XDG_STATE_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
*/
export function getStateDir(env = process.env, platform = process.platform) {
const override = env.AKM_STATE_DIR?.trim();
if (override)
return override;
if (platform === "win32") {
const localAppData = env.LOCALAPPDATA?.trim();
if (localAppData)
return path.join(localAppData, "akm", "state");
const userProfile = env.USERPROFILE?.trim();
if (userProfile)
return path.join(userProfile, "AppData", "Local", "akm", "state");
const appData = env.APPDATA?.trim();
if (!appData) {
throw new ConfigError("Unable to determine state directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
}
return path.join(appData, "..", "Local", "akm", "state");
}
const xdgStateHome = env.XDG_STATE_HOME?.trim();
if (xdgStateHome)
return path.join(xdgStateHome, "akm");
const home = env.HOME?.trim();
if (!home)
return homelessFallbackDir("akm-state");
return path.join(home, ".local", "state", "akm");
}
// ── Per-stash state under $STATE / $CACHE (itlackey/akm#890) ─────────────────
/**
* Deterministic, filesystem-safe key for machine-local state that belongs to
* one stash but must not live under `$STASH` itself. Namespaces `$STATE`/
* `$CACHE` writers by the resolved absolute stash directory — the filesystem
* counterpart of how `state.db`'s `proposals` table keys per-stash rows by
* its `stash_dir` column — so two stashes on one machine never collide.
*
* Reuses {@link shortHash} (bundle-id.ts) rather than growing a second
* sha256-truncated-hex helper. (The other existing option,
* `getCurrentWorkflowScopeKey`'s `dir:v1:<sha256>` in
* `src/workflows/authoring/scope-key.ts`, is a state.db column value for a
* different subsystem — full untruncated hex plus a `dir:v1:` prefix, which
* is neither filesystem-safe as a directory segment on Windows (`:`) nor
* short, so it is not a fit here.) `shortHash` trims to 8 hex chars (32
* bits) — fine at its original call site because a collision only ever
* shortens a batch-unique slug suffix that a retry loop immediately
* re-disambiguates. Here there is no such retry loop, but a machine
* realistically has, at most, a handful to a few dozen distinct stash
* directories ever registered — 32 bits of digest space makes an accidental
* collision between two of them astronomically unlikely.
*/
export function getStashStateKey(stashDir) {
const resolved = path.resolve(stashDir).replace(/\\/g, "/");
const normalized = IS_WINDOWS ? resolved.toLowerCase() : resolved;
return shortHash(normalized);
}
function stashScopedDir(base, stashDir) {
return path.join(base, getStashStateKey(stashDir));
}
/**
* `$STATE/improve/measurement/verdicts/<stash>/` — `akm-eval-proactive-verdict`
* reports. Moved out of `$STASH/.akm/measurement/verdicts/` (itlackey/akm#890);
* the pilot treatment file at `$STASH/.akm/measurement/` is manually-authored
* measurement input and stays put.
*/
export function getMeasurementVerdictsDir(stashDir) {
return stashScopedDir(path.join(getStateDir(), "improve", "measurement", "verdicts"), stashDir);
}
/**
* `$CACHE/index/unresolved-sources/<stash>/` — synthetic placeholder path for
* a configured source whose content root did not resolve this run. Never
* written to disk; only used as a stable, reportable `SearchSource.path`.
* Moved out of `$STASH/.akm/unresolved-sources/` (itlackey/akm#890).
*/
export function getUnresolvedSourcesDir(stashDir) {
return stashScopedDir(path.join(getCacheDir(), "index", "unresolved-sources"), stashDir);
}
/**
* `$STATE/locks/<stash>/` — per-stash operational lock files for the improve
* pipeline (e.g. `improve.lock`). Moved out of `$STASH/.akm/` (itlackey/akm#890).
*/
export function getStashLocksDir(stashDir) {
return stashScopedDir(path.join(getStateDir(), "locks"), stashDir);
}
// ── Scheduled-task runtime directories (logs + history) ──────────────────────
export function getTaskLogDir() {
return path.join(getCacheDir(), "tasks", "logs");
}
export function getTaskHistoryDir() {
return path.join(getCacheDir(), "tasks", "history");
}
// ── Default stash directory ──────────────────────────────────────────────────
export function getDefaultStashDir(env = process.env) {
const override = env.AKM_BUNDLE_DIR?.trim();
if (override)
return override;
if (IS_WINDOWS) {
const userProfile = env.USERPROFILE?.trim();
if (userProfile)
return path.join(userProfile, "Documents", "akm");
return path.join("C:\\", "akm");
}
const home = env.HOME?.trim();
if (!home) {
throw new ConfigError("Unable to determine default bundle directory. Set HOME.", "STASH_DIR_NOT_FOUND");
}
return path.join(home, "akm");
}
// ── Stash directory safety check (#473) ──────────────────────────────────────
/**
* Refuse stashDir values that would clobber a sensitive system path or the
* user's home directory itself. Called from `akm init`, `akm setup`, and the
* setup-wizard validator before any disk write.
*
* Refuses:
* - The filesystem root (`/` or Windows drive root `C:\`)
* - Common system roots (`/etc`, `/var`, `/usr`, `/usr/local`, `/opt`,
* `/sys`, `/proc`, `/boot`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/dev`,
* `/run`, `/home`, `/root`, `/mnt`, `/media`,
* `/Library`, `/System`, `/Applications`)
* - The user's home directory itself (exact match — subdirs are fine)
* - User-data dotfile parents: `~/.config`, `~/.local`, `~/.cache`,
* `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, `~/.docker`,
* and the macOS/Windows `~/Documents` and `~/Downloads` parents
*
* Subdirectories of any refused path are allowed (so `~/.local/share/akm-test`
* is fine even though `~/.local` is refused). This catches fat-finger
* `--dir /` or `--dir ~` without preventing legitimate nested use.
*/
export function assertSafeStashDir(stashDir, env = process.env) {
const resolved = path.resolve(stashDir);
// Filesystem root — POSIX and Windows drive roots.
if (resolved === "/" || /^[A-Za-z]:[\\/]?$/.test(resolved)) {
throw new ConfigError(`Refusing stashDir at filesystem root (${resolved}). Pick a subdirectory like ~/akm.`, "UNSAFE_STASH_DIR");
}
// System directories — exact match only.
const SYSTEM_ROOTS = new Set([
"/etc",
"/var",
"/usr",
"/usr/local",
"/opt",
"/sys",
"/proc",
"/boot",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/dev",
"/run",
"/home",
"/root",
"/mnt",
"/media",
"/Library",
"/System",
"/Applications",
]);
if (SYSTEM_ROOTS.has(resolved)) {
throw new ConfigError(`Refusing stashDir at system path (${resolved}). Pick a path inside your home directory.`, "UNSAFE_STASH_DIR");
}
if (resolved === "/var/tmp") {
warnOnce("stash-dir:var-tmp", `Stash directory is at ${resolved}, a shared scratch directory system cleanup jobs may periodically empty; using it as configured.`);
}
// User home — exact match only. Subdirs (~/akm, ~/work/stash) are fine.
// Check BOTH the env-controlled home and the OS-reported home, so the
// refusal can't be bypassed by unsetting HOME, and so it still fires
// under bun test (which isolates HOME to a tempdir while os.homedir()
// still returns the real user's home).
const candidateHomes = new Set();
const envHome = (env.HOME ?? env.USERPROFILE)?.trim();
if (envHome)
candidateHomes.add(path.resolve(envHome));
try {
const osHome = os.homedir();
if (osHome)
candidateHomes.add(path.resolve(osHome));
}
catch {
// os.homedir() can throw on misconfigured systems; ignore.
}
const CREDENTIAL_USER_PARENTS = [".config", ".local", ".cache", ".ssh", ".gnupg", ".aws", ".kube", ".docker"];
const PLAIN_USER_DATA_PARENTS = ["Documents", "Downloads", "AppData"];
for (const home of candidateHomes) {
if (resolved === home) {
throw new ConfigError(`Refusing stashDir at your home directory (${resolved}). Pick a subdirectory like ~/akm.`, "UNSAFE_STASH_DIR");
}
for (const sub of CREDENTIAL_USER_PARENTS) {
if (resolved === path.join(home, sub)) {
throw new ConfigError(`Refusing stashDir at sensitive user directory (${resolved}). Pick a subdirectory or a dedicated workspace.`, "UNSAFE_STASH_DIR");
}
}
for (const sub of PLAIN_USER_DATA_PARENTS) {
if (resolved === path.join(home, sub)) {
warnOnce(`stash-dir:plain-user-data:${sub}`, `Stash directory is at ${resolved}, your ${sub} folder; using it as configured, though it is usually a large, unrelated-content directory to index.`);
}
}
}
}