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

460 lines (459 loc) • 18.4 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/. /** * 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"); }