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

1,030 lines (1,029 loc) • 50.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/. /** * Interactive configuration wizard for akm. * * Walks users through service detection, embedding/LLM setup, * registry selection, stash sources, and agent platform discovery. * Collects all choices and writes config once at the end. * * This module holds the wizard orchestration; the individual wizard steps, * engine writers, prompt shims, provider table, and semantic-asset preparation * live in sibling modules (`steps/*`, `engine-config`, `prompt`, * `providers`, `semantic-assets`). */ import { promises as dnsPromises } from "node:dns"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import * as p from "../cli/clack.js"; import { akmInit } from "../commands/sources/init.js"; import { deriveBundleIds } from "../core/bundle-id.js"; import { bundleEntryToSourceEntry, DEFAULT_CONFIG, loadUserConfig, mutateConfigWithPrecommit, parseAndValidateConfigText, primaryBundlePath, validateCompleteConfig, } from "../core/config/config.js"; import { readConfigText } from "../core/config/config-io.js"; import { listTopLevelConfigKeys } from "../core/config/config-schema.js"; import { deepMergeConfig, isPlainObject } from "../core/config/deep-merge.js"; import { ConfigError, UsageError } from "../core/errors.js"; import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js"; import { warn, warnOnce } from "../core/warn.js"; import { akmIndex } from "../indexer/indexer.js"; import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent/index.js"; import { defaultProfileName } from "../integrations/harnesses/index.js"; import { readLockfile } from "../integrations/lockfile.js"; import { probeLlmReachable } from "../llm/client.js"; import { detectEnvironment, detectLMStudio, renderDetectionSummary, } from "./detect.js"; import { upsertDetectedAgentEngine, upsertDetectedLlmEngine, verifyOpenAiCompatibleEndpoint } from "./detected-engines.js"; import { readCurrentLlmEngine, writeAgentEngines, writeLlmEngine } from "./engine-config.js"; import { detectHarnessConfigs } from "./harness-config-import.js"; import { bail, prompt } from "./prompt.js"; import { PROVIDER_DEFAULTS } from "./providers.js"; import { prepareSemanticSearchAssets } from "./semantic-assets.js"; import { canonicalSetupGitUrl } from "./source-identity.js"; import { createSetupContext, runSetupSteps } from "./steps.js"; import { stepAgentConnection, stepLlm, stepOllama, stepSmallModelConnection } from "./steps/connection.js"; import { stepOutputConfig } from "./steps/output.js"; import { printCapabilitySummary, stepAgentCliDetection, stepAgentPlatforms, stepAgentSelection, } from "./steps/platforms.js"; import { stepSemanticSearch } from "./steps/semantic.js"; import { stepAdditionalSources, stepAddSources, stepRegistries } from "./steps/sources.js"; import { stepStashDir } from "./steps/stashdir.js"; import { stepScheduledTasks } from "./steps/tasks.js"; // ── Setup sandbox guard ───────────────────────────────────────────────────── /** * Warn when an explicit `--dir /tmp/...` is persisted as the default bundle * path — `akm setup --dir $(mktemp -d)` resolves under this family on every * macOS machine (`/var/folders/...`), and used to be refused outright with * no discoverable remedy. The OS may reap the directory at any time, so the * operator is told up front; `applyStashIsolationToEnv` below still pre-sets * `AKM_BUNDLE_DIR` so the `getConfigDir` / `getCacheDir` isolation rules * (`core/paths.ts`) route config + cache writes into `$stashDir/.akm/` * instead of the user's host `~/.config/akm`, which is what actually * protects the host config (the 2026-05-23 setup-clobbers-user-config * incident this guard traces to). */ export function assertSetupSandbox(stashDir, dirExplicitlyProvided) { if (!dirExplicitlyProvided) return; if (!isTransientStashPath(stashDir)) return; warnOnce(`setup-tmp-stash:${stashDir}`, `\`akm setup --dir ${stashDir}\` targets a transient/sandbox directory the OS may reap at any time; ` + `the next run would then point at a deleted bundle. Config and cache writes are isolated into ` + `${stashDir}/.akm/ so the host config is unaffected, but treat this stash as disposable.`); } /** * Propagate the explicit `--dir <stashDir>` choice to the env so that the * `getConfigDir` / `getCacheDir` isolation rules in `src/core/paths.ts` * actually fire for the duration of this setup run. Without this, a CLI * caller who passes `--dir /tmp/X` but doesn't pre-export `AKM_BUNDLE_DIR` * would still write config to the host `~/.config/akm/config.json`. We * only set the env var when: * - `--dir` was explicitly provided (we have an operator-stated stash), AND * - `AKM_BUNDLE_DIR` is not already set (caller's explicit env wins). * The set is process-wide; for the CLI that's the right scope (the process * is about to do all its work against this stash). For tests, each test * already isolates env via beforeEach/afterEach so there is no leak. */ function applyStashIsolationToEnv(stashDir, dirExplicitlyProvided) { if (!dirExplicitlyProvided) return; if (process.env.AKM_BUNDLE_DIR?.trim()) return; process.env.AKM_BUNDLE_DIR = stashDir; } // ── Helpers ───────────────────────────────────────────────────────────────── /** * Raw preflight used before setup performs prompts, initialization, or writes. * * Setup merges into a current config, so it rejects an unreadable or obsolete * file before prompting or writing. There is no runtime config translator: an * operator who wants a fresh setup moves the old file aside, while an operator * who wants to preserve its values rewrites them into the current schema. */ export function assertSetupConfigPreflight() { const configPath = getConfigPath(); let text; try { text = readConfigText(configPath); } catch (error) { throw new ConfigError(`Could not read config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE"); } if (text === undefined) return; try { parseAndValidateConfigText(text, configPath); } catch (error) { if (!(error instanceof ConfigError)) throw error; if (error.code !== "INVALID_CONFIG_FILE") throw error; throw new ConfigError(`\`akm setup\` cannot run: the config at ${configPath} did not load (${error.message}). ` + "It was left untouched — setup never writes over a config it cannot first read cleanly.", error.code, "Rewrite the file using the current config schema, or move it aside to start fresh " + `(e.g. \`mv ${configPath} ${configPath}.bak\`) and re-run \`akm setup\`.`); } } function sameConfigValue(left, right) { return isDeepStrictEqual(left, right); } function configArrayItemKey(value) { if (!isPlainObject(value)) return `value:${JSON.stringify(value)}`; if (typeof value.id === "string") return `id:${value.id}`; if (typeof value.type === "string" && typeof value.url === "string") return `source:${value.type}:url:${value.url}`; if (typeof value.type === "string" && typeof value.path === "string") return `source:${value.type}:path:${path.resolve(value.path)}`; if (typeof value.url === "string") return `url:${value.url}`; if (typeof value.name === "string") return `name:${value.name}`; return `value:${JSON.stringify(value)}`; } function setupConflict(pathParts) { const field = pathParts.length > 0 ? pathParts.join(".") : "(root)"; throw new ConfigError(`Setup config conflict at ${field}: another process changed the same field after setup started. Rerun setup against the latest config.`, "INVALID_CONFIG_FILE"); } /** Reapply setup's changes while rejecting concurrent edits to the same field. */ export function rebaseSetupChanges(original, desired, latest, pathParts = []) { if (sameConfigValue(original, desired)) return latest; if (Array.isArray(original) && Array.isArray(desired)) { if (latest !== undefined && !Array.isArray(latest)) setupConflict(pathParts); const latestItems = Array.isArray(latest) ? latest : []; const originalByKey = new Map(original.map((item) => [configArrayItemKey(item), item])); const desiredByKey = new Map(desired.map((item) => [configArrayItemKey(item), item])); const latestByKey = new Map(latestItems.map((item) => [configArrayItemKey(item), item])); const result = []; for (const [key, desiredItem] of desiredByKey) { const originalItem = originalByKey.get(key); const latestItem = latestByKey.get(key); if (originalByKey.has(key)) { if (!latestByKey.has(key)) setupConflict([...pathParts, key]); result.push(rebaseSetupChanges(originalItem, desiredItem, latestItem, [...pathParts, key])); } else if (!latestByKey.has(key) || sameConfigValue(latestItem, desiredItem)) { result.push(desiredItem); } else { setupConflict([...pathParts, key]); } } for (const [key, originalItem] of originalByKey) { if (desiredByKey.has(key) || !latestByKey.has(key)) continue; if (!sameConfigValue(latestByKey.get(key), originalItem)) setupConflict([...pathParts, key]); } for (const item of latestItems) { const key = configArrayItemKey(item); if (!originalByKey.has(key) && !desiredByKey.has(key)) result.push(item); } return result; } if (!isPlainObject(original) || !isPlainObject(desired)) { if (!sameConfigValue(latest, original) && !sameConfigValue(latest, desired)) setupConflict(pathParts); return desired; } if (latest !== undefined && !isPlainObject(latest)) setupConflict(pathParts); const result = isPlainObject(latest) ? { ...latest } : {}; for (const key of new Set([...Object.keys(original), ...Object.keys(desired)])) { if (!Object.hasOwn(desired, key)) { if (Object.hasOwn(result, key) && !sameConfigValue(result[key], original[key])) setupConflict([...pathParts, key]); delete result[key]; continue; } result[key] = rebaseSetupChanges(original[key], desired[key], result[key], [...pathParts, key]); } return result; } async function saveSetupConfig(original, desired, precommit, options) { const result = await mutateConfigWithPrecommit((latest) => rebaseSetupChanges(original, desired, latest), precommit, options); return { config: result.config, precommit: result.precommit }; } /** Registry-managed bundles are not editable through setup's source picker. */ function managedBundleIds() { return new Set(readLockfile().map((entry) => entry.id)); } function setupSourceDescriptor(source) { switch (source.type) { case "filesystem": return source.path ? { path: source.path } : undefined; case "git": return source.url ? { git: source.url } : undefined; case "website": return source.url ? { website: { url: source.url, ...(source.options ?? {}) } } : undefined; case "npm": return source.path ? { npm: source.path } : undefined; default: return undefined; } } function selectsExistingBundle(source, id, bundle) { const configured = bundleEntryToSourceEntry(id, bundle); const sameUrl = configured?.type === "git" && source.type === "git" && configured.url && source.url ? canonicalSetupGitUrl(configured.url) === canonicalSetupGitUrl(source.url) : configured?.url === source.url; return configured !== undefined && configured.type === source.type && configured.path === source.path && sameUrl; } /** Convert only the wizard's private drafts, leaving current bundle entries intact. */ function finalizeSetupDraft(draft) { const { primaryPath, additionalSources, ...canonical } = draft; const finalized = canonical; if (primaryPath === undefined && additionalSources === undefined) return finalized; const bundles = { ...(draft.bundles ?? {}) }; const managedIds = managedBundleIds(); const newBundles = []; if (additionalSources !== undefined) { const selectedExistingIds = new Set(); for (const source of additionalSources) { const existing = source.name ? bundles[source.name] : undefined; if (source.name && source.name !== draft.defaultBundle && existing && selectsExistingBundle(source, source.name, existing)) { selectedExistingIds.add(source.name); continue; } const descriptor = setupSourceDescriptor(source); if (!descriptor) continue; newBundles.push({ descriptor, derivationPath: source.path ? path.resolve(source.path) : (source.url ?? ""), preferredId: source.name, primary: false, writable: source.writable, }); } for (const id of Object.keys(bundles)) { if (id === draft.defaultBundle || managedIds.has(id) || selectedExistingIds.has(id)) continue; delete bundles[id]; } } let defaultBundle = draft.defaultBundle; const configuredPrimaryPath = primaryBundlePath(draft); if (primaryPath !== undefined && (configuredPrimaryPath === undefined || path.resolve(configuredPrimaryPath) !== path.resolve(primaryPath))) { if (defaultBundle && !managedIds.has(defaultBundle)) delete bundles[defaultBundle]; defaultBundle = undefined; newBundles.unshift({ descriptor: { path: primaryPath }, derivationPath: path.resolve(primaryPath), primary: true, writable: true, }); } const reserved = Object.keys(bundles).map((id) => ({ path: id, registryId: id })); const ids = deriveBundleIds([ ...reserved, ...newBundles.map((bundle) => ({ path: bundle.derivationPath, registryId: bundle.preferredId })), ]).slice(reserved.length); for (const [index, bundle] of newBundles.entries()) { const id = ids[index]; bundles[id] = { ...bundle.descriptor, ...(bundle.writable !== undefined ? { writable: bundle.writable } : {}), }; if (bundle.primary) defaultBundle = id; } if (Object.keys(bundles).length > 0) finalized.bundles = bundles; else delete finalized.bundles; if (defaultBundle !== undefined) finalized.defaultBundle = defaultBundle; else delete finalized.defaultBundle; return finalized; } /** * Quick connectivity check. Returns true if we can resolve a hostname * the user has already implicitly trusted within 3 seconds, false * otherwise. Used to skip network-dependent setup steps gracefully * when offline. * * We use a DNS lookup against `github.com` rather than an HTTP request * because (1) it doesn't actually send a request to anyone we aren't * already talking to (the user got akm from GitHub and `akm upgrade` * polls api.github.com), and (2) DNS is the right layer for "do we have * working network" without making the user opt into yet another remote. * The previous implementation pinged https://dns.google which * contradicted the spirit of "no remote endpoints akm doesn't own." * * @internal Exported for testing only. */ export async function isOnline() { try { await Promise.race([ dnsPromises.lookup("github.com"), new Promise((_, reject) => setTimeout(() => reject(new Error("dns lookup timed out")), 3000).unref()), ]); return true; } catch { return false; } } // ── Main Wizard ───────────────────────────────────────────────────────────── /** * Build the canonical list of `SetupStep`s for the interactive wizard. * Exposed (and exported) so tests and `akm init` can compose subsets. * * Each step wraps the existing `step*` functions, accumulating its result * into the shared `SetupContext`. The `nonInteractive` flag controls * inclusion in `akm init` (a non-interactive preset of `akm setup`). */ export function buildSetupSteps(options) { const outcome = { semantic: options.semanticSearchOutcome }; // Local cache of Ollama-detected fields surfaced from the embedding step // to the LLM step. Mutable by design — `stepLlm` needs them. let ollamaEndpoint; let ollamaChatModels; let lmStudioResult; // Harness configs detected once and shared with the LLM step. Reuse the // aggregate detection's harness configs when available so we detect once. const harnessConfigs = options.detection?.harnessConfigs ?? detectHarnessConfigs(); const steps = [ { id: "stash-dir", label: "Bundle Directory", nonInteractive: true, async run(ctx) { const stashDir = await stepStashDir(ctx.config, { nonInteractive: ctx.nonInteractive, preferredDir: options.preferredStashDir, }); ctx.apply({ primaryPath: stashDir }); }, }, { id: "embedding", label: "Embedding", async run(ctx) { if (!options.online) { ctx.apply({ embedding: ctx.config.embedding }); return; } const [result, lmStudio] = await Promise.all([stepOllama(ctx.config), detectLMStudio()]); ollamaEndpoint = result.ollamaEndpoint; ollamaChatModels = result.ollamaChatModels; lmStudioResult = lmStudio; ctx.apply({ embedding: result.embedding }); }, }, { id: "llm", label: "LLM Provider", async run(ctx) { if (!options.online) { return; } const llm = await stepLlm(ctx.config, ollamaEndpoint, ollamaChatModels, lmStudioResult, harnessConfigs); ctx.apply(writeLlmEngine(ctx.config, llm)); }, }, { id: "semantic-search", label: "Semantic Search", async run(ctx) { const semantic = await stepSemanticSearch(ctx.config, ctx.config.embedding); outcome.semantic = semantic; ctx.apply({ semanticSearchMode: semantic.mode }); }, }, { id: "registries", label: "Registries", async run(ctx) { const registries = await stepRegistries(ctx.config); ctx.apply({ registries }); }, }, { id: "stash-sources", label: "Bundle Sources", async run(ctx) { const stashes = await stepAddSources(ctx.config, { promptForAdditional: false }); const platforms = await stepAgentPlatforms({ ...ctx.config, additionalSources: stashes }); const merged = [...stashes]; for (const ps of platforms) { if (!merged.some((s) => s.path === ps.path)) merged.push(ps); } const withAdditional = await stepAdditionalSources(merged); ctx.apply({ additionalSources: withAdditional }); }, }, { id: "agent-cli", label: "Agent CLI", async run(ctx) { const result = stepAgentCliDetection(ctx.config); const detected = result.detections.filter((d) => d.available); if (detected.length > 0) { p.log.info(`Detected agent CLIs: ${detected.map((d) => d.name).join(", ")}.` + (result.agent?.default ? ` Default profile: ${result.agent.default}.` : "")); } else { p.log.info("No agent CLIs detected on PATH. Agent commands will be disabled until one is installed and `akm setup` is re-run."); } // Apply detected engines to a synthetic config for the selection UI. const synthConfig = deepMergeConfig(ctx.config, writeAgentEngines(ctx.config, result.agent)); const agent = await stepAgentSelection(synthConfig, result.detections); ctx.apply(writeAgentEngines(ctx.config, agent)); }, }, { id: "output", label: "Output Defaults", async run(ctx) { const output = await stepOutputConfig(ctx.config); ctx.apply({ output }); }, }, // NOTE: the "Scheduled Tasks" step is deliberately NOT part of this list. // It is the only wizard step with externally-visible side effects (task // files + OS scheduler entries), so `runSetupWizard` runs it by hand AFTER // the config is confirmed and persisted — see the call there. Keeping it // out of the step list also preserves the issue #512 guard: the // non-interactive entry points (`akm init` / `--yes`) run this list but // never that step, so headless runs never enable a scheduled task. ]; return { steps, outcome }; } /** * Resolve the stash directory, apply stash isolation, and THEN read the config. * * Order is the whole point. The pre-isolation read exists only to discover * where the stash is; `applyStashIsolationToEnv` can repoint AKM_CONFIG_DIR, * after which both the config contents and `getConfigPath()` differ. Reading * the merge base before isolation meant an isolated run merged the wizard's * answers onto the HOST config and reported the host path as the save target * while writing somewhere else. */ function resolveIsolatedSetupConfig(opts) { const preIsolationConfig = loadUserConfig(); // Resolve stash directory early so akmInit can run before any prompts. const resolvedStashDir = opts?.dir ? path.resolve(opts.dir) : (primaryBundlePath(preIsolationConfig) ?? getDefaultStashDir()); // Refuse explicit --dir /tmp/... before doing any work — protects the host // config from being clobbered with a stashDir that the OS may reap. assertSetupSandbox(resolvedStashDir, opts?.dir != null); applyStashIsolationToEnv(resolvedStashDir, opts?.dir != null); return { current: loadUserConfig(), configPath: getConfigPath(), resolvedStashDir }; } export async function runSetupWizard(opts) { assertSetupConfigPreflight(); p.intro("akm setup"); const { current, configPath, resolvedStashDir } = resolveIsolatedSetupConfig(opts); // Quick connectivity check — skip network-dependent steps when offline const online = await isOnline(); if (!online) { p.log.warn("No network connectivity detected. Skipping Ollama detection and remote embedding checks.\n" + "Local-only setup will continue. Re-run `akm setup` when online for full configuration."); } // Aggregate environment detection — run once before any prompt and surface // a summary so the user sees what was auto-detected. NAMES only, never // API key values. const detection = await detectEnvironment({ existingStashDir: primaryBundlePath(current) }); p.note(renderDetectionSummary(detection), "Detected environment"); // Interactive entry point for `runResetRecommended`: offer to apply the // opinionated, detection-derived defaults and skip the step-by-step wizard. const useRecommended = await prompt(() => p.confirm({ message: "Apply recommended defaults from the detected environment (merged into your existing config)?", initialValue: false, })); if (useRecommended) { const result = await runResetRecommended({ dir: opts?.dir, noInit: opts?.noInit }); p.outro(`Recommended configuration saved to ${result.configPath}`); return; } const ctx = createSetupContext(current, { nonInteractive: false }); const { steps, outcome } = buildSetupSteps({ online, semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false }, preferredStashDir: resolvedStashDir, detection, }); // Wrap each step with a `p.log.step()` header so the wizard UI is // unchanged. The canonical `runSetupSteps()` runner is used directly by // `akm init` (non-interactive) and by tests. const labeledSteps = steps.map((step) => ({ ...step, async run(stepCtx) { p.log.step(step.label); await step.run(stepCtx); }, })); await runSetupSteps(labeledSteps, ctx); // ── Two-step connection configuration ────────────────────────────────────── // Step 1/2: Small model connection (for enrichment features) const smallModelResult = await stepSmallModelConnection(ctx.config); if (!smallModelResult.skipped) { ctx.apply(writeLlmEngine(ctx.config, smallModelResult.llm)); } // Step 2/2: Agent connection (for agentic features) const agentConfig = await stepAgentConnection(ctx.config, smallModelResult); ctx.apply(writeAgentEngines(ctx.config, agentConfig)); const newConfig = { ...ctx.config }; const semanticSearchMode = outcome.semantic; const stashDir = newConfig.primaryPath ?? primaryBundlePath(current) ?? getDefaultStashDir(); const embedding = newConfig.embedding; const llm = readCurrentLlmEngine(newConfig); const registries = newConfig.registries; const allStashes = newConfig.additionalSources ?? []; // Feature capability summary const agentConfigured = Boolean(agentConfig && !agentConfig.disabled); printCapabilitySummary(smallModelResult.skipped, agentConfigured); // Confirm before saving const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? []; p.note([ `Bundle directory: ${stashDir}`, `Embedding: ${embedding ? `${embedding.provider ?? "remote"} / ${embedding.model}` : "built-in local"}`, `LLM: ${llm ? `${llm.provider ?? "remote"} / ${llm.model}` : "disabled"}`, `Semantic search: ${semanticSearchMode.mode}`, `Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`, `Bundle sources: ${allStashes.length}`, `Agent default: ${newConfig.defaults?.engine ?? "disabled"}`, `Output: ${newConfig.output?.format ?? "json"} / ${newConfig.output?.detail ?? "brief"}`, ].join("\n"), "Configuration Summary"); const shouldSave = await prompt(() => p.confirm({ message: "Save this configuration?", initialValue: true, })); if (!shouldSave) bail(); const finalConfig = finalizeSetupDraft(newConfig); validateCompleteConfig(finalConfig); const { config: savedConfig } = await saveSetupConfig(current, finalConfig, async () => { if (!opts?.noInit) await akmInit({ dir: stashDir, setDefault: true, persistConfig: false }); }, { persistTopLevelKeys: ["semanticSearchMode"] }); // After config persistence, the task step reviews the plan and asks one // explicit confirmation before changing task files or scheduler state. // Non-interactive setup paths never reach this interactive-only step. p.log.step("Scheduled Tasks"); await stepScheduledTasks(); if (semanticSearchMode.mode === "auto") { if (semanticSearchMode.prepareAssets) { const ready = await prepareSemanticSearchAssets(savedConfig); if (!ready.ok) { p.log.warn("Semantic search remains set to auto, but is currently blocked. Re-run `akm index --full --verbose` once the issue is resolved."); } } else { p.log.info("Semantic search is set to auto, but asset preparation was skipped. Run `akm index --full --verbose` later to verify it."); } } // Build search index p.log.info("Building search index..."); const spin = p.spinner(); spin.start("Building search index..."); try { const indexResult = await akmIndex({ stashDir }); spin.stop(`Indexed ${indexResult.totalEntries} assets.`); if (savedConfig.semanticSearchMode === "auto") { if (indexResult.verification.ok) { p.log.success(indexResult.verification.message); } else { p.log.warn(indexResult.verification.message); if (indexResult.verification.guidance) { p.log.info(indexResult.verification.guidance); } } } } catch (err) { spin.stop("Indexing failed — you can run `akm index` manually later."); p.log.warn(String(err)); } // API key reminder if (embedding?.apiKey === undefined && embedding?.provider !== "ollama") { // Only remind about API keys for non-Ollama remote providers if (embedding?.endpoint && !embedding.endpoint.includes("localhost")) { p.log.info("Reminder: Set your embedding API key via the AKM_EMBED_API_KEY environment variable."); } } if (llm?.apiKey === undefined && llm?.provider !== "ollama") { if (llm?.endpoint && !llm.endpoint.includes("localhost")) { p.log.info("Reminder: Set your LLM API key via the AKM_LLM_API_KEY environment variable."); } } p.outro(`Configuration saved to ${configPath}\n` + 'Next: `akm bundle add <source>`, `akm index`, `akm search "<query>"`, `akm help agents`'); } // ── Non-interactive / scripting entry points ───────────────────────────────── /** * Run setup in non-interactive mode, applying all defaults. * Safe to call from CI or scripts. Idempotent — re-running produces the same result. */ export async function runSetupWithDefaults(opts) { assertSetupConfigPreflight(); const explicitStashDir = opts.dir != null ? path.resolve(opts.dir) : undefined; // R-066 #4: a second `assertSetupSandbox(stashDir, explicitStashDir != null)` // + `applyStashIsolationToEnv(stashDir, explicitStashDir != null)` pair used // to follow `stashDir`'s resolution below. It was strictly redundant: unlike // `runSetupFromConfig` (which can also derive an explicit stash dir from an // incoming config file's bundle path, a case the early block below can't // see yet), `runSetupWithDefaults` has only one source of an explicit dir — // `opts.dir` — so `stashDir` always equals `explicitStashDir` whenever the // `if` below ran, and always fails `dirExplicitlyProvided` (an immediate // no-op in both helpers) whenever it didn't. Removed rather than duplicated. if (explicitStashDir) { assertSetupSandbox(explicitStashDir, true); applyStashIsolationToEnv(explicitStashDir, true); } const current = loadUserConfig(); const stashDir = explicitStashDir ?? primaryBundlePath(current) ?? getDefaultStashDir(); // Run steps in non-interactive mode (applies defaults, skips prompts) const ctx = createSetupContext(current, { nonInteractive: true }); const { steps } = buildSetupSteps({ online: false, semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false }, preferredStashDir: stashDir, }); await runSetupSteps(steps, ctx); if (!ctx.config.primaryPath) ctx.apply({ primaryPath: stashDir }); // Aggregate environment detection — apply detected values directly. const env = await detectEnvironment({ existingStashDir: ctx.config.primaryPath }); // Apply a detected LLM (live local server) when the config has none yet. if (!readCurrentLlmEngine(ctx.config) && opts.probe) { const liveLocal = env.localServers.find((s) => s.available && s.defaultModel); if (liveLocal?.defaultModel) { const llm = { provider: "local", endpoint: `${liveLocal.baseUrl.replace(/\/$/, "")}/v1/chat/completions`, model: liveLocal.defaultModel, }; // A required field being unresolvable must fail loudly rather than write // a broken config (--yes acceptance criterion). if (!llm.endpoint?.trim() || !llm.model?.trim()) { throw new UsageError("Detected a local LLM server but could not resolve a required field (endpoint/model). Re-run `akm setup` interactively.", "MISSING_REQUIRED_ARGUMENT"); } const verified = await verifyOpenAiCompatibleEndpoint(llm); if (verified.ok) { const applied = upsertDetectedLlmEngine(ctx.config, { provider: llm.provider ?? "local", endpoint: verified.endpoint, model: llm.model, }); ctx.apply(applied.config); } else { warn(`[akm setup] Skipping detected local LLM: ${verified.reason}. Verify it and rerun setup.`); } } } // Auto-detect agent CLI if not already configured if (!ctx.config.defaults?.engine) { let defaultProfile; if (env.harness !== "none") { defaultProfile = env.harness; } else { const detected = detectAgentCliProfiles(undefined); defaultProfile = pickDefaultAgentProfile(detected, undefined); } if (defaultProfile) { ctx.apply(upsertDetectedAgentEngine(ctx.config, defaultProfile).config); } } const finalConfig = finalizeSetupDraft(ctx.config); validateCompleteConfig(finalConfig); const { precommit: initResult } = await saveSetupConfig(current, finalConfig, async () => { if (opts.noInit) return undefined; return akmInit({ dir: stashDir, setDefault: true, persistConfig: false }); }); return { configPath: getConfigPath(), bundleDir: stashDir, stashCreated: initResult?.created ?? false, written: true, fields: Object.keys(finalConfig).filter((k) => finalConfig[k] !== undefined), }; } /** * Run ONLY environment detection and return the typed result. Performs no * config writes and shows no prompts. * * SAFETY: The returned object carries env var NAMES only — never any API key * value. */ export async function runDetectOnly() { return detectEnvironment(); } /** * Derive opinionated defaults from a detection result. * * - Best harness → agent default (when a profile maps to it). * - Fastest live local model, else the first detected cloud key's provider. * - `nomic-embed-text` embeddings when a local LLM is live. * * Returns a partial `AkmConfig`-shaped object plus an LLM connection block, ready * to merge. Never includes an API key value. */ export function deriveRecommendedConfig(env) { const result = {}; // Best harness → agent default. #566: derive the default profile name from // the harness registry instead of a hardcoded if-chain, so a newly added // dispatch-capable harness gets a usable headless default (its canonical id) // automatically. "none" / unknown ids resolve to undefined (no default). const agentDefault = defaultProfileName(env.harness); if (agentDefault) result.agentDefault = agentDefault; // LLM: prefer a live local server, else a detected cloud provider key. const liveLocal = env.localServers.find((s) => s.available && s.defaultModel); if (liveLocal?.defaultModel) { result.llm = { provider: "local", endpoint: `${liveLocal.baseUrl.replace(/\/$/, "")}/v1`, model: liveLocal.defaultModel, }; // Local LLM live → use a local embedding model. result.embedding = { provider: "ollama", model: "nomic-embed-text", endpoint: `${liveLocal.baseUrl}/v1` }; } else { // Map a detected cloud API-key provider to an llm endpoint. NAMES only — // the value lives in the env var the user already set; we never read it. const cloud = env.providers.find((pr) => pr.kind === "apiKey"); if (cloud) { const defaults = PROVIDER_DEFAULTS[cloud.provider]; if (defaults) { result.llm = { provider: cloud.provider, endpoint: defaults.endpoint, model: defaults.model }; result.llmApiKeyEnvVar = cloud.envVar; } } } return result; } /** * Merge opinionated, detection-derived defaults into the existing config * WITHOUT removing pre-existing custom keys. Backs the interactive wizard's * "apply recommended defaults" prompt. Uses the same merge path as * {@link runSetupFromConfig} so custom keys survive (follows #511 semantics). */ export async function runResetRecommended(opts) { assertSetupConfigPreflight(); const explicitStashDir = opts.dir != null ? path.resolve(opts.dir) : undefined; if (explicitStashDir) { assertSetupSandbox(explicitStashDir, true); applyStashIsolationToEnv(explicitStashDir, true); } const current = loadUserConfig(); const env = await detectEnvironment({ existingStashDir: primaryBundlePath(current) }); const recommended = deriveRecommendedConfig(env); let incoming = {}; if (recommended.llm && recommended.llm.provider !== "anthropic") { let endpoint = recommended.llm.endpoint; let accepted = true; if (opts.probe) { const verified = await verifyOpenAiCompatibleEndpoint({ endpoint, model: recommended.llm.model, apiKeyEnvVar: recommended.llmApiKeyEnvVar, }); if (verified.ok) { endpoint = verified.endpoint; } else { accepted = false; warn(`[akm setup] Skipping detected LLM: ${verified.reason}. Verify it and rerun setup.`); } } if (accepted) { incoming = upsertDetectedLlmEngine(incoming, { provider: recommended.llm.provider ?? "local", endpoint, model: recommended.llm.model, apiKeyEnvVar: recommended.llmApiKeyEnvVar, }).config; } } if (recommended.embedding) incoming.embedding = recommended.embedding; if (recommended.agentDefault) { incoming = deepMergeConfig(incoming, writeAgentEngines(incoming, { default: recommended.agentDefault })); } return runSetupFromConfig({ configJson: JSON.stringify(incoming), dir: opts.dir, noInit: opts.noInit, probe: opts.probe, }); } /** * Apply a JSON config blob non-interactively, merging it with the current config. * Validates required sub-fields and strips unknown/restricted keys. */ export async function runSetupFromConfig(opts) { assertSetupConfigPreflight(); // Phase 1: Parse JSON let parsed; try { parsed = JSON.parse(opts.configJson); } catch (e) { throw new UsageError(`Invalid JSON in --config: ${e.message}`, "INVALID_FLAG_VALUE"); } if (!isPlainObject(parsed)) { throw new ConfigError("Setup config must contain a top-level object.", "INVALID_CONFIG_FILE"); } const incoming = parsed; // Phase 2: Validate — only allow safe top-level keys for (const key of ["stashDir", "sources", "installed"]) { if (key in incoming) { throw new ConfigError(`${key} is not supported by the current config schema; configure bundles/defaultBundle instead.`, "INVALID_CONFIG_FILE"); } } // Derived from AkmConfigShape (via `listTopLevelConfigKeys`) rather than a // hand-copied set: a hand-copied allowlist silently fell out of sync as the // schema grew (R-017 — `index`, `search`, `feedback`, `archiveRetentionDays`, // `workflow`, and `experimental` were all valid schema keys that this list // forgot, each dropped with only a warning while the command exited 0). // Every key the schema recognizes is a legitimate thing to set via // `--config`/`--from`; there is no key a user can set here that they // couldn't set by hand-editing config.json and letting `akm config set` // validate it. Keys that remain genuinely retired (`profiles`, `llm`, // `agent`, `features`, `stashes`, `bindings`, `writable`) are not part of // the schema shape, so they still fall into the warn-and-drop branch below; // `stashDir`/`sources`/`installed` get the more specific migration error above. const ALLOWED_KEYS = new Set(listTopLevelConfigKeys()); for (const key of Object.keys(incoming)) { if (!ALLOWED_KEYS.has(key)) { warn(`[akm setup] Ignoring unknown or restricted config key: "${key}"`); delete incoming[key]; } } // Validate required sub-fields if (incoming.embedding) { if (!incoming.embedding.endpoint?.trim()) throw new Error("embedding.endpoint is required when embedding is provided"); if (!incoming.embedding.model?.trim()) throw new Error("embedding.model is required when embedding is provided"); } // Phase 3: Merge with existing config // // R-066 #4 (verified, NOT reduced — unlike the `runSetupWithDefaults` pair // above, this one is NOT strictly redundant): the `assertSetupSandbox` + // `applyStashIsolationToEnv` pair below looks like a duplicate of the pair // here, but each guards a case the other cannot: // - THIS early pair must run before `loadUserConfig()` so an explicit // `--dir` pointed at a force-escaped transient sandbox isolates the // config READ too, not just the later write (the isolation env var has // to be set before `loadUserConfig()` executes, or `current` below is // read from the host config instead of the sandboxed one — the exact // failure class the "2026-05-23 setup-clobbers-user-config incident" // comment on `assertSetupSandbox` describes). // - The LATER pair below additionally covers a case this one can't see // yet: an incoming `--config`/`--file` JSON blob that names its own // bundle path (`incomingPrimaryPath`) with no `--dir` at all — that // path is only known after the merge, so it cannot be checked early. // When `explicitStashDir` is set both pairs do end up asserting the same // (stashDir, true), but removing either one changes behavior in the case // it uniquely covers, so both stay. const explicitStashDir = opts.dir != null ? path.resolve(opts.dir) : undefined; if (explicitStashDir) { assertSetupSandbox(explicitStashDir, true); applyStashIsolationToEnv(explicitStashDir, true); } const current = loadUserConfig(); let merged = deepMergeConfig(current, incoming); const incomingPrimaryPath = primaryBundlePath(incoming); const configuredPrimaryPath = primaryBundlePath(merged); const stashDir = explicitStashDir ?? configuredPrimaryPath ?? getDefaultStashDir(); const stashDirExplicit = explicitStashDir != null || incomingPrimaryPath !== undefined; assertSetupSandbox(stashDir, stashDirExplicit); applyStashIsolationToEnv(stashDir, stashDirExplicit); if (explicitStashDir !== undefined || configuredPrimaryPath === undefined) { merged = deepMergeConfig(merged, { primaryPath: stashDir }); } // Deep-merge canonical keys: nested objects merge key-by-key so a // partial `--file` only updates the keys it carries and never drops sibling // subkeys (e.g. output.detail survives an output.format-only file). Arrays // and scalars replace wholesale. // With `--yes`, fill keys still missing after the merge with non-interactive // defaults. Steps start from `merged` and their nonInteractive path only // populates absent values, so nothing the file or existing config supplied // is overwritten. if (opts.applyDefaults) { const ctx = createSetupContext(merged, { nonInteractive: true }); const { steps } = buildSetupSteps({ online: false, semanticSearchOutcome: { mode: merged.semanticSearchMode, prepareAssets: false }, preferredStashDir: stashDir, }); await runSetupSteps(steps, ctx); if (!ctx.config.primaryPath) ctx.apply({ primaryPath: stashDir }); if (!ctx.config.defaults?.engine) { const detected = detectAgentCliProfiles(undefined); const defaultProfile = pickDefaultAgentProfile(detected, undefined); if (defaultProfile) { ctx.apply(upsertDetectedAgentEngine(ctx.config, defaultProfile).config); } } merged = ctx.config; } // Convert the private setup draft to the persisted bundle shape. const finalizedMerged = finalizeSetupDraft(merged); // Reject an invalid merged engine graph before probing or touching the stash. validateCompleteConfig(finalizedMerged); // Optional connectivity probe — informational only, never blocks or // mutates config. `chatCompletion` attempts structured output fresh on // every call that needs it (see `llm/client.ts`), so there is nothing to // probe or persist here beyond "did the endpoint answer". const mergedLlm = readCurrentLlmEngine(finalizedMerged); if (opts.probe && mergedLlm) { try { const reach = await probeLlmReachable(mergedLlm); if (!reach.reachable) { warn(`[akm setup] LLM endpoint not reachable${reach.error ? `: ${reach.error}` : ""}.`); } } catch { // Non-fatal: probe failure is informational only } } validateCompleteConfig(finalizedMerged); const { precommit: initResult } = await saveSetupConfig(current, finalizedMerged, async () => { if (opts.noInit) return undefined; return akmInit({ dir: stashDir, setDefault: true, persistConfig: false }); }); return { configPath: getConfigPath(), bundleDir: stashDir, stashCreated: initResult?.created ?? false, written: true, fields: Object.keys(incoming).filter((k) => incoming[k] !== undefined), }; } // ── Setup --from <file> bootstrap helper ──────────────────────────────────── /** * Resolve a `--from <file>` argument to a JSON-encoded config payload suitable * for `runSetupFromConfig({ configJson })`. Used by the CLI to bootstrap from * a JSON or YAML file on disk; extracted as a standalone function so its * filesystem and parser behaviour can be unit-tested directly. * * - Expands a leading `~` to the current user's home directory. * - Resolves the path against `cwd ?? process.cwd()` for relative inputs. * - Detects YAML vs JSON via the file extension (`.yml`/`.yaml` → YAML; * anything else, including `.json`, parses as JSON). * - Throws `ConfigError("INVALID_CONFIG_FILE")` when the file does not exist, * cannot be read, cannot be parsed, or contains a non-object top level. * * Returns `{ configJson, resolvedPath, format }` so callers can log which * file was actually loaded and which parser was used. */ export async function loadSetupConfigFromFile(filePath, opts) { const cwd = opts?.cwd ?? process.cwd(); const homeDir = opts?.homeDir ?? os.homedir(); const expanded = filePath.startsWith("~") ? path.join(homeDir, filePath.slice(1)) : filePath; const resolvedPath = path.resolve(cwd, expanded); if (!fs.existsSync(resolvedPath)) { throw new ConfigError(`Config file not found: ${resolvedPath}`, "INVALID_CONFIG_FILE"); } let raw; try { raw = fs.readFileSync(resolvedPath, "utf8"); } catch (err) { throw new ConfigError(`Failed to read config file ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE"); } const ext = path.extname(resolvedPath).toLowerCase(); const format = ext === ".yml" || ext === ".yaml" ? "yaml" : "json"; let parsed; try { if (format === "yaml") { const { parse: yamlParse } = await import("yaml"); parsed = yamlParse(raw); } else { parsed = JSON.parse(raw); } } catch (err) { throw new ConfigError(`Failed to parse ${format.toUpperCase()} config file ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE"); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new ConfigError(`Config file ${resolvedPath} must contain a top-level object, got ${Array.isArray(parsed) ? "array" : typeof parsed}.`, "INVALID_CONFIG_FILE"); } return { configJson: JSON.stringify(parsed), resolvedPath, format }; }