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
460 lines (459 loc) • 18.4 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/.
/**
* Service detection utilities for the setup wizard.
*
* Pure detection functions with no user interaction — each returns
* a result object describing what was found.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { runManagedSubprocess } from "../core/subprocess.js";
import { defaultWhich } from "../integrations/agent/detect.js";
import { getBuiltinAgentProfile, OPENCODE_SDK_SERVER_BIN } from "../integrations/agent/profiles.js";
import { AGENT_DISPATCH_HARNESSES, SESSION_LOG_HARNESSES } from "../integrations/harnesses/index.js";
import { detectHarnessConfigs } from "./harness-config-import.js";
let detectOverrides;
/** TEST-ONLY. Swap the network/host probes; pass undefined to restore. */
export function _setDetectForTests(fakes) {
detectOverrides = fakes;
}
// ── Ollama Detection ────────────────────────────────────────────────────────
const OLLAMA_BASE = "http://localhost:11434";
/**
* Detect if Ollama is running and list available models.
*
* Tries the HTTP API first (`/api/tags`), then falls back to `ollama list`
* via subprocess. Returns available models sorted alphabetically.
*/
export async function detectOllama() {
if (detectOverrides?.detectOllama)
return detectOverrides.detectOllama();
const result = { available: false, models: [], endpoint: OLLAMA_BASE };
// Try HTTP API first
try {
const response = await fetch(`${OLLAMA_BASE}/api/tags`, {
signal: AbortSignal.timeout(3000),
});
if (response.ok) {
const data = (await response.json());
if (Array.isArray(data.models)) {
result.models = data.models
.map((m) => (typeof m.name === "string" ? m.name.replace(/:latest$/, "") : ""))
.filter(Boolean)
.sort();
result.available = true;
return result;
}
}
}
catch {
// HTTP failed — try CLI fallback
}
// CLI fallback — bounded (10 s) so a wedged `ollama` can't hang detection.
try {
const { stdout: text, exitCode } = await runManagedSubprocess(["ollama", "list"], {
capture: true,
timeoutMs: 10_000,
});
if (exitCode === 0 && text.trim()) {
const lines = text.trim().split("\n").slice(1); // skip header
result.models = lines
.map((line) => {
const name = line.split(/\s+/)[0]?.replace(/:latest$/, "");
return name || "";
})
.filter(Boolean)
.sort();
result.available = true;
}
}
catch {
// Ollama not installed or not in PATH
}
return result;
}
const LMSTUDIO_BASE = "http://localhost:1234";
/**
* Detect if LM Studio is running and list available models.
* Probes the OpenAI-compatible /v1/models endpoint.
*/
export async function detectLMStudio() {
const result = { available: false, models: [], endpoint: LMSTUDIO_BASE };
try {
const response = await fetch(`${LMSTUDIO_BASE}/v1/models`, {
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
const data = (await response.json());
if (Array.isArray(data.data)) {
result.models = data.data
.map((m) => (typeof m.id === "string" ? m.id : ""))
.filter(Boolean)
.sort();
result.available = true;
}
}
}
catch {
// LM Studio not running or not accessible
}
return result;
}
// ── Agent Platform Detection ────────────────────────────────────────────────
/**
* Setup stash-source candidates, derived from the unified harness registry
* (#567).
*
* BEHAVIOUR FIX: the old hardcoded list named 6 harnesses (Claude Code,
* OpenCode, Continue, Codeium/Windsurf, Cursor, Codex CLI) but only the first
* two have a session-log provider. Selecting any of the other four added a
* filesystem stash source that was never indexed/extracted — a silent no-op
* "detection trap" (sessions never reached the improve pipeline).
*
* The registry is now the single source of which harnesses are real stash
* sources: a candidate must (a) have `capabilities.sessionLogs === true` AND
* (b) declare a `setupDetectionDir`. `SESSION_LOG_HARNESSES` already filters by
* (a), so dropping the four dead options is automatic — they aren't even in the
* registry. Adding a new session-log harness with a detection dir makes it
* appear here with no edit to this file.
*/
const AGENT_PLATFORMS = SESSION_LOG_HARNESSES.filter((h) => h.setupDetectionDir).map((h) => ({ name: h.displayName, relPath: h.setupDetectionDir }));
/**
* Scan the user's home directory for known agent platform config directories.
* Supports both HOME (Unix) and USERPROFILE (Windows).
*/
export function detectAgentPlatforms() {
if (detectOverrides?.detectAgentPlatforms)
return detectOverrides.detectAgentPlatforms();
const home = process.env.HOME?.trim() || process.env.USERPROFILE?.trim();
if (!home)
return [];
return AGENT_PLATFORMS.filter((p) => {
const fullPath = path.join(home, p.relPath);
try {
return fs.statSync(fullPath).isDirectory();
}
catch {
return false;
}
}).map((p) => ({
name: p.name,
path: path.join(home, p.relPath),
}));
}
/**
* Map of env var NAME → { provider, kind }. The key NAMES are the only thing
* this module ever inspects from `process.env`; values are never touched.
*/
const PROVIDER_ENV_VARS = [
{ name: "ANTHROPIC_API_KEY", provider: "anthropic", kind: "apiKey" },
{ name: "OPENAI_API_KEY", provider: "openai", kind: "apiKey" },
{ name: "GEMINI_API_KEY", provider: "gemini", kind: "apiKey" },
{ name: "GOOGLE_API_KEY", provider: "gemini", kind: "apiKey" },
{ name: "GROQ_API_KEY", provider: "groq", kind: "apiKey" },
{ name: "OLLAMA_HOST", provider: "ollama", kind: "endpoint" },
{ name: "OLLAMA_BASE_URL", provider: "ollama", kind: "endpoint" },
{ name: "LM_STUDIO_BASE_URL", provider: "lmstudio", kind: "endpoint" },
{ name: "LM_STUDIO_API_BASE", provider: "lmstudio", kind: "endpoint" },
{ name: "LMSTUDIO_BASE_URL", provider: "lmstudio", kind: "endpoint" },
{ name: "LMSTUDIO_API_BASE", provider: "lmstudio", kind: "endpoint" },
{ name: "AKM_LLM_API_KEY", provider: "akm-llm", kind: "apiKey" },
{ name: "AKM_LLM_ENDPOINT", provider: "akm-llm", kind: "endpoint" },
{ name: "AKM_LLM_BASE_URL", provider: "akm-llm", kind: "endpoint" },
];
/**
* Scan `process.env` for the presence of known provider configuration env var
* NAMES and return inferred providers.
*
* Pure function — no network, no filesystem. It reads only whether each known
* key is *defined and non-empty*; it never reads, returns, or logs the value.
*
* @param envSource Env to inspect. Defaults to `process.env`. Tests inject a
* fake env so a real API key is never required.
* @returns Inferred providers, each carrying the env var NAME only.
*/
export function scanProviderEnvVars(envSource = process.env) {
const results = [];
for (const entry of PROVIDER_ENV_VARS) {
const value = envSource[entry.name];
const present = typeof value === "string" && value.trim().length > 0;
if (!present)
continue;
results.push({ provider: entry.provider, envVar: entry.name, kind: entry.kind });
}
return results;
}
/** Default endpoints probed in addition to any harness-config base URLs. */
const DEFAULT_LOCAL_ENDPOINTS = [
{ baseUrl: "http://localhost:11434", label: "Ollama" },
{ baseUrl: "http://localhost:1234", label: "LM Studio" },
{ baseUrl: "http://localhost:8080", label: "Local (8080)" },
];
/**
* Pick a sensible default model from a list via a name heuristic.
*
* Preference order: an explicit "instruct" variant, then the longest name
* (a rough proxy for the larger / more-capable variant), then the first.
* Returns `undefined` for an empty list.
*/
export function pickDefaultModel(models) {
const cleaned = models.filter((m) => typeof m === "string" && m.trim().length > 0);
if (cleaned.length === 0)
return undefined;
const instruct = cleaned.filter((m) => /instruct/i.test(m));
const pool = instruct.length > 0 ? instruct : cleaned;
// Prefer the longest name as a proxy for the larger/most-specific variant,
// breaking ties by sort order for determinism.
return [...pool].sort((a, b) => b.length - a.length || a.localeCompare(b))[0];
}
/**
* Probe a single OpenAI-compatible `/v1/models` endpoint.
*
* Tolerant of failure: any network/timeout/parse error yields an
* `available: false` result rather than throwing.
*/
export async function probeLocalEndpoint(baseUrl, label, timeoutMs = 2000) {
const result = { baseUrl, label, available: false, models: [] };
const url = `${baseUrl.replace(/\/$/, "")}/v1/models`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (response.ok) {
const data = (await response.json());
if (Array.isArray(data.data)) {
result.models = data.data
.map((m) => (typeof m.id === "string" ? m.id : ""))
.filter(Boolean)
.sort();
result.available = true;
result.defaultModel = pickDefaultModel(result.models);
}
}
}
catch {
// Endpoint down/unreachable — leave available=false.
}
return result;
}
/**
* Probe the default local endpoints (Ollama 11434, LM Studio 1234, generic
* 8080) plus any base URLs found in imported harness configs.
*
* Never throws: every endpoint being down yields a list of unavailable
* results, not an error.
*
* @param harnessBaseUrls Extra base URLs to probe (e.g. from harness configs).
*/
export async function detectLocalServers(harnessBaseUrls = []) {
const endpoints = [...DEFAULT_LOCAL_ENDPOINTS];
for (const raw of harnessBaseUrls) {
if (typeof raw !== "string" || raw.trim().length === 0)
continue;
// Strip a trailing /v1 (and trailing slash) so we probe consistently.
const baseUrl = raw.replace(/\/$/, "").replace(/\/v1$/, "");
if (!endpoints.some((e) => e.baseUrl === baseUrl)) {
endpoints.push({ baseUrl, label: `Harness (${baseUrl})` });
}
}
return Promise.all(endpoints.map((e) => probeLocalEndpoint(e.baseUrl, e.label)));
}
/**
* Suggest stash directories, ranked (lower rank = higher priority).
*
* Sources, in priority order:
* 1. An existing config `stashDir` (always rank 0 — no-op / keep current).
* 2. A `akm/` or `agent-stash/` directory in the CWD git repo.
* 3. `~/akm` then `~/.akm` when they already exist.
*
* Pure function — filesystem reads only, no network. Tests inject `cwd`/`home`.
*/
export function detectStashDir(opts) {
const cwd = opts?.cwd ?? process.cwd();
const home = opts?.home ?? os.homedir();
const suggestions = [];
const seen = new Set();
const push = (p, reason, rank) => {
const abs = path.resolve(p);
if (seen.has(abs))
return;
seen.add(abs);
suggestions.push({ path: abs, reason, rank });
};
if (opts?.existingStashDir?.trim()) {
push(opts.existingStashDir, "existing config stashDir", 0);
}
// CWD git repo containing akm/ or agent-stash/
const repoRoot = findGitRepoRoot(cwd);
if (repoRoot) {
for (const dirName of ["akm", "agent-stash"]) {
const candidate = path.join(repoRoot, dirName);
try {
if (fs.statSync(candidate).isDirectory()) {
push(candidate, `git repo contains ${dirName}/`, 1);
}
}
catch {
// not present
}
}
}
// ~/akm then ~/.akm when they exist
if (home) {
for (const dirName of ["akm", ".akm"]) {
const candidate = path.join(home, dirName);
try {
if (fs.statSync(candidate).isDirectory()) {
push(candidate, `${dirName} exists in home`, 2);
}
}
catch {
// not present
}
}
}
return suggestions.sort((a, b) => a.rank - b.rank);
}
/** Walk up from `start` looking for a directory containing `.git`. */
function findGitRepoRoot(start) {
let dir = path.resolve(start);
for (;;) {
try {
if (fs.existsSync(path.join(dir, ".git")))
return dir;
}
catch {
// ignore
}
const parent = path.dirname(dir);
if (parent === dir)
return undefined;
dir = parent;
}
}
/**
* Detect the best agent harness, preferring in-process/SDK dispatch over CLI
* subprocess dispatch:
* 1. `opencode-sdk` — the embedded SDK, when the server binary it spawns is
* on PATH.
* 2. the first dispatch-capable CLI harness whose binary is on PATH, in
* registry order (`opencode`, `claude`, `codex`, ...).
* 3. none.
*
* The SDK import resolving is NOT evidence the SDK path can run: `@opencode-ai/sdk`
* is a hard dependency of akm-cli itself, so `import()` always succeeds and an
* import-only check would report `opencode-sdk` on every machine — including
* ones where the first agentic command then dies with spawn ENOENT. The SDK
* runner spawns `opencode serve` (see `harnesses/opencode-sdk/sdk-runner.ts`),
* so the binary probe is what actually decides. The package cannot substitute
* for the binary under any driver: it declares `"dependencies": {}` and its
* own `createOpencodeServer` spawns the same `opencode serve`.
*
* Pure aside from the dynamic import resolution (which performs no network).
*/
export async function detectHarness(whichFn = defaultWhich) {
// Probed once; reused below so the `opencode` CLI fallback doesn't repeat
// the identical full-PATH walk this just performed.
const opencodePath = whichFn(OPENCODE_SDK_SERVER_BIN);
if (opencodePath) {
try {
await import("@opencode-ai/sdk");
return "opencode-sdk";
}
catch {
// SDK genuinely unavailable (e.g. externalized in a standalone build).
// Fall through — the `opencode` CLI harness is probed below.
}
}
for (const harness of AGENT_DISPATCH_HARNESSES) {
// The SDK harness dispatches without a CLI profile and was decided above;
// an explicit skip so a future BUILTINS entry for it can't silently
// reintroduce it here after the import guard already rejected it.
if (harness.id === "opencode-sdk")
continue;
const bin = getBuiltinAgentProfile(harness.id)?.bin;
if (!bin)
continue;
const found = bin === OPENCODE_SDK_SERVER_BIN ? opencodePath : whichFn(bin);
if (found)
return harness.id;
}
return "none";
}
/**
* Run the full environment-detection pipeline once and return a single typed
* result. Orchestrates env-var scan, harness config import, harness selection,
* local-server probes, and stash-dir suggestions.
*
* SAFETY: No API key VALUE is ever read, stored, logged, or returned — only
* env var NAMES. Tolerant of every detector failing.
*
* @param opts.existingStashDir Current config stashDir (no-op suggestion).
* @param opts.envSource Env to scan. Defaults to `process.env`.
* @param opts.whichFn Binary lookup. Tests inject a stub.
*/
export async function detectEnvironment(opts) {
const envSource = opts?.envSource ?? process.env;
const whichFn = opts?.whichFn ?? defaultWhich;
let harnessConfigs = [];
try {
harnessConfigs = detectHarnessConfigs();
}
catch {
harnessConfigs = [];
}
const harnessBaseUrls = harnessConfigs.map((c) => c.baseUrl).filter((u) => typeof u === "string");
const [harness, localServers] = await Promise.all([
detectHarness(whichFn),
detectLocalServers(harnessBaseUrls).catch(() => []),
]);
let agentPlatforms = [];
try {
agentPlatforms = detectAgentPlatforms();
}
catch {
agentPlatforms = [];
}
return {
harness,
providers: scanProviderEnvVars(envSource),
harnessConfigs,
localServers,
stashSuggestions: detectStashDir({
existingStashDir: opts?.existingStashDir,
cwd: opts?.cwd,
home: opts?.home,
}),
agentPlatforms,
};
}
/**
* Render a compact, human-readable "Detected environment" summary block.
* Contains env var NAMES only — never any value.
*/
export function renderDetectionSummary(env) {
const lines = ["Detected environment:"];
lines.push(` Harness: ${env.harness}`);
const liveServers = env.localServers.filter((s) => s.available);
if (liveServers.length > 0) {
lines.push(` Local servers: ${liveServers
.map((s) => `${s.label}${s.defaultModel ? ` (${s.defaultModel})` : ""}`)
.join(", ")}`);
}
else {
lines.push(" Local servers: none reachable");
}
if (env.providers.length > 0) {
// NAMES only — never values.
lines.push(` Provider keys: ${env.providers.map((p) => `${p.provider}:${p.envVar}`).join(", ")}`);
}
else {
lines.push(" Provider keys: none in environment");
}
const topStash = env.stashSuggestions[0];
if (topStash) {
lines.push(` Stash suggest: ${topStash.path} (${topStash.reason})`);
}
return lines.join("\n");
}