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
142 lines (141 loc) • 7.3 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/.
/**
* akm initialization logic.
*
* Creates the working stash directory structure, persists the stashDir
* in config.json, and ensures ripgrep is available.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { stashDirNames } from "../../core/asset/asset-placement.js";
import { mutateConfig } from "../../core/config/config.js";
import { assertSafeStashDir, getConfigPath, getDefaultStashDir } from "../../core/paths.js";
import { warnOnce } from "../../core/warn.js";
import { primaryBundlePath, withPrimaryBundle } from "./bundle-config-ops.js";
import { copyStashSkeleton, ensureStashGitignore, scaffoldStashMeta } from "./stash-skeleton.js";
/**
* Refuse to persist a temporary-directory stashDir to the user's config when
* running under a test runner AND `--dir <tempdir>` was passed explicitly.
* This guard targets the exact agent-overreach pattern documented in
* `memories/akm-init-persists-stashdir-warning`: an agent ran
* `akm bundle create --dir $(mktemp -d)` for an E2E test and silently rewrote the
* developer's real config to point at a now-deleted temp dir.
*
* Tests that legitimately resolve a tempdir via HOME (default-path init) are
* unaffected — those are normal `~/akm` resolutions and not the failure mode.
*
* Test sentinels (either suffices):
* - `BUN_TEST=1` — explicit opt-in
* - `NODE_ENV=test` — what `bun test` sets today
*
* Tests that genuinely need to exercise `akm bundle create --dir /tmp/...` should set
* `AKM_FORCE_INIT_TMP_STASH=1`.
*/
function assertInitSandbox(stashDir, dirExplicitlyProvided) {
if (!dirExplicitlyProvided)
return; // Only guard explicit --dir, not default HOME resolution.
const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
if (!isUnderTest)
return;
const isTmp = stashDir.startsWith("/tmp/") ||
stashDir === "/tmp" ||
stashDir.startsWith("/var/tmp/") ||
stashDir === "/var/tmp" ||
stashDir.startsWith("/private/var/folders/") ||
stashDir.startsWith("/private/tmp/");
if (!isTmp)
return;
warnOnce(`init-tmp-stash:${stashDir}`, `Persisting --dir stashDir to a temporary path (${stashDir}) while a test-runner env var (BUN_TEST/NODE_ENV=test) is set; ` +
"the OS may reap this path, leaving the next run pointing at a deleted bundle.");
}
// ── Test seam ────────────────────────────────────────────────────────────────
// Swap-and-restore override. Inert in production; only tests call the setter.
let akmInitOverride;
/** TEST-ONLY. Swap the implementation of `akmInit`; pass undefined to restore. */
export function _setAkmInitForTests(fake) {
akmInitOverride = fake;
}
export async function akmInit(options) {
if (akmInitOverride)
return akmInitOverride(options);
return akmInitReal(options);
}
async function akmInitReal(options) {
const dirExplicitlyProvided = options?.dir != null;
const setDefault = options?.setDefault === true;
const stashDir = options?.dir ? path.resolve(options.dir) : getDefaultStashDir();
// Safety check (#473): refuse stashDir at /, $HOME, /etc, ~/.config, etc.
// Runs BEFORE any disk write — a fat-fingered `akm bundle create --dir /` or
// `akm bundle create --dir ~` would otherwise mkdir + git-init the user's system
// root or home directory. Catastrophic-on-misuse vs. trivial-to-recover-from.
assertSafeStashDir(stashDir);
// Defense-in-depth: refuse to persist an explicit --dir /tmp/... stashDir
// to config under a test runner. Default HOME-resolved paths are exempt.
assertInitSandbox(stashDir, dirExplicitlyProvided);
let created = false;
if (!fs.existsSync(stashDir)) {
fs.mkdirSync(stashDir, { recursive: true });
created = true;
}
for (const sub of stashDirNames()) {
const subDir = path.join(stashDir, sub);
if (!fs.existsSync(subDir)) {
fs.mkdirSync(subDir, { recursive: true, mode: sub === "env" || sub === "secrets" ? 0o700 : undefined });
}
}
// Ensure the default stash is a local git repo (no remote required)
ensureGitRepo(stashDir);
// 08-F1: scaffold a default `.gitignore` that keeps env/ + secrets/ out of git
// so a `git push` can never leak them. Idempotent + non-clobbering; the user
// opts into versioning by un-ignoring a path.
ensureStashGitignore(stashDir);
// Run seeding UNCONDITIONALLY (not just when the stash was newly created) so
// re-running `akm bundle create` on an existing stash backfills any missing skeleton
// files — the README, the per-type SOFT convention templates under
// facts/conventions/assets/, and the `.meta/index.md` orientation doc. Both
// helpers are absent-only: they never overwrite a file a user has edited.
copyStashSkeleton(stashDir);
scaffoldStashMeta(stashDir);
// Persist stashDir in config.json — but ONLY when the user is actually
// setting up / opting into a default. A bare `akm bundle create --dir <secondary>`
// must NOT silently repoint the user's real default stash (the footgun
// documented in memories/akm-init-persists-stashdir-warning).
//
// Decision matrix — persist when ANY of:
// (a) no --dir provided → default HOME-resolved setup flow
// (b) --dir AND no existing stashDir in config → first-time bootstrap
// (c) --dir AND --set-default → explicit opt-in
// Otherwise (--dir + existing default + no --set-default) leave the default
// pointer alone; the target dir is still scaffolded above.
const configPath = getConfigPath();
let defaultBundleUpdated = false;
let previousStashDir;
if (options?.persistConfig !== false) {
const result = mutateConfig((latest) => {
// 0.9.0 (spec §10.1): the primary stash is the `defaultBundle`'s filesystem
// `path`, not the retired top-level `stashDir`.
const currentPrimary = primaryBundlePath(latest);
const shouldPersist = !dirExplicitlyProvided || !currentPrimary || setDefault;
if (!shouldPersist) {
previousStashDir = currentPrimary;
return latest;
}
if (currentPrimary === stashDir)
return latest;
return withPrimaryBundle(latest, stashDir);
});
defaultBundleUpdated = result.written;
}
return { bundleDir: stashDir, created, configPath, defaultBundleUpdated, previousBundleDir: previousStashDir };
}
/** Initialise `dir` as a git repository if it is not already one. */
function ensureGitRepo(dir) {
const gitDir = path.join(dir, ".git");
if (fs.existsSync(gitDir))
return;
// Non-fatal: git may not be available in all environments
spawnSync("git", ["init", dir], { encoding: "utf8", timeout: 15_000 });
}