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
274 lines (273 loc) • 12.5 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/.
/**
* `plugin-version` advisory for `akm health` (itlackey/akm#832).
*
* #828 was filed as a CLI bug — session extraction failing on 232/234 runs —
* and took a full investigation to resolve. The actual cause: the harness
* plugin (`akm@akm-plugins`, installed in Claude Code's plugin cache) was
* three days stale relative to the fix, and its own `AKM_VERSION_RANGE` gate
* had nothing to do with it — the plugin was simply running old code. Every
* fact needed to reach that conclusion in one step was already on disk:
*
* - installed plugin version: `~/.claude/plugins/cache/<marketplace>/akm/<version>/.claude-plugin/plugin.json`
* - the plugin's own akm-cli compatibility contract: `<pluginDir>/shared/akm-version.ts`'s `AKM_VERSION_RANGE`
* - the running CLI's version: `../../version.ts`'s `pkgVersion`
*
* Nothing correlated them, so a stale plugin was indistinguishable from a
* broken CLI. This module closes that gap with three checks, one per
* detected plugin:
*
* 1. report the installed version;
* 2. compare it against the newest tag published to the plugin's git
* remote, and warn (naming the update command) when behind;
* 3. the sharp one — check whether the *installed plugin's* declared
* `AKM_VERSION_RANGE` admits the *running* CLI version. When it does
* not, the plugin has silently disabled itself (both surfaces log
* `version_out_of_range` / `akm_version_mismatch` and degrade quietly)
* and there was previously no way to know that from the CLI side.
*
* Read-only: this never fetches, writes, or mutates the plugin cache or
* marketplace clone. Check 2 is one of two deliberate exceptions to "`akm
* health` makes no network call" (see `./health-advisories.md`; the other is
* the `cli-version` advisory in `./version-drift.ts`, #950): a plugin's
* local marketplace clone is not proof of what is newest upstream — the
* incident above involved a clone that hadn't seen the fix's tag at all — so
* the only way to ever detect drift is to ask the remote what tags exist.
* That query is a `git ls-remote --tags` (lists refs; fetches nothing,
* writes nothing) with a short timeout, and any failure (offline, no
* remote, timeout) degrades to "installed version reported, no staleness
* claim" rather than a false positive or a hang. Unlike `cli-version`, this
* check is NOT gated behind `--probe`/`--no-probe` — it predates that flag
* and stays unconditional; see `./version-drift.ts` for why the newer check
* chose the opposite default.
*
* Every collector here is best-effort and silent on missing/unreadable
* input: no Claude plugin installed, no marketplace clone, an unreadable
* manifest, or a malformed version range must never crash `akm health` and
* must never produce a false "stale" or "inactive" warning.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isExactSemver, isSemverRange, maxSatisfying, satisfiesRange } from "../../registry/semver.js";
/**
* Root directory holding Claude Code's plugin cache + marketplace clones.
* Resolved per call (not memoized) so `AKM_CLAUDE_PLUGINS_DIR` can be set
* after import — the override exists so tests point this at an empty
* fixture directory instead of the real `~/.claude/plugins`, matching
* `AKM_CLAUDE_PROJECTS_DIR` in `../../integrations/harnesses/claude/session-log.ts`.
*/
function claudePluginsDir() {
return process.env.AKM_CLAUDE_PLUGINS_DIR ?? path.join(os.homedir(), ".claude", "plugins");
}
/**
* Root of OpenCode's package cache, same override pattern as
* {@link claudePluginsDir} (`AKM_OPENCODE_CACHE_DIR` for tests).
* The OpenCode plugin (`akm-opencode`) bundles its own `akm-cli` under
* `packages/akm-opencode/node_modules/akm-cli` and runs it in-process,
* sharing the host's databases with every other akm install — its version
* lag matters the same way a stale Claude plugin's does.
*/
function opencodeCacheDir() {
return process.env.AKM_OPENCODE_CACHE_DIR ?? path.join(os.homedir(), ".cache", "opencode");
}
/**
* Read the OpenCode plugin's bundled `akm-cli` version from its
* `package.json` — never executed, just parsed, mirroring
* {@link detectInstalledPlugins}'s manifest read. `undefined` when the
* OpenCode plugin, or its bundled akm-cli, is not installed.
*/
function detectOpencodeBundledAkmVersion(cacheRoot) {
const pkgPath = path.join(cacheRoot, "packages", "akm-opencode", "node_modules", "akm-cli", "package.json");
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
return typeof pkg.version === "string" ? pkg.version : undefined;
}
catch {
return undefined;
}
}
function buildOpencodeBundledAdvisory(bundledVersion, cliVersion) {
const stale = bundledVersion !== cliVersion;
return {
name: "opencode-plugin-version",
kind: "deterministic",
status: stale ? "warn" : "pass",
confidence: "high",
message: stale
? `OpenCode's bundled akm-cli is v${bundledVersion}, but the running CLI is v${cliVersion} — it shares the same databases in-process; update the OpenCode plugin.`
: `OpenCode's bundled akm-cli matches the running v${cliVersion}.`,
evidence: { bundledVersion, cliVersion },
};
}
/**
* Scan `<pluginsRoot>/cache/<marketplace>/akm/<version>/` for every
* `akm` plugin cache entry, picking the highest cached version per
* marketplace when more than one is present. Returns `[]` (never throws)
* when the cache directory is absent, empty, or unreadable — that is the
* ordinary "no Claude plugin installed" case, not an error.
*/
function detectInstalledPlugins(pluginsRoot) {
const cacheDir = path.join(pluginsRoot, "cache");
let marketplaces;
try {
marketplaces = fs.readdirSync(cacheDir);
}
catch {
return [];
}
const detected = [];
for (const marketplace of marketplaces) {
const pluginDir = path.join(cacheDir, marketplace, "akm");
let versions;
try {
versions = fs.readdirSync(pluginDir).filter(isExactSemver);
}
catch {
continue;
}
if (versions.length === 0)
continue;
const latest = maxSatisfying(versions, "*") ?? versions.sort().at(-1);
if (!latest)
continue;
const versionDir = path.join(pluginDir, latest);
const manifestPath = path.join(versionDir, ".claude-plugin", "plugin.json");
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (typeof manifest.version !== "string")
continue;
detected.push({
harness: "claude",
marketplace,
pluginName: "akm",
version: manifest.version,
pluginDir: versionDir,
});
}
catch {
// Unreadable/malformed manifest — skip this entry rather than crash.
}
}
return detected;
}
/**
* Extract `AKM_VERSION_RANGE` from the installed plugin's vendored
* `shared/akm-version.ts`. Returns `undefined` when the file is missing,
* unreadable, or does not contain the expected declaration — callers must
* treat that as "compatibility unknown", never as a mismatch.
*/
function readVersionRange(pluginDir) {
const versionFilePath = path.join(pluginDir, "shared", "akm-version.ts");
let text;
try {
text = fs.readFileSync(versionFilePath, "utf8");
}
catch {
return undefined;
}
const match = text.match(/export\s+const\s+AKM_VERSION_RANGE\s*=\s*["']([^"']+)["']/);
return match?.[1];
}
const LS_REMOTE_TIMEOUT_MS = 5_000;
/**
* `git ls-remote --tags origin` against the marketplace clone's configured
* remote — lists refs only, fetches no objects, writes no local refs.
* Returns `undefined` (never throws) when the directory is not a git
* checkout, has no `origin` remote, or the command fails/times out (e.g.
* offline) — all "cannot determine availability", not "up to date".
*/
const realListRemoteTags = (marketplaceDir) => {
let result;
try {
result = spawnSync("git", ["-C", marketplaceDir, "ls-remote", "--tags", "origin"], {
encoding: "utf8",
timeout: LS_REMOTE_TIMEOUT_MS,
});
}
catch {
return undefined;
}
if (result.status !== 0 || !result.stdout)
return undefined;
const tags = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split("\t")[1])
.filter((ref) => typeof ref === "string" && ref.startsWith("refs/tags/"))
.map((ref) => ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, ""))
.map((tag) => tag.replace(/^v/, ""))
.filter(isExactSemver);
return [...new Set(tags)];
};
/**
* Build one `plugin-version` advisory per detected `akm` harness plugin,
* plus one `opencode-plugin-version` advisory when the
* OpenCode plugin's bundled `akm-cli` is found — it shares the host's
* databases in-process, so its lag matters the same way a stale Claude
* plugin's does. Returns `[]` when neither is installed — the benign,
* common case.
*/
export function collectPluginStalenessAdvisories(options) {
const pluginsRoot = options.pluginsRoot ?? claudePluginsDir();
const listRemoteTags = options.listRemoteTags ?? realListRemoteTags;
const plugins = detectInstalledPlugins(pluginsRoot);
const results = plugins.map((plugin) => buildAdvisory(plugin, options.cliVersion, pluginsRoot, listRemoteTags));
const opencodeCacheRoot = options.opencodeCacheRoot ?? opencodeCacheDir();
const bundledVersion = detectOpencodeBundledAkmVersion(opencodeCacheRoot);
if (bundledVersion !== undefined) {
results.push(buildOpencodeBundledAdvisory(bundledVersion, options.cliVersion));
}
return results;
}
function buildAdvisory(plugin, cliVersion, pluginsRoot, listRemoteTags) {
const pluginRef = `${plugin.pluginName}@${plugin.marketplace}`;
// Point 2: newest available vs. installed, via the marketplace clone's remote.
const marketplaceDir = path.join(pluginsRoot, "marketplaces", plugin.marketplace);
let availableVersion;
try {
const tags = fs.existsSync(marketplaceDir) ? listRemoteTags(marketplaceDir) : undefined;
availableVersion = tags && tags.length > 0 ? maxSatisfying(tags, "*") : undefined;
}
catch {
availableVersion = undefined;
}
const stale = availableVersion !== undefined &&
plugin.version !== availableVersion &&
maxSatisfying([plugin.version, availableVersion], "*") === availableVersion;
// Point 3: does the plugin's own declared range admit the running CLI?
const versionRange = readVersionRange(plugin.pluginDir);
const rangeKnown = versionRange !== undefined && isSemverRange(versionRange);
const admitted = rangeKnown ? satisfiesRange(cliVersion, versionRange) : undefined;
const messageParts = [`${pluginRef}: installed ${plugin.version}`];
if (availableVersion !== undefined) {
messageParts.push(stale ? `available ${availableVersion} (STALE)` : `available ${availableVersion} (up to date)`);
}
if (stale)
messageParts.push(`-> claude plugin update ${pluginRef}`);
if (rangeKnown && admitted === false) {
messageParts.push(`installed plugin requires akm-cli ${versionRange}; running ${cliVersion} -> NOT ADMITTED (plugin is inactive)`);
}
return {
name: "plugin-version",
kind: "deterministic",
status: stale || admitted === false ? "warn" : "pass",
confidence: "high",
message: messageParts.join(" — "),
evidence: {
harness: plugin.harness,
marketplace: plugin.marketplace,
plugin: plugin.pluginName,
installedVersion: plugin.version,
availableVersion: availableVersion ?? null,
stale,
versionRange: versionRange ?? null,
cliVersion,
admitted: admitted ?? null,
},
};
}