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
164 lines (163 loc) • 6.93 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/.
/**
* Setup-time agent CLI detection (v1 spec §12.3).
*
* Probes every known/configured agent profile by checking `which <bin>` on
* PATH. We do **not** call `<bin> --version` — that would execute the
* binary at setup time, which is unnecessarily side-effectful for
* detection. The presence of the binary on PATH is sufficient signal; the
* spawn wrapper handles failures at run-time.
*
* Tests inject a fake `whichFn` so detection branches can be exercised
* without poking at the real PATH.
*/
import fs from "node:fs";
import path from "node:path";
import { getBuiltinAgentProfile, listBuiltinAgentProfiles, OPENCODE_SDK_SERVER_BIN, } from "./profiles.js";
/**
* Default PATH lookup. Walks `process.env.PATH` and returns the first
* existing executable file. Returns `undefined` when the bin is not on
* PATH or the env is empty.
*
* `process.env.PATH` is split on the platform-correct delimiter. On Windows
* agent CLIs install as extension-bearing shims (`claude.cmd`, `q.exe`), so
* each PATH entry is probed for the bare name AND for `<bin><ext>` over
* PATHEXT — an exact-match-only probe reports every agent CLI as missing
* there, which silently hides the "installed CLI agent" option during setup.
*/
export function defaultWhich(bin, envSource = process.env) {
// Computed once per call — not per PATH entry — since it never varies.
const suffixes = executableSuffixes(envSource);
if (!bin || bin.includes("/") || bin.includes("\\")) {
// Absolute / relative paths: caller already specified location.
for (const suffix of suffixes) {
const candidate = bin + suffix;
try {
if (fs.statSync(candidate).isFile())
return candidate;
}
catch {
/* try the next extension */
}
}
return undefined;
}
const pathVar = envSource.PATH ?? envSource.Path ?? envSource.path ?? "";
if (!pathVar)
return undefined;
const sep = pathVar.includes(";") && !pathVar.includes(":") ? ";" : path.delimiter;
for (const dir of pathVar.split(sep)) {
if (!dir)
continue;
const base = path.join(dir, bin);
for (const suffix of suffixes) {
const candidate = base + suffix;
try {
const st = fs.statSync(candidate);
if (st.isFile())
return candidate;
}
catch {
/* keep walking */
}
}
}
return undefined;
}
/** Windows PATHEXT default, used when the env carries no explicit list. */
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
/**
* Executable suffixes to try for each candidate path, bare name (`""`) first
* so POSIX resolution is byte-for-byte unchanged. Extensions are appended only
* on win32 or when the env supplies PATHEXT (which is also the seam tests use
* to cover Windows resolution from a POSIX runner).
*/
/**
* Script shims that `child_process.spawn` cannot execute directly on Windows.
*
* Node refuses to spawn `.bat`/`.cmd` without a shell (the CVE-2024-27980
* hardening), so a detection that returns one of these reports an agent as
* available that dispatch then fails to launch. They stay in the candidate
* list — a shim is better than reporting the CLI as missing — but they sort
* last, so a real `.exe` next to `claude.cmd` wins.
*/
const NON_SPAWNABLE_SUFFIXES = new Set([".cmd", ".bat"]);
function executableSuffixes(envSource) {
const pathext = envSource.PATHEXT ?? envSource.Pathext ?? envSource.pathext;
if (process.platform !== "win32" && !pathext)
return [""];
const exts = (pathext ?? DEFAULT_PATHEXT)
.split(";")
.map((ext) => ext.trim())
.filter(Boolean)
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
const directlySpawnable = exts.filter((ext) => !NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
const shims = exts.filter((ext) => NON_SPAWNABLE_SUFFIXES.has(ext.toLowerCase()));
return ["", ...directlySpawnable, ...shims];
}
let detectOverrides;
/** TEST-ONLY. Swap the detection implementations; pass undefined to restore. */
export function _setAgentDetectForTests(fakes) {
detectOverrides = fakes;
}
/**
* Probe every resolvable agent profile (built-ins plus user overrides)
* for an installed CLI.
*
* @param agent Optional `agent` config block. When omitted we probe the
* built-ins.
* @param whichFn Binary lookup. Tests should inject a stub.
*/
export function detectAgentCliProfiles(agent, whichFn = defaultWhich) {
if (detectOverrides?.detectAgentCliProfiles)
return detectOverrides.detectAgentCliProfiles(agent, whichFn);
const profilesByName = new Map(Object.values(listBuiltinAgentProfiles()).map((profile) => [profile.name, profile]));
for (const [name, engine] of Object.entries(agent?.engines ?? {})) {
if (engine.kind !== "agent")
continue;
const builtin = getBuiltinAgentProfile(engine.platform);
profilesByName.set(name, {
name,
platform: engine.platform,
bin: engine.bin ?? builtin?.bin ?? (engine.platform === "opencode-sdk" ? OPENCODE_SDK_SERVER_BIN : engine.platform),
args: engine.args ?? builtin?.args ?? [],
stdio: "captured",
envPassthrough: builtin?.envPassthrough ?? [],
parseOutput: "text",
});
}
const profiles = [...profilesByName.values()];
return profiles.map((profile) => probeProfile(profile, whichFn));
}
function probeProfile(profile, whichFn) {
const resolved = whichFn(profile.bin);
return {
name: profile.name,
bin: profile.bin,
available: Boolean(resolved),
...(resolved ? { resolvedPath: resolved } : {}),
};
}
/**
* Pick the default profile to persist after a setup-time detection run.
*
* Strategy:
* 1. If the user already set `agent.default` and that profile is
* available, keep it (round-trip stability).
* 2. Otherwise, pick the first available result in detection order.
* 3. If nothing is available, return `undefined` and the caller skips
* writing `agent.default`.
*/
export function pickDefaultAgentProfile(results, existingDefault) {
if (detectOverrides?.pickDefaultAgentProfile)
return detectOverrides.pickDefaultAgentProfile(results, existingDefault);
if (existingDefault) {
const match = results.find((r) => r.name === existingDefault && r.available);
if (match)
return match.name;
}
const first = results.find((r) => r.available);
return first?.name;
}