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

164 lines (163 loc) • 6.93 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/. /** * 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; }