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
483 lines (482 loc) • 23 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/.
/**
* The plugin half of `akm upgrade` (#1007): refresh the akm plugin of each
* installed agent harness, and keep the CLI in version lockstep with the
* OpenCode plugin. Update only: a plugin that is not installed is never
* installed, and a harness without one is skipped. Every external command
* runs with a timeout and its failure is captured on that harness's entry,
* so a broken harness never aborts the CLI upgrade.
*/
import * as childProcess from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { IS_WINDOWS, stripJsonComments } from "../../core/common.js";
import { moveToTrash } from "../../core/trash.js";
import { semverOrder } from "../../runtime.js";
const MARKETPLACE = "akm-plugins";
const PLUGIN_ID = `akm@${MARKETPLACE}`;
const OPENCODE_PACKAGE = "akm-opencode";
const OPENCODE_NEXT_SPEC = `${OPENCODE_PACKAGE}@next`;
const READ_TIMEOUT_MS = 30_000;
const REFRESH_TIMEOUT_MS = 120_000;
const PREFETCH_TIMEOUT_MS = 180_000;
function runCommand(command, args, timeoutMs, opts) {
const result = childProcess.spawnSync(command, args, {
encoding: "utf8",
env: process.env,
stdio: "pipe",
timeout: timeoutMs,
killSignal: "SIGKILL",
cwd: opts?.cwd,
});
const label = `${command} ${args.join(" ")}`;
if (result.error) {
const code = result.error.code;
if (code === "ETIMEDOUT")
return { ok: false, missing: false, error: `\`${label}\` timed out after ${timeoutMs / 1000}s` };
return { ok: false, missing: code === "ENOENT", error: `\`${label}\` could not run: ${result.error.message}` };
}
if (result.status !== 0) {
const detail = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `exit code ${result.status}`;
return { ok: false, missing: false, error: `\`${label}\` failed: ${detail}` };
}
return { ok: true, stdout: result.stdout ?? "" };
}
function parseJson(text) {
try {
return JSON.parse(text);
}
catch {
return undefined;
}
}
function skipped(harness, message) {
return { harness, outcome: "skipped", message };
}
function failed(harness, message) {
return { harness, outcome: "failed", message };
}
/** An entry for a refresh that ran: `updated` when the installed version moved, else `current`. */
function refreshed(harness, from, to) {
const moved = from !== undefined && to !== undefined && from !== to;
return { harness, outcome: moved ? "updated" : "current", from, to };
}
/** Unknown, in `--check`: knowing whether a newer build exists needs a marketplace fetch, which `--check` does not do. */
function unknownRefresh(harness, from) {
return {
harness,
outcome: "unknown",
from,
message: "checking needs a fetch of the akm-plugins marketplace, which --check does not do; `akm upgrade` refreshes it",
};
}
function claudePluginVersion() {
const listed = runCommand("claude", ["plugin", "list", "--json"], READ_TIMEOUT_MS);
if (!listed.ok)
return { error: listed };
const plugins = parseJson(listed.stdout);
const plugin = Array.isArray(plugins) ? plugins.find((p) => p?.id === PLUGIN_ID) : undefined;
return { version: plugin ? String(plugin.version ?? "") : undefined };
}
function detectClaudeCode() {
const marketplaces = runCommand("claude", ["plugin", "marketplace", "list", "--json"], READ_TIMEOUT_MS);
if (!marketplaces.ok) {
return { entry: skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error) };
}
const list = parseJson(marketplaces.stdout);
if (!Array.isArray(list) || !list.some((m) => m?.name === MARKETPLACE)) {
return { entry: skipped("claude-code", `the ${MARKETPLACE} marketplace is not configured`) };
}
const plugin = claudePluginVersion();
if ("error" in plugin)
return { entry: skipped("claude-code", plugin.error.error) };
if (plugin.version === undefined)
return { entry: skipped("claude-code", "the akm plugin is not installed") };
return { version: plugin.version };
}
function upgradeClaudeCode(dryRun) {
const detected = detectClaudeCode();
if ("entry" in detected)
return detected.entry;
if (dryRun)
return unknownRefresh("claude-code", detected.version);
const marketplace = runCommand("claude", ["plugin", "marketplace", "update", MARKETPLACE], REFRESH_TIMEOUT_MS);
if (!marketplace.ok)
return failed("claude-code", marketplace.error);
const update = runCommand("claude", ["plugin", "update", PLUGIN_ID], REFRESH_TIMEOUT_MS);
if (!update.ok)
return failed("claude-code", update.error);
const after = claudePluginVersion();
return refreshed("claude-code", detected.version, "version" in after ? after.version : undefined);
}
// ── Codex ───────────────────────────────────────────────────────────────────
function codexPluginVersion() {
const listed = runCommand("codex", ["plugin", "list", "--marketplace", MARKETPLACE, "--json"], READ_TIMEOUT_MS);
if (!listed.ok)
return { error: listed };
const parsed = parseJson(listed.stdout);
const plugin = parsed?.installed?.find((p) => p?.pluginId === PLUGIN_ID);
return { version: plugin ? String(plugin.version ?? "") : undefined };
}
function upgradeCodex(dryRun) {
const before = codexPluginVersion();
if ("error" in before)
return skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
if (before.version === undefined) {
return skipped("codex", `the akm plugin is not installed from the ${MARKETPLACE} marketplace`);
}
if (dryRun)
return unknownRefresh("codex", before.version);
// Also refreshes the installed plugin's cache, so no re-add is needed.
const upgrade = runCommand("codex", ["plugin", "marketplace", "upgrade", MARKETPLACE], REFRESH_TIMEOUT_MS);
if (!upgrade.ok)
return failed("codex", upgrade.error);
const after = codexPluginVersion();
return refreshed("codex", before.version, "version" in after ? after.version : undefined);
}
function homeDir() {
return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || os.homedir();
}
/**
* The `akm-opencode` spec the user's global OpenCode config names (bare,
* `@latest`, `@next`, an exact version, ...), or undefined when none does.
* OpenCode caches each spec in its own folder, so this says which cache it
* loads. The config is only read, never written; project configs are not
* looked at (akm does not know which project OpenCode runs in).
*/
export function openCodeConfigSpec() {
const configHome = process.env.XDG_CONFIG_HOME?.trim() || path.join(homeDir(), ".config");
const files = [
process.env.OPENCODE_CONFIG?.trim(),
path.join(configHome, "opencode", "opencode.json"),
path.join(configHome, "opencode", "opencode.jsonc"),
];
for (const file of files) {
if (!file)
continue;
try {
const config = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8")));
if (!Array.isArray(config.plugin))
continue;
// An entry is a spec string or a `[spec, options]` pair.
for (const entry of config.plugin) {
const spec = Array.isArray(entry) ? entry[0] : entry;
if (typeof spec === "string" && (spec === OPENCODE_PACKAGE || spec.startsWith(`${OPENCODE_PACKAGE}@`))) {
return spec;
}
}
}
catch {
// Missing or unparseable: this file names no spec.
}
}
return undefined;
}
/**
* The cached `akm-opencode` OpenCode installed on first use, or undefined when
* there is none. OpenCode names the folder after the spec it resolved, so a
* bare `akm-opencode` lands in `@latest` and `akm-opencode@next` in `@next`.
*/
export function detectOpenCodeCache(tag = "latest") {
const cacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(homeDir(), ".cache");
const dir = path.join(cacheHome, "opencode", "packages", `${OPENCODE_PACKAGE}@${tag}`);
if (!fs.existsSync(dir))
return undefined;
const readVersion = (file) => {
try {
const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
return typeof pkg.version === "string" ? pkg.version : undefined;
}
catch {
return undefined;
}
};
return { dir, version: readVersion(path.join(dir, "node_modules", OPENCODE_PACKAGE, "package.json")) };
}
/** What npm's `akm-opencode@<tag>` is, and which akm-cli it pins. */
export function lookupOpenCodeLatest(tag = "latest") {
const spec = `${OPENCODE_PACKAGE}@${tag}`;
const view = runCommand(IS_WINDOWS ? "npm.cmd" : "npm", ["view", spec, "version", "dependencies.akm-cli", "--json"], READ_TIMEOUT_MS);
if (!view.ok)
return { error: view.error };
const parsed = parseJson(view.stdout);
if (typeof parsed?.version !== "string")
return { error: `npm did not report a version for ${spec}` };
const akmCli = parsed["dependencies.akm-cli"];
return { version: parsed.version, akmCli: typeof akmCli === "string" ? akmCli : undefined };
}
/**
* `akm-opencode@next`, which must exist and pin an akm-cli no older than the one
* `@latest` pins: a prerelease channel that trails the stable one is not a target.
*
* The two builds are compared by their akm-cli pins, never by their own
* versions. akm-plugins' stable versions are `<akm_version><yyyymmddhhmm>`
* concatenated into the PATCH (`0.9.27202610072331`), while prereleases are
* `<akm_version>.<ts>` (`0.9.28-alpha.8.202610081938`), so by semver every
* prerelease sorts below the stable build and the comparison means nothing
* (#1089). For the same reason plugin versions are only ever tested for
* equality (is the cache already this build?), never ordered.
*/
export function lookupOpenCodeNext() {
const next = lookupOpenCodeLatest("next");
if ("error" in next)
return next;
const latest = lookupOpenCodeLatest("latest");
if ("error" in latest)
return { error: `could not compare with ${OPENCODE_PACKAGE}@latest: ${latest.error}` };
// No pin on @next: returned as is, and the lockstep holds the CLI and says why.
if (!next.akmCli)
return next;
if (!latest.akmCli) {
return { error: `could not compare with ${OPENCODE_PACKAGE}@latest: it declares no akm-cli dependency` };
}
if (semverOrder(next.akmCli, latest.akmCli) < 0) {
return {
error: `${OPENCODE_NEXT_SPEC} (${next.version}) pins akm-cli ${next.akmCli}, older than the ` +
`${latest.akmCli} that ${OPENCODE_PACKAGE}@latest (${latest.version}) pins`,
};
}
return next;
}
const CONTAINER_CGROUP = /docker|containerd|podman|libpod|lxc/;
/**
* Whether the process could use this host's OpenCode cache. A process in another mount namespace (a container's
* `opencode serve`) has its own cache; when that cannot be read, a container cgroup says the same. Anything
* unreadable counts as a host process, so the answer errs towards deferring.
*/
export function usesHostCache(pid, procRoot = "/proc") {
try {
return fs.readlinkSync(`${procRoot}/${pid}/ns/mnt`) === fs.readlinkSync(`${procRoot}/self/ns/mnt`);
}
catch {
// not readable (another user's process, or no such namespace file): look at the cgroup instead
}
try {
return !CONTAINER_CGROUP.test(fs.readFileSync(`${procRoot}/${pid}/cgroup`, "utf8"));
}
catch {
return true;
}
}
/** Whether an OpenCode process is running (its prefetch would be replaced under it). `undefined` when that cannot be told. */
function openCodeRunning() {
if (IS_WINDOWS) {
const tasks = runCommand("tasklist", ["/FI", "IMAGENAME eq opencode.exe", "/NH"], READ_TIMEOUT_MS);
return tasks.ok ? /opencode\.exe/i.test(tasks.stdout) : undefined;
}
const linux = process.platform === "linux";
// `-f` because a Node-wrapped opencode shows up as `node …/opencode`; the
// pattern needs `opencode` to be a whole path segment so `akm-opencode`
// in some other command's arguments does not match.
const pgrep = runCommand("pgrep", ["-f", "(^|/)opencode( |$)"], READ_TIMEOUT_MS);
if (pgrep.ok) {
return !linux || pgrep.stdout.split(/\s+/).some((pid) => /^\d+$/.test(pid) && usesHostCache(pid));
}
if (!pgrep.missing)
return false; // pgrep exits 1 when nothing matched
if (!linux)
return undefined;
try {
return fs.readdirSync("/proc").some((pid) => {
if (!/^\d+$/.test(pid) || Number(pid) === process.pid)
return false;
try {
const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0");
return argv.slice(0, 2).some((arg) => path.basename(arg) === "opencode") && usesHostCache(pid);
}
catch {
return false;
}
});
}
catch {
return undefined;
}
}
const NOT_FOLLOWING_NEXT = `--next: OpenCode resolves a bare "${OPENCODE_PACKAGE}" to @latest, so it is not updated to a prerelease; ` +
`set "plugin": ["${OPENCODE_NEXT_SPEC}"] in your OpenCode config to follow prereleases`;
/** Why a run leaves alone an OpenCode whose config names a spec this run does not refresh (#1115, #1117). */
function otherSpecNote(spec) {
const loads = `OpenCode loads "${spec}" (your OpenCode config), so this upgrade leaves it alone`;
return spec === OPENCODE_NEXT_SPEC
? `${loads}; run \`akm upgrade --next\` to update it, or set "plugin": ["${OPENCODE_PACKAGE}"] to follow stable releases`
: `${loads}; set "plugin": ["${OPENCODE_PACKAGE}"] to have \`akm upgrade\` keep it current`;
}
function upgradeOpenCode(dryRun, cache, latest, tag, notFollowingNext, otherSpec) {
// Refreshing a cache OpenCode does not load would trash one it never re-creates (#1115, #1117).
if (otherSpec)
return skipped("opencode", otherSpecNote(otherSpec));
if (!cache || !latest)
return skipped("opencode", `no cached ${OPENCODE_PACKAGE}@${tag} plugin`);
if ("error" in latest)
return failed("opencode", latest.error);
if (notFollowingNext)
return skipped("opencode", NOT_FOLLOWING_NEXT);
if (cache.version === latest.version)
return { harness: "opencode", outcome: "current", from: cache.version, to: latest.version };
const base = { harness: "opencode", from: cache.version, to: latest.version };
const running = openCodeRunning();
if (running !== false) {
return {
...base,
outcome: "deferred",
message: running === true
? "OpenCode is running; the plugin cache is replaced by a later `akm upgrade` once it has exited"
: "could not tell whether OpenCode is running; the plugin cache is replaced by a later `akm upgrade`",
};
}
if (dryRun)
return { ...base, outcome: "pending" };
// OpenCode must be runnable before the cache goes: it does the prefetch.
const probe = runCommand("opencode", ["--version"], READ_TIMEOUT_MS);
if (!probe.ok)
return skipped("opencode", probe.missing ? "opencode is not on PATH" : probe.error);
try {
moveToTrash(cache.dir);
}
catch (error) {
return failed("opencode", `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`);
}
// Any OpenCode command that resolves the config installs the plugin again;
// from a temp dir, so no project's opencode.json is read.
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-opencode-prefetch-"));
try {
const prefetch = runCommand("opencode", ["debug", "config"], PREFETCH_TIMEOUT_MS, { cwd: workDir });
if (!prefetch.ok)
return failed("opencode", `${prefetch.error} (the old cache is in the trash)`);
}
finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
const after = detectOpenCodeCache(tag);
if (!after)
return failed("opencode", "opencode did not re-create the plugin cache (the old cache is in the trash)");
return { ...base, outcome: "updated", to: after.version ?? latest.version };
}
// ── Orchestration ───────────────────────────────────────────────────────────
const NO_PRERELEASE_CHANNEL = "--next: no prerelease channel; the akm-plugins marketplace is followed as usual and works with any 0.9.x akm";
/** The note `--next` adds to a harness whose plugin has no prerelease channel. */
function withNextNote(entry) {
if (entry.outcome === "skipped")
return entry;
return { ...entry, message: entry.message ? `${entry.message}; ${NO_PRERELEASE_CHANNEL}` : NO_PRERELEASE_CHANNEL };
}
export function upgradePlugins(opts) {
const { cache, latest, tag, notFollowingNext, otherSpec } = opts.openCode;
const claude = upgradeClaudeCode(opts.dryRun);
const codex = upgradeCodex(opts.dryRun);
return [
opts.next ? withNextNote(claude) : claude,
opts.next ? withNextNote(codex) : codex,
upgradeOpenCode(opts.dryRun, cache, latest, tag, notFollowingNext, otherSpec),
];
}
/**
* When the OpenCode plugin is present the CLI target is the akm-cli that
* `akm-opencode@latest` pins, not the newest release: the plugin runs that
* exact akm in-process, against databases a newer CLI may already have
* migrated. The CLI never moves backwards to meet the pin.
*
* It fails closed: when the plugin is present but its pin cannot be read (the
* npm lookup failed, or the package declares no akm-cli), the CLI is held where
* it is (`updateAvailable: false`, `latestVersion` = the current version) and
* `lockstep.reason` says why. Moving the CLI ahead of a pin nobody could read is
* what lockstep exists to prevent; the OpenCode entry reports the failure.
*/
export function applyLockstep(check, latest, tag = "latest") {
if (!latest)
return check;
// Held back only when there is a release this upgrade would otherwise install.
const wouldInstall = semverOrder(check.currentVersion, check.latestVersion) < 0;
const pinned = "error" in latest ? undefined : latest.akmCli;
if (!pinned) {
const reason = "error" in latest
? `could not read the akm-cli pin of ${OPENCODE_PACKAGE}@${tag}: ${latest.error}`
: `${OPENCODE_PACKAGE}@${tag} declares no akm-cli dependency`;
return {
...check,
latestVersion: check.currentVersion,
updateAvailable: false,
lockstep: {
plugin: OPENCODE_PACKAGE,
pinnedVersion: null,
newestVersion: check.latestVersion,
heldBack: wouldInstall,
reason,
},
};
}
const heldBack = semverOrder(pinned, check.latestVersion) < 0 && wouldInstall;
const lockstep = {
plugin: OPENCODE_PACKAGE,
pinnedVersion: pinned,
newestVersion: check.latestVersion,
heldBack,
};
if (!heldBack)
return { ...check, lockstep };
return {
...check,
latestVersion: pinned,
updateAvailable: semverOrder(check.currentVersion, pinned) < 0,
lockstep,
};
}
/**
* Which OpenCode plugin build the CLI is held to, and which cache refreshes.
* Under `--next` that is `akm-opencode@next`, but only when the user's OpenCode
* config names that tag: a bare one keeps resolving `@latest`, so the lockstep
* stays against the `@latest` pin and the entry is reported as skipped. A config
* naming any other spec (an exact version, or `@next` on a plain run) is left
* alone, with no lockstep.
*/
function resolveOpenCodeTarget(next) {
// No config entry behaves like a bare one: OpenCode would resolve @latest.
const spec = openCodeConfigSpec() ?? OPENCODE_PACKAGE;
const followsLatest = spec === OPENCODE_PACKAGE || spec === `${OPENCODE_PACKAGE}@latest`;
const followNext = next && spec === OPENCODE_NEXT_SPEC;
// Any other spec loads a cache this run does not refresh, and pins no akm-cli it should be held to.
if (!followsLatest && !followNext) {
return {
cache: undefined,
latest: undefined,
tag: next ? "next" : "latest",
notFollowingNext: false,
otherSpec: spec,
};
}
const tag = followNext ? "next" : "latest";
const cache = detectOpenCodeCache(tag);
const latest = cache ? (followNext ? lookupOpenCodeNext() : lookupOpenCodeLatest()) : undefined;
return { cache, latest, tag, notFollowingNext: next && !followNext };
}
/** `akm upgrade`: the CLI step (held to the OpenCode plugin's akm), then the plugins. */
export async function runUpgrade(args, currentVersion, deps) {
const next = args.next === true;
const channel = next ? "next" : "latest";
const openCode = resolveOpenCodeTarget(next);
const check = {
...applyLockstep(await deps.checkForUpdate(currentVersion, channel), openCode.latest, openCode.tag),
channel,
};
if (args.check) {
return { mode: "check", result: { ...check, plugins: upgradePlugins({ dryRun: true, next, openCode }) } };
}
const upgraded = await deps.performUpgrade(check, {
force: args.force,
skipPostUpgrade: args.skipPostUpgrade,
// A package manager install must name the version, or `@latest` goes past the pin
// (or past the prerelease that `--next` chose).
...(check.lockstep?.heldBack || (next && check.latestVersion) ? { targetVersion: check.latestVersion } : {}),
});
const plugins = upgradePlugins({ dryRun: false, next, openCode });
const result = { ...upgraded, channel, ...(check.lockstep ? { lockstep: check.lockstep } : {}), plugins };
// The install may have succeeded, but an upgrade whose migration is
// blocked or could not run is not done, and neither is one whose plugin
// step failed.
const migrationFailed = upgraded.migration?.status === "blocked" || upgraded.migration?.status === "failed";
return { mode: "upgrade", result, failed: migrationFailed || plugins.some((p) => p.outcome === "failed") };
}