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

476 lines (475 loc) • 22.8 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/. /** * 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.`); } } } }