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
537 lines (536 loc) • 25.2 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 wizard connection steps: embedding (Ollama) detection, LLM provider
* selection, and the two-step small-model + agent connection configuration.
*/
import * as p from "../../cli/clack.js";
import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../../integrations/agent/index.js";
import { detectLMStudio, detectOllama } from "../detect.js";
import { verifyOpenAiCompatibleEndpoint } from "../detected-engines.js";
import { cloneLlmConnection, readAgentEngineSelection, readCurrentLlmEngine, } from "../engine-config.js";
import { prompt } from "../prompt.js";
import { keepCurrentOption, llmConnection, lmStudioOptionHint, probeLlmConnection, promptApiKeyEnvVarName, promptEndpointUrl, promptLmStudioModel, promptModelName, } from "./connection-shared.js";
export async function stepOllama(current) {
const spin = p.spinner();
spin.start("Checking for Ollama...");
const ollama = await detectOllama();
if (!ollama.available) {
spin.stop("Ollama not detected");
p.log.info("Ollama is not running. Embeddings will use the built-in local model.\n" +
"To use Ollama later, install it from https://ollama.com and re-run `akm setup`.");
// Preserve existing embedding config when Ollama is not available
return { embedding: current.embedding };
}
spin.stop(`Ollama detected at ${ollama.endpoint}`);
if (ollama.models.length > 0) {
p.log.info(`Available models: ${ollama.models.join(", ")}`);
}
// Embedding model selection
const embeddingModels = ollama.models.filter((m) => m.includes("embed") || m.includes("nomic") || m.includes("minilm") || m.includes("bge"));
const hasEmbeddingModels = embeddingModels.length > 0;
let embedding;
const embeddingOptions = [];
for (const m of embeddingModels) {
embeddingOptions.push({ value: m, label: m, hint: "Ollama" });
}
embeddingOptions.push({
value: "local",
label: "Built-in local embeddings",
hint: "no server needed",
});
if (current.embedding) {
embeddingOptions.push({
value: "keep",
label: `Keep current: ${current.embedding.provider ?? current.embedding.endpoint}`,
hint: current.embedding.model,
});
}
const embChoice = await prompt(() => p.select({
message: "Which embedding provider should akm use?",
options: embeddingOptions,
initialValue: hasEmbeddingModels ? embeddingModels[0] : "local",
}));
if (embChoice === "keep") {
embedding = current.embedding;
}
else if (embChoice !== "local") {
// Ask for dimension — different models produce different sizes.
// Common dimensions: nomic-embed-text=768, mxbai-embed-large=1024,
// all-minilm/bge-small=384. Default based on selected model.
const knownDims = {
nomic: 768,
mxbai: 1024,
minilm: 384,
bge: 384,
qwen3: 1024,
};
const guessedDim = Object.entries(knownDims).find(([k]) => embChoice.includes(k))?.[1] ?? 384;
p.note("Embedding dimension must match the model. Common values: 384 (BGE small), 768 (BGE base), 1024 (BGE large). Press Enter to accept the detected default.", "Embedding dimension");
const dimChoice = await prompt(() => p.text({
message: `Embedding dimension for ${embChoice}:`,
placeholder: String(guessedDim),
defaultValue: String(guessedDim),
validate: (v) => {
const n = Number(v);
if (!Number.isInteger(n) || n <= 0)
return "Must be a positive integer";
},
}));
embedding = {
provider: "ollama",
endpoint: `${ollama.endpoint}/v1/embeddings`,
model: embChoice,
dimension: Number(dimChoice),
};
p.note([
"Recommended Qwen embedding models (modern, high context support):",
" • qwen3-embedding-0.6b — fast and lightweight (ollama pull qwen3-embedding-0.6b)",
" • qwen3-embedding-4b — higher quality (ollama pull qwen3-embedding-4b)",
"",
"For long documents (wiki pages, large files), set context length to avoid 400 errors:",
" akm config set embedding.contextLength 8192",
].join("\n"), "Embedding tips");
}
// else: undefined → use built-in local
// Surface Ollama details to the LLM step so it can offer Ollama as a preset.
const ollamaChatModels = ollama.models.filter((m) => !embeddingModels.includes(m));
return { embedding, ollamaEndpoint: ollama.endpoint, ollamaChatModels };
}
const LLM_PRESETS = [
{
value: "anthropic",
label: "Anthropic Claude (OpenAI SDK compat beta)",
endpoint: "https://api.anthropic.com/v1/chat/completions",
defaultModel: "claude-sonnet-4-5",
hint: "beta OpenAI-compat layer; set AKM_LLM_API_KEY; override the model if the default is unavailable",
},
{
value: "openai",
label: "OpenAI",
endpoint: "https://api.openai.com/v1/chat/completions",
defaultModel: "gpt-4o-mini",
hint: "AKM_LLM_API_KEY required",
},
{
value: "google",
label: "Google Gemini (OpenAI-compat)",
endpoint: "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
defaultModel: "gemini-2.0-flash",
hint: "OpenAI-compat endpoint, AKM_LLM_API_KEY required",
},
];
/**
* Step 3a: pick an LLM provider. Used for LLM-gated background features
* (e.g. memory inference).
*
* @internal Exported for testing only.
*/
export async function stepLlm(current, ollamaEndpoint, ollamaChatModels, lmStudio, harnessConfigs) {
// Build "Import from <Harness>" options and prepend them before LLM_PRESETS
const harnessOptions = (harnessConfigs ?? []).map((h) => ({
value: `harness:${h.harnessName}`,
label: `Import from ${h.harnessName}`,
hint: [h.provider, h.model].filter(Boolean).join(" / ") || "detected",
}));
const options = [
...harnessOptions,
...LLM_PRESETS.map((preset) => ({
value: preset.value,
label: preset.label,
hint: preset.hint,
})),
];
const ollamaAvailable = Boolean(ollamaEndpoint && ollamaChatModels && ollamaChatModels.length > 0);
if (ollamaAvailable) {
options.push({
value: "ollama",
label: "Ollama (local)",
hint: ollamaChatModels?.[0] ?? "local",
});
}
options.push({ value: "lmstudio", label: "LM Studio / local server", hint: lmStudioOptionHint(lmStudio) });
options.push({ value: "custom", label: "Custom OpenAI-compatible endpoint" });
options.push({ value: "none", label: "Skip LLM", hint: "LLM-gated background features stay disabled" });
const currentLlm = readCurrentLlmEngine(current);
if (currentLlm) {
options.push(keepCurrentOption(currentLlm));
}
const initialValue = currentLlm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
const choice = await prompt(() => p.select({
message: "Configure an LLM for background features:",
options,
initialValue,
}));
if (choice === "keep")
return cloneLlmConnection(currentLlm);
if (choice === "none")
return undefined;
// Handle "Import from <Harness>" choices
if (typeof choice === "string" && choice.startsWith("harness:")) {
const harness = (harnessConfigs ?? []).find((h) => `harness:${h.harnessName}` === choice);
if (!harness)
return undefined;
// Show a summary before accepting
p.log.info(`Importing LLM config from ${harness.harnessName}: ` +
[harness.provider, harness.model, harness.baseUrl].filter(Boolean).join(", "));
if (!harness.baseUrl || !harness.model) {
p.log.warn(`Skipping ${harness.harnessName}: no complete endpoint/model was detected.`);
return undefined;
}
const verified = await verifyOpenAiCompatibleEndpoint({
endpoint: harness.baseUrl,
model: harness.model,
apiKeyEnvVar: harness.apiKeyEnvVar,
});
if (!verified.ok) {
p.log.warn(`Skipping ${harness.harnessName}: ${verified.reason}. Fix its endpoint/credential and retry setup.`);
return undefined;
}
const llmConfig = {
endpoint: verified.endpoint,
model: harness.model ?? "",
temperature: 0.3,
...(harness.apiKeyEnvVar ? { apiKey: `\${${harness.apiKeyEnvVar}}` } : {}),
};
if (harness.provider)
llmConfig.provider = harness.provider;
return llmConfig;
}
let llm;
if (choice === "ollama") {
const modelChoice = await prompt(() => p.select({
message: "Which Ollama model?",
options: (ollamaChatModels ?? []).map((m) => ({ value: m, label: m })),
initialValue: ollamaChatModels?.[0],
}));
llm = llmConnection("ollama", `${ollamaEndpoint}/v1/chat/completions`, modelChoice);
}
else if (choice === "lmstudio") {
const currentLmsLlm = currentLlm?.provider === "lmstudio" ? currentLlm : undefined;
const defaultEndpoint = currentLmsLlm?.endpoint ??
(lmStudio?.endpoint ? `${lmStudio.endpoint}/v1/chat/completions` : "http://localhost:1234/v1/chat/completions");
const endpoint = await promptEndpointUrl({ placeholder: defaultEndpoint, defaultValue: defaultEndpoint });
const model = await promptLmStudioModel(lmStudio, currentLmsLlm?.model);
llm = llmConnection("lmstudio", endpoint.trim(), model.trim());
}
else if (choice === "custom") {
const currentCustomLlm = currentLlm?.provider === "custom" ? currentLlm : undefined;
const endpoint = await promptEndpointUrl({
message: "OpenAI-compatible chat completions endpoint:",
placeholder: currentCustomLlm?.endpoint ?? "https://your-host/v1/chat/completions",
defaultValue: currentCustomLlm?.endpoint,
schemeMessage: "Endpoint must start with http:// or https://",
});
const model = await promptModelName({
placeholder: currentCustomLlm?.model ?? "gpt-4o-mini",
defaultValue: currentCustomLlm?.model,
});
llm = llmConnection("custom", endpoint.trim(), model.trim());
}
else {
const preset = LLM_PRESETS.find((p) => p.value === choice);
if (!preset)
return undefined;
const model = await promptModelName({
message: `Model for ${preset.label}:`,
placeholder: preset.defaultModel,
defaultValue: preset.defaultModel,
});
llm = llmConnection(preset.value, preset.endpoint, model.trim() || preset.defaultModel);
}
// Remind the user about API key placement. We do not offer a "store in config"
// option because saveConfig() strips apiKey fields before writing — persisting
// secrets would need an encrypted/secure store that we don't ship.
const needsKey = llm.provider !== "ollama" && !llm.endpoint.includes("localhost");
if (needsKey && !process.env.AKM_LLM_API_KEY) {
p.log.info("This provider requires an API key. Set AKM_LLM_API_KEY in your shell (e.g. `export AKM_LLM_API_KEY=...`) before running `akm index`.");
}
// Capability probe — best-effort, never blocks setup.
await probeLlmConnection(llm);
return llm;
}
/**
* Step 1/2: Configure the small model connection used for bounded LLM features.
*
* Detects Ollama automatically and pre-selects it. The user may also choose
* OpenAI, LM Studio, a custom endpoint, or skip the step entirely.
*/
export async function stepSmallModelConnection(current) {
p.log.step("Step 1/2: Configure your small model connection");
p.note([
"This connection is used for background processing:",
" • akm improve (lesson distillation)",
" • akm remember --enrich (memory compression)",
].join("\n"));
// Probe for Ollama and LM Studio in the background while showing the note.
const spin = p.spinner();
spin.start("Detecting local services...");
const [ollama, lmStudio] = await Promise.all([detectOllama(), detectLMStudio()]);
const detectedServices = [
ollama.available ? `Ollama at ${ollama.endpoint}` : null,
lmStudio.available ? `LM Studio at ${lmStudio.endpoint}` : null,
]
.filter(Boolean)
.join(", ");
spin.stop(detectedServices ? `Detected: ${detectedServices}` : "No local services detected");
const ollamaEndpoint = ollama.available ? ollama.endpoint : undefined;
const providerOptions = [];
if (ollama.available) {
providerOptions.push({
value: "ollama",
label: "Ollama (local)",
hint: `detected at ${ollama.endpoint}`,
});
}
providerOptions.push({ value: "openai", label: "OpenAI", hint: "requires AKM_LLM_API_KEY" }, { value: "lmstudio", label: "LM Studio / local server", hint: lmStudioOptionHint(lmStudio) }, { value: "custom", label: "Custom OpenAI-compatible endpoint" }, { value: "skip", label: "Skip — disable enrichment features" });
const currentLlmSmall = readCurrentLlmEngine(current);
if (currentLlmSmall) {
providerOptions.push(keepCurrentOption(currentLlmSmall));
}
const initialValue = currentLlmSmall ? "keep" : ollama.available ? "ollama" : "openai";
const providerChoice = await prompt(() => p.select({
message: "Provider:",
options: providerOptions,
initialValue,
}));
if (providerChoice === "keep") {
return { llm: cloneLlmConnection(currentLlmSmall), skipped: false, ollamaEndpoint };
}
if (providerChoice === "skip") {
p.note([
"Enrichment features disabled:",
" • akm improve — lesson generation",
" • akm remember --enrich",
"",
"You can configure this later with `akm setup`.",
].join("\n"), "Warning");
return { llm: undefined, skipped: true, ollamaEndpoint };
}
let llm;
if (providerChoice === "ollama") {
const ollamaChatModels = ollama.models.filter((m) => !m.includes("embed") && !m.includes("nomic") && !m.includes("minilm") && !m.includes("bge"));
let model;
if (ollamaChatModels.length > 0) {
model = await prompt(() => p.select({
message: "Model name:",
options: [
...ollamaChatModels.map((m) => ({ value: m, label: m })),
{ value: "__custom__", label: "Enter a model name manually..." },
],
initialValue: ollamaChatModels[0],
}));
if (model === "__custom__") {
model = await promptModelName({ placeholder: "llama3.2" });
}
}
else {
const currentOllamaModel = currentLlmSmall?.provider === "ollama" ? (currentLlmSmall.model ?? "llama3.2") : "llama3.2";
model = await promptModelName({
message: "Model name (e.g. llama3.2):",
placeholder: currentOllamaModel,
defaultValue: currentOllamaModel,
});
}
llm = llmConnection("ollama", `${ollama.endpoint}/v1/chat/completions`, model.trim());
}
else if (providerChoice === "openai") {
const currentOpenAiModel = currentLlmSmall?.provider === "openai" ? (currentLlmSmall.model ?? "gpt-4o-mini") : "gpt-4o-mini";
const model = await promptModelName({ placeholder: currentOpenAiModel, defaultValue: currentOpenAiModel });
if (!process.env.AKM_LLM_API_KEY) {
p.log.info("Set AKM_LLM_API_KEY in your shell before running `akm index`.");
}
llm = llmConnection("openai", "https://api.openai.com/v1/chat/completions", model.trim() || currentOpenAiModel);
}
else if (providerChoice === "lmstudio") {
const currentLmsEndpoint = currentLlmSmall?.provider === "lmstudio"
? (currentLlmSmall.endpoint ?? `${lmStudio.endpoint}/v1/chat/completions`)
: `${lmStudio.endpoint}/v1/chat/completions`;
const currentLmsModel = currentLlmSmall?.provider === "lmstudio" ? currentLlmSmall.model : undefined;
const endpoint = await promptEndpointUrl({ placeholder: currentLmsEndpoint, defaultValue: currentLmsEndpoint });
const model = await promptLmStudioModel(lmStudio, currentLmsModel);
llm = llmConnection("lmstudio", endpoint.trim(), model.trim());
}
else {
// custom
const currentCustomEndpoint = currentLlmSmall?.provider === "custom" ? currentLlmSmall.endpoint : undefined;
const currentCustomModel = currentLlmSmall?.provider === "custom" ? currentLlmSmall.model : undefined;
const endpoint = await promptEndpointUrl({
message: "OpenAI-compatible chat completions endpoint:",
placeholder: currentCustomEndpoint ?? "https://your-host/v1/chat/completions",
defaultValue: currentCustomEndpoint,
});
const model = await promptModelName({
placeholder: currentCustomModel ?? "gpt-4o-mini",
defaultValue: currentCustomModel,
});
const apiKeyInput = await promptApiKeyEnvVarName();
llm = llmConnection("custom", endpoint.trim(), model.trim(), apiKeyInput?.trim() ? { apiKey: `\${${apiKeyInput.trim()}}` } : {});
}
// Best-effort probe — never blocks setup.
await probeLlmConnection(llm);
return { llm, skipped: false, ollamaEndpoint };
}
/**
* Step 2/2: Configure the agent connection used for agentic features.
*
* Options depend on whether Step 1 was completed or skipped.
*/
export async function stepAgentConnection(current, smallModel) {
p.log.step("Step 2/2: Configure your agent connection");
p.note([
"This connection is used for agentic commands:",
" • akm proposal new (generate improvement proposals)",
" • akm improve (run the reflect/distill/consolidate self-improvement pipeline)",
" • akm task run (run automated task prompts)",
].join("\n"));
// Detect available CLI agents.
const detections = detectAgentCliProfiles(current);
const currentAgentBlock = readAgentEngineSelection(current);
const availableClis = detections.filter((d) => d.available);
const agentOptions = [];
if (!smallModel.skipped && smallModel.llm) {
agentOptions.push({
value: "same-connection",
label: "Same connection, select model",
hint: `uses ${smallModel.llm.endpoint.replace("/v1/chat/completions", "")}`,
});
}
agentOptions.push({ value: "new-connection", label: "New connection (different endpoint)" });
if (availableClis.length > 0) {
agentOptions.push({
value: "cli-agent",
label: "Installed CLI agent",
hint: `${availableClis.map((d) => d.name).join(", ")} detected`,
});
}
agentOptions.push({ value: "none", label: "None — disable agentic features" });
if (currentAgentBlock) {
const currentDesc = currentAgentBlock.default
? `CLI: ${currentAgentBlock.default}`
: currentAgentBlock.engines?.default?.model
? `SDK: ${currentAgentBlock.engines.default.model}`
: "configured";
agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
}
const initialAgentValue = currentAgentBlock
? "keep"
: availableClis.length > 0 && smallModel.skipped
? "cli-agent"
: !smallModel.skipped && smallModel.llm
? "same-connection"
: availableClis.length > 0
? "cli-agent"
: "none";
const agentChoice = await prompt(() => p.select({
message: "How do you want to run agent commands?",
options: agentOptions,
initialValue: initialAgentValue,
}));
if (agentChoice === "keep") {
return currentAgentBlock;
}
if (agentChoice === "none") {
p.note([
"Agentic features disabled:",
' • akm proposal new — will show "no agent configured" error',
' • akm improve — will show "no agent configured" error',
' • akm task run — will show "no agent configured" error',
"",
"You can configure this later with `akm setup`.",
].join("\n"), "Warning");
return { disabled: true };
}
if (agentChoice === "same-connection") {
if (smallModel.skipped || !smallModel.llm) {
p.log.warn("You skipped the small model connection. Configure one to use the same connection. Falling back to 'new connection'.");
// Fall through to new-connection flow
}
else {
const baseEndpoint = smallModel.llm.endpoint.replace("/v1/chat/completions", "");
p.log.info(`Endpoint: ${baseEndpoint} (from Step 1)`);
const profileName = `${smallModel.llm.provider ?? "default"}-agent`;
// Pre-populate from existing agent profile for this provider, if any.
const existingAgentModel = currentAgentBlock?.engines?.[profileName]?.model ?? smallModel.llm.model ?? undefined;
const agentModel = await promptModelName({
message: "Model to use for agent tasks (same model is fine, larger models work better):",
placeholder: existingAgentModel ?? "qwen2.5-coder:32b",
defaultValue: existingAgentModel,
});
return {
...(currentAgentBlock ?? {}),
engines: {
...(currentAgentBlock?.engines ?? {}),
[profileName]: {
...(currentAgentBlock?.engines?.[profileName] ?? {}),
kind: "agent",
platform: "opencode-sdk",
model: agentModel.trim(),
...(current.defaults?.llmEngine ? { llmEngine: current.defaults.llmEngine } : {}),
},
},
default: profileName,
};
}
}
if (agentChoice === "cli-agent") {
if (availableClis.length === 0) {
p.log.warn("No agent CLIs detected on PATH.");
return currentAgentBlock;
}
const initialCli = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? availableClis[0]?.name;
const selectedCli = await prompt(() => p.select({
message: "Which CLI agent?",
options: availableClis.map((d) => ({
value: d.name,
label: d.name,
hint: d.resolvedPath ?? d.bin,
})),
initialValue: initialCli,
}));
return {
...(currentAgentBlock ?? {}),
default: selectedCli,
};
}
// "new-connection" (also fall-through from "same-provider" when Step 1 was skipped)
// Pre-populate from current "custom" agent profile if available.
const currentCustomAgentProfile = currentAgentBlock?.engines?.["custom-agent"];
const currentNewEndpoint = undefined;
const currentNewModel = currentCustomAgentProfile?.model ?? undefined;
const newEndpoint = await promptEndpointUrl({
message: "OpenAI-compatible chat completions endpoint:",
placeholder: currentNewEndpoint ?? "https://your-host/v1/chat/completions",
defaultValue: currentNewEndpoint,
});
const newApiKeyInput = await promptApiKeyEnvVarName();
const newModel = await promptModelName({
message: "Model name (larger is better, e.g. gpt-4o):",
placeholder: currentNewModel ?? "gpt-4o",
defaultValue: currentNewModel,
});
const llmEngineName = "custom-llm";
const agentEngineName = "custom-agent";
const customProfile = {
kind: "agent",
platform: "opencode-sdk",
llmEngine: llmEngineName,
model: newModel.trim(),
};
return {
...(currentAgentBlock ?? {}),
engines: {
...(currentAgentBlock?.engines ?? {}),
[llmEngineName]: {
kind: "llm",
provider: "custom",
endpoint: newEndpoint.trim(),
model: newModel.trim(),
...(newApiKeyInput?.trim() ? { apiKey: `\${${newApiKeyInput.trim()}}` } : {}),
},
[agentEngineName]: customProfile,
},
default: agentEngineName,
};
}