akm-cli 0.7.4 → 0.8.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/CHANGELOG.md +224 -1
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2631 -1440
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  16. package/dist/commands/curate.js +45 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +660 -0
  19. package/dist/commands/distill.js +1081 -73
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +15 -24
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +3 -0
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +120 -45
  62. package/dist/commands/reflect.js +1104 -60
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +70 -7
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +158 -60
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -968
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +105 -135
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +198 -489
  113. package/dist/indexer/db.js +990 -108
  114. package/dist/indexer/ensure-index.js +136 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -114
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +547 -309
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +250 -36
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +183 -35
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +79 -88
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -72
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +80 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -311
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +306 -258
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -511
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1093
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +179 -20
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +141 -2
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +91 -89
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +79 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.4.md +1 -1
  294. package/docs/migration/release-notes/0.7.5.md +20 -0
  295. package/docs/migration/release-notes/0.8.0.md +48 -0
  296. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  297. package/package.json +29 -11
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -333
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Interactive configuration wizard for akm.
3
6
  *
@@ -5,22 +8,147 @@
5
8
  * registry selection, stash sources, and agent platform discovery.
6
9
  * Collects all choices and writes config once at the end.
7
10
  */
11
+ import { promises as dnsPromises } from "node:dns";
8
12
  import fs from "node:fs";
9
13
  import os from "node:os";
10
14
  import path from "node:path";
11
15
  import * as p from "@clack/prompts";
12
16
  import { akmInit } from "../commands/init";
13
17
  import { isHttpUrl } from "../core/common";
14
- import { DEFAULT_CONFIG, getConfigPath, loadUserConfig, saveConfig } from "../core/config";
15
- import { getDefaultStashDir } from "../core/paths";
18
+ import { DEFAULT_CONFIG, getDefaultLlmConfig, loadUserConfig, saveConfig } from "../core/config";
19
+ import { ConfigError } from "../core/errors";
20
+ import { assertSafeStashDir, getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths";
21
+ import { warn } from "../core/warn";
16
22
  import { closeDatabase, isVecAvailable, openDatabase } from "../indexer/db";
17
23
  import { akmIndex } from "../indexer/indexer";
18
24
  import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/semantic-status";
19
- import { detectAgentCliProfiles, pickDefaultAgentProfile, } from "../integrations/agent";
25
+ import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent";
20
26
  import { probeLlmCapabilities } from "../llm/client";
21
27
  import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "../llm/embedder";
22
28
  import { detectAgentPlatforms, detectOllama } from "./detect";
23
29
  import { createSetupContext, runSetupSteps } from "./steps";
30
+ // ── Setup sandbox guard ─────────────────────────────────────────────────────
31
+ /**
32
+ * Refuse to persist an explicit `--dir /tmp/...` stashDir to the user's
33
+ * config. The OS may reap the directory at any time, and the next run will
34
+ * see a `stashDir` that points at a deleted path (falling back to ~/akm
35
+ * silently). Mirrors the `assertInitSandbox` check in commands/init.ts, but
36
+ * fires under all runtimes (not just `bun test`) because `akm setup --dir
37
+ * /tmp/X` is a documented isolation pattern that has been observed to
38
+ * silently clobber the host config — see
39
+ * `docs/technical/incidents/2026-05-23-setup-clobbers-user-config.md`.
40
+ *
41
+ * Escape hatch: set `AKM_FORCE_SETUP_TMP_STASH=1` to override. When the
42
+ * escape hatch is on, `applyStashIsolationToEnv` below also pre-sets
43
+ * `AKM_STASH_DIR` so that the `getConfigDir` / `getCacheDir` isolation
44
+ * rules fire and config + cache writes route into `$stashDir/.akm/`
45
+ * instead of the user's host `~/.config/akm`.
46
+ */
47
+ function assertSetupSandbox(stashDir, dirExplicitlyProvided) {
48
+ if (!dirExplicitlyProvided)
49
+ return;
50
+ if (process.env.AKM_FORCE_SETUP_TMP_STASH === "1")
51
+ return;
52
+ if (!isTransientStashPath(stashDir))
53
+ return;
54
+ throw new ConfigError(`refusing to run \`akm setup --dir ${stashDir}\`: the path is in a transient/sandbox directory family the OS may reap. ` +
55
+ "Persisting it as the user's stashDir would leave the next run pointing at a deleted path (silently falling back to ~/akm). " +
56
+ "Use a persistent directory, OR set AKM_FORCE_SETUP_TMP_STASH=1 if you intentionally want a sandbox setup " +
57
+ "(setup will also auto-isolate config + cache writes into $stashDir/.akm/ so the host config is preserved).", "SETUP_TMP_STASH_REFUSED");
58
+ }
59
+ /**
60
+ * Propagate the explicit `--dir <stashDir>` choice to the env so that the
61
+ * `getConfigDir` / `getCacheDir` isolation rules in `src/core/paths.ts`
62
+ * actually fire for the duration of this setup run. Without this, a CLI
63
+ * caller who passes `--dir /tmp/X` but doesn't pre-export `AKM_STASH_DIR`
64
+ * would still write config to the host `~/.config/akm/config.json`. We
65
+ * only set the env var when:
66
+ * - `--dir` was explicitly provided (we have an operator-stated stash), AND
67
+ * - `AKM_STASH_DIR` is not already set (caller's explicit env wins).
68
+ * The set is process-wide; for the CLI that's the right scope (the process
69
+ * is about to do all its work against this stash). For tests, each test
70
+ * already isolates env via beforeEach/afterEach so there is no leak.
71
+ */
72
+ function applyStashIsolationToEnv(stashDir, dirExplicitlyProvided) {
73
+ if (!dirExplicitlyProvided)
74
+ return;
75
+ if (process.env.AKM_STASH_DIR?.trim())
76
+ return;
77
+ process.env.AKM_STASH_DIR = stashDir;
78
+ }
79
+ /** Read the currently-configured LLM connection from a loaded config. */
80
+ function getCurrentLlm(config) {
81
+ return getDefaultLlmConfig(config);
82
+ }
83
+ /** Read a synthesised legacy-shape agent block from the new-shape AkmConfig. */
84
+ function getCurrentAgentBlock(config) {
85
+ if (!config.profiles?.agent && !config.defaults?.agent)
86
+ return undefined;
87
+ const block = {};
88
+ if (config.defaults?.agent)
89
+ block.default = config.defaults.agent;
90
+ if (config.profiles?.agent) {
91
+ const profiles = {};
92
+ for (const [name, raw] of Object.entries(config.profiles.agent)) {
93
+ profiles[name] = {
94
+ ...(raw.platform === "opencode-sdk" ? { sdkMode: true } : {}),
95
+ ...(raw.model ? { model: raw.model } : {}),
96
+ ...(raw.bin ? { bin: raw.bin } : {}),
97
+ ...(raw.args ? { args: raw.args } : {}),
98
+ };
99
+ }
100
+ block.profiles = profiles;
101
+ }
102
+ return block;
103
+ }
104
+ /** Apply an LLM connection patch onto the new-shape config. */
105
+ function applyLegacyLlm(config, llm) {
106
+ if (!llm) {
107
+ // Clear the default LLM profile.
108
+ const name = config.defaults?.llm ?? "default";
109
+ const remaining = { ...(config.profiles?.llm ?? {}) };
110
+ delete remaining[name];
111
+ return {
112
+ profiles: { ...(config.profiles ?? {}), llm: remaining },
113
+ defaults: { ...(config.defaults ?? {}), llm: undefined },
114
+ };
115
+ }
116
+ const name = config.defaults?.llm ?? "default";
117
+ return {
118
+ profiles: {
119
+ ...(config.profiles ?? {}),
120
+ llm: { ...(config.profiles?.llm ?? {}), [name]: llm },
121
+ },
122
+ defaults: { ...(config.defaults ?? {}), llm: name },
123
+ };
124
+ }
125
+ /** Apply a legacy-shape agent block onto the new-shape config. */
126
+ function applyLegacyAgent(config, agent) {
127
+ if (!agent) {
128
+ return {
129
+ profiles: { ...(config.profiles ?? {}), agent: undefined },
130
+ defaults: { ...(config.defaults ?? {}), agent: undefined },
131
+ };
132
+ }
133
+ const v2Profiles = { ...(config.profiles?.agent ?? {}) };
134
+ for (const [name, profile] of Object.entries(agent.profiles ?? {})) {
135
+ const platform = profile.sdkMode
136
+ ? "opencode-sdk"
137
+ : name.toLowerCase().includes("claude")
138
+ ? "claude"
139
+ : "opencode";
140
+ v2Profiles[name] = {
141
+ platform,
142
+ ...(profile.bin ? { bin: profile.bin } : {}),
143
+ ...(profile.args ? { args: profile.args } : {}),
144
+ ...(profile.model ? { model: profile.model } : {}),
145
+ };
146
+ }
147
+ return {
148
+ profiles: { ...(config.profiles ?? {}), agent: v2Profiles },
149
+ defaults: { ...(config.defaults ?? {}), agent: agent.default },
150
+ };
151
+ }
24
152
  /**
25
153
  * Recommended GitHub repositories shown during setup.
26
154
  */
@@ -121,7 +249,6 @@ function cloneLlmConfig(llm) {
121
249
  return {
122
250
  ...llm,
123
251
  ...(llm.capabilities ? { capabilities: { ...llm.capabilities } } : {}),
124
- ...(llm.features ? { features: { ...llm.features } } : {}),
125
252
  ...(llm.extraParams ? { extraParams: { ...llm.extraParams } } : {}),
126
253
  };
127
254
  }
@@ -201,15 +328,27 @@ async function stepAdditionalSources(currentSources) {
201
328
  return sources;
202
329
  }
203
330
  /**
204
- * Quick connectivity check. Returns true if we can reach a public
205
- * endpoint within 3 seconds, false otherwise. Used to skip network-
206
- * dependent setup steps gracefully when offline.
331
+ * Quick connectivity check. Returns true if we can resolve a hostname
332
+ * the user has already implicitly trusted within 3 seconds, false
333
+ * otherwise. Used to skip network-dependent setup steps gracefully
334
+ * when offline.
335
+ *
336
+ * We use a DNS lookup against `github.com` rather than an HTTP request
337
+ * because (1) it doesn't actually send a request to anyone we aren't
338
+ * already talking to (the user got akm from GitHub and `akm upgrade`
339
+ * polls api.github.com), and (2) DNS is the right layer for "do we have
340
+ * working network" without making the user opt into yet another remote.
341
+ * The previous implementation pinged https://dns.google which
342
+ * contradicted the spirit of "no remote endpoints akm doesn't own."
207
343
  *
208
344
  * @internal Exported for testing only.
209
345
  */
210
346
  export async function isOnline() {
211
347
  try {
212
- await fetch("https://dns.google", { signal: AbortSignal.timeout(3000) });
348
+ await Promise.race([
349
+ dnsPromises.lookup("github.com"),
350
+ new Promise((_, reject) => setTimeout(() => reject(new Error("dns lookup timed out")), 3000).unref()),
351
+ ]);
213
352
  return true;
214
353
  }
215
354
  catch {
@@ -337,8 +476,11 @@ async function prepareSemanticSearchAssets(config) {
337
476
  return { ok: true };
338
477
  }
339
478
  // ── Steps ───────────────────────────────────────────────────────────────────
340
- async function stepStashDir(current) {
341
- const defaultDir = current.stashDir ?? getDefaultStashDir();
479
+ async function stepStashDir(current, options) {
480
+ const defaultDir = options?.preferredDir ?? current.stashDir ?? getDefaultStashDir();
481
+ if (options?.nonInteractive) {
482
+ return defaultDir;
483
+ }
342
484
  const choice = await prompt(() => p.select({
343
485
  message: "Where should akm store skills, commands, and other assets?",
344
486
  options: [
@@ -354,6 +496,14 @@ async function stepStashDir(current) {
354
496
  validate: (v) => {
355
497
  if (!v?.trim())
356
498
  return "Path cannot be empty";
499
+ try {
500
+ assertSafeStashDir(v.trim());
501
+ }
502
+ catch (err) {
503
+ if (err instanceof Error)
504
+ return err.message;
505
+ return "Refused: unsafe stash directory";
506
+ }
357
507
  },
358
508
  }));
359
509
  return customPath.trim();
@@ -488,21 +638,22 @@ export async function stepLlm(current, ollamaEndpoint, ollamaChatModels) {
488
638
  }
489
639
  options.push({ value: "custom", label: "Custom OpenAI-compatible endpoint" });
490
640
  options.push({ value: "none", label: "Skip LLM", hint: "no metadata enhancement during indexing" });
491
- if (current.llm) {
641
+ const currentLlm = getCurrentLlm(current);
642
+ if (currentLlm) {
492
643
  options.push({
493
644
  value: "keep",
494
- label: `Keep current: ${current.llm.provider ?? current.llm.endpoint}`,
495
- hint: current.llm.model,
645
+ label: `Keep current: ${currentLlm.provider ?? currentLlm.endpoint}`,
646
+ hint: currentLlm.model,
496
647
  });
497
648
  }
498
- const initialValue = current.llm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
649
+ const initialValue = currentLlm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
499
650
  const choice = await prompt(() => p.select({
500
651
  message: "Configure an LLM for richer metadata during indexing:",
501
652
  options,
502
653
  initialValue,
503
654
  }));
504
655
  if (choice === "keep")
505
- return cloneLlmConfig(current.llm);
656
+ return cloneLlmConfig(currentLlm);
506
657
  if (choice === "none")
507
658
  return undefined;
508
659
  let llm;
@@ -635,7 +786,7 @@ export async function stepRegistries(current) {
635
786
  * @internal Exported for testing only.
636
787
  */
637
788
  export async function stepAddSources(current, options) {
638
- const existingSources = [...(current.sources ?? current.stashes ?? [])];
789
+ const existingSources = [...(current.sources ?? [])];
639
790
  const sources = [];
640
791
  if (existingSources.length > 0) {
641
792
  p.note(renderConfiguredSourceList(existingSources), "Configured stash sources");
@@ -705,7 +856,7 @@ async function stepAgentPlatforms(current) {
705
856
  p.log.info("No agent platform configurations detected.");
706
857
  return [];
707
858
  }
708
- const existingPaths = new Set((current.sources ?? current.stashes ?? []).map((s) => s.path));
859
+ const existingPaths = new Set((current.sources ?? []).map((s) => s.path));
709
860
  // Filter out platforms already configured
710
861
  const newPlatforms = platforms.filter((pl) => !existingPaths.has(pl.path));
711
862
  if (newPlatforms.length === 0) {
@@ -734,12 +885,374 @@ async function stepAgentPlatforms(current) {
734
885
  }
735
886
  return entries;
736
887
  }
888
+ /**
889
+ * Step 1/2: Configure the small model connection used for metadata and bounded LLM features.
890
+ *
891
+ * Detects Ollama automatically and pre-selects it. The user may also choose
892
+ * OpenAI, LM Studio, a custom endpoint, or skip the step entirely.
893
+ */
894
+ export async function stepSmallModelConnection(current) {
895
+ p.log.step("Step 1/2: Configure your small model connection");
896
+ p.note([
897
+ "This connection is used for background processing:",
898
+ " • akm index (metadata enhancement)",
899
+ " • akm distill (lesson distillation)",
900
+ " • akm remember --enrich (memory compression)",
901
+ " • akm curate --rerank (search reranking)",
902
+ ].join("\n"));
903
+ // Probe for Ollama in the background while showing the note.
904
+ const spin = p.spinner();
905
+ spin.start("Detecting local services...");
906
+ const ollama = await detectOllama();
907
+ spin.stop(ollama.available ? `Ollama detected at ${ollama.endpoint}` : "No local services detected");
908
+ const ollamaEndpoint = ollama.available ? ollama.endpoint : undefined;
909
+ const providerOptions = [];
910
+ if (ollama.available) {
911
+ providerOptions.push({
912
+ value: "ollama",
913
+ label: "Ollama (local)",
914
+ hint: `detected at ${ollama.endpoint}`,
915
+ });
916
+ }
917
+ providerOptions.push({ value: "openai", label: "OpenAI", hint: "requires AKM_LLM_API_KEY" }, { value: "lmstudio", label: "LM Studio / local server", hint: "http://localhost:1234" }, { value: "custom", label: "Custom OpenAI-compatible endpoint" }, { value: "skip", label: "Skip — disable enrichment features" });
918
+ const currentLlmSmall = getCurrentLlm(current);
919
+ if (currentLlmSmall) {
920
+ providerOptions.push({
921
+ value: "keep",
922
+ label: `Keep current: ${currentLlmSmall.provider ?? currentLlmSmall.endpoint}`,
923
+ hint: currentLlmSmall.model,
924
+ });
925
+ }
926
+ const initialValue = currentLlmSmall ? "keep" : ollama.available ? "ollama" : "openai";
927
+ const providerChoice = await prompt(() => p.select({
928
+ message: "Provider:",
929
+ options: providerOptions,
930
+ initialValue,
931
+ }));
932
+ if (providerChoice === "keep") {
933
+ return { llm: cloneLlmConfig(currentLlmSmall), skipped: false, ollamaEndpoint };
934
+ }
935
+ if (providerChoice === "skip") {
936
+ p.note([
937
+ "Enrichment features disabled:",
938
+ " • akm index — metadata enhancement disabled",
939
+ " • akm distill — lesson generation",
940
+ " • akm remember --enrich",
941
+ " • akm curate --rerank",
942
+ "",
943
+ "You can configure this later with `akm setup`.",
944
+ ].join("\n"), "Warning");
945
+ return { llm: undefined, skipped: true, ollamaEndpoint };
946
+ }
947
+ let llm;
948
+ if (providerChoice === "ollama") {
949
+ const ollamaChatModels = ollama.models.filter((m) => !m.includes("embed") && !m.includes("nomic") && !m.includes("minilm") && !m.includes("bge"));
950
+ let model;
951
+ if (ollamaChatModels.length > 0) {
952
+ model = await prompt(() => p.select({
953
+ message: "Model name:",
954
+ options: [
955
+ ...ollamaChatModels.map((m) => ({ value: m, label: m })),
956
+ { value: "__custom__", label: "Enter a model name manually..." },
957
+ ],
958
+ initialValue: ollamaChatModels[0],
959
+ }));
960
+ if (model === "__custom__") {
961
+ model = await prompt(() => p.text({
962
+ message: "Model name:",
963
+ placeholder: "llama3.2",
964
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
965
+ }));
966
+ }
967
+ }
968
+ else {
969
+ model = await prompt(() => p.text({
970
+ message: "Model name (e.g. llama3.2):",
971
+ placeholder: "llama3.2",
972
+ defaultValue: "llama3.2",
973
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
974
+ }));
975
+ }
976
+ llm = {
977
+ provider: "ollama",
978
+ endpoint: `${ollama.endpoint}/v1/chat/completions`,
979
+ model: model.trim(),
980
+ temperature: 0.3,
981
+ maxTokens: 1024,
982
+ };
983
+ }
984
+ else if (providerChoice === "openai") {
985
+ const model = await prompt(() => p.text({
986
+ message: "Model name:",
987
+ placeholder: "gpt-4o-mini",
988
+ defaultValue: "gpt-4o-mini",
989
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
990
+ }));
991
+ if (!process.env.AKM_LLM_API_KEY) {
992
+ p.log.info("Set AKM_LLM_API_KEY in your shell before running `akm index`.");
993
+ }
994
+ llm = {
995
+ provider: "openai",
996
+ endpoint: "https://api.openai.com/v1/chat/completions",
997
+ model: model.trim() || "gpt-4o-mini",
998
+ temperature: 0.3,
999
+ maxTokens: 1024,
1000
+ };
1001
+ }
1002
+ else if (providerChoice === "lmstudio") {
1003
+ const endpoint = await prompt(() => p.text({
1004
+ message: "Endpoint URL:",
1005
+ placeholder: "http://localhost:1234/v1/chat/completions",
1006
+ defaultValue: "http://localhost:1234/v1/chat/completions",
1007
+ validate: (v) => {
1008
+ if (!v?.trim())
1009
+ return "Endpoint cannot be empty";
1010
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
1011
+ return "Must start with http:// or https://";
1012
+ },
1013
+ }));
1014
+ const model = await prompt(() => p.text({
1015
+ message: "Model name:",
1016
+ placeholder: "local-model",
1017
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1018
+ }));
1019
+ llm = {
1020
+ provider: "lmstudio",
1021
+ endpoint: endpoint.trim(),
1022
+ model: model.trim(),
1023
+ temperature: 0.3,
1024
+ maxTokens: 1024,
1025
+ };
1026
+ }
1027
+ else {
1028
+ // custom
1029
+ const endpoint = await prompt(() => p.text({
1030
+ message: "OpenAI-compatible chat completions endpoint:",
1031
+ placeholder: "https://your-host/v1/chat/completions",
1032
+ validate: (v) => {
1033
+ if (!v?.trim())
1034
+ return "Endpoint cannot be empty";
1035
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
1036
+ return "Must start with http:// or https://";
1037
+ },
1038
+ }));
1039
+ const model = await prompt(() => p.text({
1040
+ message: "Model name:",
1041
+ placeholder: "gpt-4o-mini",
1042
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1043
+ }));
1044
+ const apiKeyInput = await promptOrBack(() => p.text({
1045
+ message: "API key (optional — press Enter to skip):",
1046
+ placeholder: "",
1047
+ }));
1048
+ llm = {
1049
+ provider: "custom",
1050
+ endpoint: endpoint.trim(),
1051
+ model: model.trim(),
1052
+ temperature: 0.3,
1053
+ maxTokens: 1024,
1054
+ ...(apiKeyInput?.trim() ? { apiKey: apiKeyInput.trim() } : {}),
1055
+ };
1056
+ }
1057
+ // Best-effort probe — never blocks setup.
1058
+ const probeSpin = p.spinner();
1059
+ probeSpin.start("Probing LLM (structured-output round-trip)...");
1060
+ const probe = await probeLlmCapabilities(llm);
1061
+ if (probe.reachable && probe.structuredOutput) {
1062
+ probeSpin.stop("LLM reachable; structured output verified.");
1063
+ llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: true };
1064
+ }
1065
+ else if (probe.reachable) {
1066
+ probeSpin.stop("LLM reachable but structured-output probe failed.");
1067
+ llm.capabilities = { ...(llm.capabilities ?? {}), structuredOutput: false };
1068
+ }
1069
+ else {
1070
+ probeSpin.stop("LLM not reachable.");
1071
+ p.log.warn(`Could not reach the LLM endpoint${probe.error ? ` (${probe.error})` : ""}. Configuration was saved; verify your endpoint and API key, then retry.`);
1072
+ }
1073
+ return { llm, skipped: false, ollamaEndpoint };
1074
+ }
1075
+ /**
1076
+ * Step 2/2: Configure the agent connection used for agentic features.
1077
+ *
1078
+ * Options depend on whether Step 1 was completed or skipped.
1079
+ */
1080
+ export async function stepAgentConnection(current, smallModel) {
1081
+ p.log.step("Step 2/2: Configure your agent connection");
1082
+ p.note([
1083
+ "This connection is used for agentic commands:",
1084
+ " • akm propose (generate improvement proposals)",
1085
+ " • akm improve (run the reflect/distill/consolidate self-improvement pipeline)",
1086
+ " • akm tasks run (run automated task prompts)",
1087
+ ].join("\n"));
1088
+ // Detect available CLI agents.
1089
+ const detections = detectAgentCliProfiles(current);
1090
+ const currentAgentBlock = getCurrentAgentBlock(current);
1091
+ const availableClis = detections.filter((d) => d.available);
1092
+ const agentOptions = [];
1093
+ if (!smallModel.skipped && smallModel.llm) {
1094
+ agentOptions.push({
1095
+ value: "same-connection",
1096
+ label: "Same connection, select model",
1097
+ hint: `uses ${smallModel.llm.endpoint.replace("/v1/chat/completions", "")}`,
1098
+ });
1099
+ }
1100
+ agentOptions.push({ value: "new-connection", label: "New connection (different endpoint)" });
1101
+ if (availableClis.length > 0) {
1102
+ agentOptions.push({
1103
+ value: "cli-agent",
1104
+ label: "Installed CLI agent",
1105
+ hint: `${availableClis.map((d) => d.name).join(", ")} detected`,
1106
+ });
1107
+ }
1108
+ agentOptions.push({ value: "none", label: "None — disable agentic features" });
1109
+ if (currentAgentBlock) {
1110
+ const currentDesc = currentAgentBlock.default
1111
+ ? `CLI: ${currentAgentBlock.default}`
1112
+ : currentAgentBlock.profiles?.default?.model
1113
+ ? `SDK: ${currentAgentBlock.profiles.default.model}`
1114
+ : "configured";
1115
+ agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
1116
+ }
1117
+ const initialAgentValue = currentAgentBlock
1118
+ ? "keep"
1119
+ : availableClis.length > 0 && smallModel.skipped
1120
+ ? "cli-agent"
1121
+ : !smallModel.skipped && smallModel.llm
1122
+ ? "same-connection"
1123
+ : availableClis.length > 0
1124
+ ? "cli-agent"
1125
+ : "none";
1126
+ const agentChoice = await prompt(() => p.select({
1127
+ message: "How do you want to run agent commands?",
1128
+ options: agentOptions,
1129
+ initialValue: initialAgentValue,
1130
+ }));
1131
+ if (agentChoice === "keep") {
1132
+ return currentAgentBlock;
1133
+ }
1134
+ if (agentChoice === "none") {
1135
+ p.note([
1136
+ "Agentic features disabled:",
1137
+ ' • akm propose — will show "no agent configured" error',
1138
+ ' • akm improve — will show "no agent configured" error',
1139
+ ' • akm tasks run — will show "no agent configured" error',
1140
+ "",
1141
+ "You can configure this later with `akm setup`.",
1142
+ ].join("\n"), "Warning");
1143
+ return undefined;
1144
+ }
1145
+ if (agentChoice === "same-connection") {
1146
+ if (smallModel.skipped || !smallModel.llm) {
1147
+ p.log.warn("You skipped the small model connection. Configure one to use the same connection. Falling back to 'new connection'.");
1148
+ // Fall through to new-connection flow
1149
+ }
1150
+ else {
1151
+ const baseEndpoint = smallModel.llm.endpoint.replace("/v1/chat/completions", "");
1152
+ p.log.info(`Endpoint: ${baseEndpoint} (from Step 1)`);
1153
+ const agentModel = await prompt(() => p.text({
1154
+ message: "Model to use for agent tasks (same model is fine, larger models work better):",
1155
+ placeholder: "qwen2.5-coder:32b",
1156
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1157
+ }));
1158
+ const profileName = smallModel.llm.provider ?? "default";
1159
+ return {
1160
+ ...(currentAgentBlock ?? {}),
1161
+ profiles: {
1162
+ ...(currentAgentBlock?.profiles ?? {}),
1163
+ [profileName]: {
1164
+ ...(currentAgentBlock?.profiles?.[profileName] ?? {}),
1165
+ sdkMode: true,
1166
+ model: agentModel.trim(),
1167
+ endpoint: smallModel.llm.endpoint,
1168
+ },
1169
+ },
1170
+ default: profileName,
1171
+ };
1172
+ }
1173
+ }
1174
+ if (agentChoice === "cli-agent") {
1175
+ if (availableClis.length === 0) {
1176
+ p.log.warn("No agent CLIs detected on PATH.");
1177
+ return currentAgentBlock;
1178
+ }
1179
+ const initialCli = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? availableClis[0]?.name;
1180
+ const selectedCli = await prompt(() => p.select({
1181
+ message: "Which CLI agent?",
1182
+ options: availableClis.map((d) => ({
1183
+ value: d.name,
1184
+ label: d.name,
1185
+ hint: d.resolvedPath ?? d.bin,
1186
+ })),
1187
+ initialValue: initialCli,
1188
+ }));
1189
+ return {
1190
+ ...(currentAgentBlock ?? {}),
1191
+ default: selectedCli,
1192
+ };
1193
+ }
1194
+ // "new-connection" (also fall-through from "same-provider" when Step 1 was skipped)
1195
+ const newEndpoint = await prompt(() => p.text({
1196
+ message: "OpenAI-compatible chat completions endpoint:",
1197
+ placeholder: "https://your-host/v1/chat/completions",
1198
+ validate: (v) => {
1199
+ if (!v?.trim())
1200
+ return "Endpoint cannot be empty";
1201
+ if (!v.startsWith("http://") && !v.startsWith("https://"))
1202
+ return "Must start with http:// or https://";
1203
+ },
1204
+ }));
1205
+ const newApiKeyInput = await promptOrBack(() => p.text({
1206
+ message: "API key (optional — press Enter to skip):",
1207
+ placeholder: "",
1208
+ }));
1209
+ const newModel = await prompt(() => p.text({
1210
+ message: "Model name (larger is better, e.g. gpt-4o):",
1211
+ placeholder: "gpt-4o",
1212
+ validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
1213
+ }));
1214
+ const customProfile = {
1215
+ sdkMode: true,
1216
+ endpoint: newEndpoint.trim(),
1217
+ model: newModel.trim(),
1218
+ ...(newApiKeyInput?.trim() ? { apiKey: newApiKeyInput.trim() } : {}),
1219
+ };
1220
+ return {
1221
+ ...(currentAgentBlock ?? {}),
1222
+ profiles: {
1223
+ ...(currentAgentBlock?.profiles ?? {}),
1224
+ custom: customProfile,
1225
+ },
1226
+ default: "custom",
1227
+ };
1228
+ }
1229
+ /**
1230
+ * Print a feature capability summary after both connection steps are complete.
1231
+ */
1232
+ function printCapabilitySummary(smallModelSkipped, agentConfigured) {
1233
+ const lines = ["Setup complete. Here's what's enabled:", ""];
1234
+ lines.push(" ✓ akm search, akm curate, akm show — always available");
1235
+ if (!smallModelSkipped) {
1236
+ lines.push(" ✓ akm index, akm distill, akm remember — small model configured");
1237
+ }
1238
+ else {
1239
+ lines.push(" ✗ akm index, akm distill, akm remember — run `akm setup` to enable");
1240
+ }
1241
+ if (agentConfigured) {
1242
+ lines.push(" ✓ akm propose, akm improve, akm tasks — agent configured");
1243
+ }
1244
+ else {
1245
+ lines.push(" ✗ akm propose, akm improve, akm tasks — run `akm setup` to enable");
1246
+ }
1247
+ p.note(lines.join("\n"), "Feature Summary");
1248
+ }
737
1249
  export async function stepAgentSelection(current, detections) {
1250
+ const currentAgentBlock = getCurrentAgentBlock(current);
738
1251
  const available = detections.filter((d) => d.available);
739
1252
  if (available.length === 0) {
740
- return current.agent;
1253
+ return currentAgentBlock;
741
1254
  }
742
- const initialValue = pickDefaultAgentProfile(detections, current.agent?.default) ?? available[0]?.name;
1255
+ const initialValue = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? available[0]?.name;
743
1256
  const selectedDefault = await prompt(() => p.select({
744
1257
  message: "Which detected agent CLI should be the default?",
745
1258
  options: [
@@ -753,16 +1266,16 @@ export async function stepAgentSelection(current, detections) {
753
1266
  initialValue,
754
1267
  }));
755
1268
  if (selectedDefault === "disabled") {
756
- if (!current.agent?.profiles && !current.agent?.timeoutMs) {
1269
+ if (!currentAgentBlock?.profiles && !currentAgentBlock?.timeoutMs) {
757
1270
  return undefined;
758
1271
  }
759
1272
  return {
760
- ...(current.agent ?? {}),
1273
+ ...(currentAgentBlock ?? {}),
761
1274
  default: undefined,
762
1275
  };
763
1276
  }
764
1277
  return {
765
- ...(current.agent ?? {}),
1278
+ ...(currentAgentBlock ?? {}),
766
1279
  default: selectedDefault,
767
1280
  };
768
1281
  }
@@ -799,14 +1312,15 @@ export async function stepOutputConfig(current) {
799
1312
  * @internal Exported for testing only.
800
1313
  */
801
1314
  export function stepAgentCliDetection(current, detectFn = detectAgentCliProfiles) {
802
- const detections = detectFn(current.agent);
803
- const defaultName = pickDefaultAgentProfile(detections, current.agent?.default);
1315
+ const detections = detectFn(current);
1316
+ const currentAgentBlock = getCurrentAgentBlock(current);
1317
+ const defaultName = pickDefaultAgentProfile(detections, currentAgentBlock?.default);
804
1318
  // No installed agents found and no existing config → leave block absent.
805
- if (!defaultName && !current.agent) {
1319
+ if (!defaultName && !currentAgentBlock) {
806
1320
  return { detections };
807
1321
  }
808
1322
  const agent = {
809
- ...(current.agent ?? {}),
1323
+ ...(currentAgentBlock ?? {}),
810
1324
  ...(defaultName ? { default: defaultName } : {}),
811
1325
  };
812
1326
  return { agent, detections };
@@ -832,7 +1346,10 @@ export function buildSetupSteps(options) {
832
1346
  label: "Stash Directory",
833
1347
  nonInteractive: true,
834
1348
  async run(ctx) {
835
- const stashDir = await stepStashDir(ctx.config);
1349
+ const stashDir = await stepStashDir(ctx.config, {
1350
+ nonInteractive: ctx.nonInteractive,
1351
+ preferredDir: options.preferredStashDir,
1352
+ });
836
1353
  ctx.apply({ stashDir });
837
1354
  },
838
1355
  },
@@ -855,11 +1372,10 @@ export function buildSetupSteps(options) {
855
1372
  label: "LLM Provider",
856
1373
  async run(ctx) {
857
1374
  if (!options.online) {
858
- ctx.apply({ llm: ctx.config.llm });
859
1375
  return;
860
1376
  }
861
1377
  const llm = await stepLlm(ctx.config, ollamaEndpoint, ollamaChatModels);
862
- ctx.apply({ llm });
1378
+ ctx.apply(applyLegacyLlm(ctx.config, llm));
863
1379
  },
864
1380
  },
865
1381
  {
@@ -907,8 +1423,11 @@ export function buildSetupSteps(options) {
907
1423
  else {
908
1424
  p.log.info("No agent CLIs detected on PATH. Agent commands will be disabled until one is installed and `akm setup` is re-run.");
909
1425
  }
910
- const agent = await stepAgentSelection({ ...ctx.config, agent: result.agent }, result.detections);
911
- ctx.apply({ agent });
1426
+ // Inject the detected agent block into a synthetic AkmConfig so
1427
+ // stepAgentSelection can read it via getCurrentAgentBlock().
1428
+ const synthConfig = { ...ctx.config, ...applyLegacyAgent(ctx.config, result.agent) };
1429
+ const agent = await stepAgentSelection(synthConfig, result.detections);
1430
+ ctx.apply(applyLegacyAgent(ctx.config, agent));
912
1431
  },
913
1432
  },
914
1433
  {
@@ -922,10 +1441,21 @@ export function buildSetupSteps(options) {
922
1441
  ];
923
1442
  return { steps, outcome };
924
1443
  }
925
- export async function runSetupWizard() {
1444
+ export async function runSetupWizard(opts) {
926
1445
  p.intro("akm setup");
927
1446
  const current = loadUserConfig();
928
1447
  const configPath = getConfigPath();
1448
+ // Resolve stash directory early so akmInit can run before any prompts
1449
+ const resolvedStashDir = opts?.dir ? path.resolve(opts.dir) : (current.stashDir ?? getDefaultStashDir());
1450
+ // Refuse explicit --dir /tmp/... before doing any work — protects the host
1451
+ // config from being clobbered with a stashDir that the OS may reap.
1452
+ assertSetupSandbox(resolvedStashDir, opts?.dir != null);
1453
+ applyStashIsolationToEnv(resolvedStashDir, opts?.dir != null);
1454
+ // Bootstrap directory structure before any prompts so the stash exists
1455
+ // even if the wizard is interrupted after this point.
1456
+ if (!opts?.noInit) {
1457
+ await akmInit({ dir: resolvedStashDir });
1458
+ }
929
1459
  // Quick connectivity check — skip network-dependent steps when offline
930
1460
  const online = await isOnline();
931
1461
  if (!online) {
@@ -936,6 +1466,7 @@ export async function runSetupWizard() {
936
1466
  const { steps, outcome } = buildSetupSteps({
937
1467
  online,
938
1468
  semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false },
1469
+ preferredStashDir: resolvedStashDir,
939
1470
  });
940
1471
  // Wrap each step with a `p.log.step()` header so the wizard UI is
941
1472
  // unchanged. The canonical `runSetupSteps()` runner is used directly by
@@ -948,6 +1479,15 @@ export async function runSetupWizard() {
948
1479
  },
949
1480
  }));
950
1481
  await runSetupSteps(labeledSteps, ctx);
1482
+ // ── Two-step connection configuration ──────────────────────────────────────
1483
+ // Step 1/2: Small model connection (for enrichment features)
1484
+ const smallModelResult = await stepSmallModelConnection(ctx.config);
1485
+ if (!smallModelResult.skipped) {
1486
+ ctx.apply(applyLegacyLlm(ctx.config, smallModelResult.llm));
1487
+ }
1488
+ // Step 2/2: Agent connection (for agentic features)
1489
+ const agentConfig = await stepAgentConnection(ctx.config, smallModelResult);
1490
+ ctx.apply(applyLegacyAgent(ctx.config, agentConfig));
951
1491
  const newConfig = {
952
1492
  ...ctx.config,
953
1493
  // Preserve fields the steps don't manage explicitly.
@@ -956,9 +1496,12 @@ export async function runSetupWizard() {
956
1496
  const semanticSearchMode = outcome.semantic;
957
1497
  const stashDir = newConfig.stashDir ?? current.stashDir ?? getDefaultStashDir();
958
1498
  const embedding = newConfig.embedding;
959
- const llm = newConfig.llm;
1499
+ const llm = getDefaultLlmConfig(newConfig);
960
1500
  const registries = newConfig.registries;
961
- const allStashes = newConfig.sources ?? newConfig.stashes ?? [];
1501
+ const allStashes = newConfig.sources ?? [];
1502
+ // Feature capability summary
1503
+ const agentConfigured = Boolean(agentConfig);
1504
+ printCapabilitySummary(smallModelResult.skipped, agentConfigured);
962
1505
  // Confirm before saving
963
1506
  const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
964
1507
  p.note([
@@ -968,7 +1511,7 @@ export async function runSetupWizard() {
968
1511
  `Semantic search: ${semanticSearchMode.mode}`,
969
1512
  `Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`,
970
1513
  `Stash sources: ${allStashes.length}`,
971
- `Agent default: ${newConfig.agent?.default ?? "disabled"}`,
1514
+ `Agent default: ${newConfig.defaults?.agent ?? "disabled"}`,
972
1515
  `Output: ${newConfig.output?.format ?? "json"} / ${newConfig.output?.detail ?? "brief"}`,
973
1516
  ].join("\n"), "Configuration Summary");
974
1517
  const shouldSave = await prompt(() => p.confirm({
@@ -979,8 +1522,6 @@ export async function runSetupWizard() {
979
1522
  bail();
980
1523
  // Save config
981
1524
  saveConfig(newConfig);
982
- // Initialize stash directory
983
- await akmInit({ dir: stashDir });
984
1525
  if (semanticSearchMode.mode === "off") {
985
1526
  clearSemanticStatus();
986
1527
  }
@@ -1062,3 +1603,200 @@ export async function runSetupWizard() {
1062
1603
  }
1063
1604
  p.outro(`Configuration saved to ${configPath}`);
1064
1605
  }
1606
+ // ── Non-interactive / scripting entry points ─────────────────────────────────
1607
+ /**
1608
+ * Run setup in non-interactive mode, applying all defaults.
1609
+ * Safe to call from CI or scripts. Idempotent — re-running produces the same result.
1610
+ */
1611
+ export async function runSetupWithDefaults(opts) {
1612
+ const current = loadUserConfig();
1613
+ const stashDir = opts.dir ? path.resolve(opts.dir) : (current.stashDir ?? getDefaultStashDir());
1614
+ assertSetupSandbox(stashDir, opts.dir != null);
1615
+ applyStashIsolationToEnv(stashDir, opts.dir != null);
1616
+ // Bootstrap directory structure first
1617
+ let initResult;
1618
+ if (!opts.noInit) {
1619
+ initResult = await akmInit({ dir: stashDir });
1620
+ }
1621
+ // Run steps in non-interactive mode (applies defaults, skips prompts)
1622
+ const ctx = createSetupContext(current, { nonInteractive: true });
1623
+ const { steps } = buildSetupSteps({
1624
+ online: false,
1625
+ semanticSearchOutcome: { mode: current.semanticSearchMode, prepareAssets: false },
1626
+ preferredStashDir: stashDir,
1627
+ });
1628
+ await runSetupSteps(steps, ctx);
1629
+ // Ensure stashDir is set
1630
+ if (!ctx.config.stashDir)
1631
+ ctx.apply({ stashDir });
1632
+ // Auto-detect agent CLI if not already configured
1633
+ if (!ctx.config.defaults?.agent) {
1634
+ const detected = detectAgentCliProfiles(undefined);
1635
+ const defaultProfile = pickDefaultAgentProfile(detected, undefined);
1636
+ if (defaultProfile) {
1637
+ ctx.apply(applyLegacyAgent(ctx.config, { default: defaultProfile }));
1638
+ }
1639
+ }
1640
+ saveConfig(ctx.config);
1641
+ return {
1642
+ configPath: getConfigPath(),
1643
+ stashDir,
1644
+ stashCreated: initResult?.created ?? false,
1645
+ written: true,
1646
+ fields: Object.keys(ctx.config).filter((k) => ctx.config[k] !== undefined),
1647
+ ripgrep: initResult?.ripgrep,
1648
+ };
1649
+ }
1650
+ /**
1651
+ * Apply a JSON config blob non-interactively, merging it with the current config.
1652
+ * Validates required sub-fields and strips unknown/restricted keys.
1653
+ */
1654
+ export async function runSetupFromConfig(opts) {
1655
+ let incoming;
1656
+ try {
1657
+ incoming = JSON.parse(opts.configJson);
1658
+ }
1659
+ catch (e) {
1660
+ throw new Error(`Invalid JSON in --config: ${e.message}`);
1661
+ }
1662
+ // Phase 2: Validate — only allow safe top-level keys
1663
+ const ALLOWED_KEYS = new Set([
1664
+ "stashDir",
1665
+ "llm",
1666
+ "embedding",
1667
+ "agent",
1668
+ "semanticSearchMode",
1669
+ "output",
1670
+ "profiles",
1671
+ "defaults",
1672
+ ]);
1673
+ for (const key of Object.keys(incoming)) {
1674
+ if (!ALLOWED_KEYS.has(key)) {
1675
+ warn(`[akm setup] Ignoring unknown or restricted config key: "${key}"`);
1676
+ delete incoming[key];
1677
+ }
1678
+ }
1679
+ // Validate required sub-fields
1680
+ if (incoming.llm) {
1681
+ if (!incoming.llm.endpoint?.trim())
1682
+ throw new Error("llm.endpoint is required when llm is provided");
1683
+ if (!incoming.llm.model?.trim())
1684
+ throw new Error("llm.model is required when llm is provided");
1685
+ }
1686
+ if (incoming.embedding) {
1687
+ if (!incoming.embedding.endpoint?.trim())
1688
+ throw new Error("embedding.endpoint is required when embedding is provided");
1689
+ if (!incoming.embedding.model?.trim())
1690
+ throw new Error("embedding.model is required when embedding is provided");
1691
+ }
1692
+ // Phase 3: Merge with existing config
1693
+ const current = loadUserConfig();
1694
+ const stashDir = opts.dir
1695
+ ? path.resolve(opts.dir)
1696
+ : incoming.stashDir
1697
+ ? path.resolve(incoming.stashDir)
1698
+ : (current.stashDir ?? getDefaultStashDir());
1699
+ const stashDirExplicit = opts.dir != null || incoming.stashDir != null;
1700
+ assertSetupSandbox(stashDir, stashDirExplicit);
1701
+ applyStashIsolationToEnv(stashDir, stashDirExplicit);
1702
+ let merged = { ...current, stashDir };
1703
+ // Apply non-llm/agent keys directly.
1704
+ const mergedRec = merged;
1705
+ for (const key of Object.keys(incoming)) {
1706
+ if (key === "llm" || key === "agent")
1707
+ continue;
1708
+ mergedRec[key] = incoming[key];
1709
+ }
1710
+ // Translate legacy llm/agent inputs into the new shape.
1711
+ if (incoming.llm) {
1712
+ merged = { ...merged, ...applyLegacyLlm(merged, incoming.llm) };
1713
+ }
1714
+ if (incoming.agent) {
1715
+ merged = { ...merged, ...applyLegacyAgent(merged, incoming.agent) };
1716
+ }
1717
+ // Bootstrap directory structure
1718
+ let initResult;
1719
+ if (!opts.noInit) {
1720
+ initResult = await akmInit({ dir: stashDir });
1721
+ }
1722
+ // Optional probe
1723
+ const mergedLlm = getDefaultLlmConfig(merged);
1724
+ if (opts.probe && mergedLlm) {
1725
+ try {
1726
+ const caps = await probeLlmCapabilities(mergedLlm);
1727
+ if (caps.reachable) {
1728
+ merged = {
1729
+ ...merged,
1730
+ ...applyLegacyLlm(merged, {
1731
+ ...mergedLlm,
1732
+ capabilities: { structuredOutput: caps.structuredOutput ?? false },
1733
+ }),
1734
+ };
1735
+ }
1736
+ }
1737
+ catch {
1738
+ // Non-fatal: probe failure is informational only
1739
+ }
1740
+ }
1741
+ saveConfig(merged);
1742
+ return {
1743
+ configPath: getConfigPath(),
1744
+ stashDir,
1745
+ stashCreated: initResult?.created ?? false,
1746
+ written: true,
1747
+ fields: Object.keys(incoming).filter((k) => incoming[k] !== undefined),
1748
+ ripgrep: initResult?.ripgrep,
1749
+ };
1750
+ }
1751
+ // ── Setup --from <file> bootstrap helper ────────────────────────────────────
1752
+ /**
1753
+ * Resolve a `--from <file>` argument to a JSON-encoded config payload suitable
1754
+ * for `runSetupFromConfig({ configJson })`. Used by the CLI to bootstrap from
1755
+ * a JSON or YAML file on disk; extracted as a standalone function so its
1756
+ * filesystem and parser behaviour can be unit-tested directly.
1757
+ *
1758
+ * - Expands a leading `~` to the current user's home directory.
1759
+ * - Resolves the path against `cwd ?? process.cwd()` for relative inputs.
1760
+ * - Detects YAML vs JSON via the file extension (`.yml`/`.yaml` → YAML;
1761
+ * anything else, including `.json`, parses as JSON).
1762
+ * - Throws `ConfigError("INVALID_CONFIG_FILE")` when the file does not exist,
1763
+ * cannot be read, cannot be parsed, or contains a non-object top level.
1764
+ *
1765
+ * Returns `{ configJson, resolvedPath, format }` so callers can log which
1766
+ * file was actually loaded and which parser was used.
1767
+ */
1768
+ export async function loadSetupConfigFromFile(filePath, opts) {
1769
+ const cwd = opts?.cwd ?? process.cwd();
1770
+ const homeDir = opts?.homeDir ?? os.homedir();
1771
+ const expanded = filePath.startsWith("~") ? path.join(homeDir, filePath.slice(1)) : filePath;
1772
+ const resolvedPath = path.resolve(cwd, expanded);
1773
+ if (!fs.existsSync(resolvedPath)) {
1774
+ throw new ConfigError(`Config file not found: ${resolvedPath}`, "INVALID_CONFIG_FILE");
1775
+ }
1776
+ let raw;
1777
+ try {
1778
+ raw = fs.readFileSync(resolvedPath, "utf8");
1779
+ }
1780
+ catch (err) {
1781
+ throw new ConfigError(`Failed to read config file ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
1782
+ }
1783
+ const ext = path.extname(resolvedPath).toLowerCase();
1784
+ const format = ext === ".yml" || ext === ".yaml" ? "yaml" : "json";
1785
+ let parsed;
1786
+ try {
1787
+ if (format === "yaml") {
1788
+ const { parse: yamlParse } = await import("yaml");
1789
+ parsed = yamlParse(raw);
1790
+ }
1791
+ else {
1792
+ parsed = JSON.parse(raw);
1793
+ }
1794
+ }
1795
+ catch (err) {
1796
+ throw new ConfigError(`Failed to parse ${format.toUpperCase()} config file ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE");
1797
+ }
1798
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1799
+ throw new ConfigError(`Config file ${resolvedPath} must contain a top-level object, got ${Array.isArray(parsed) ? "array" : typeof parsed}.`, "INVALID_CONFIG_FILE");
1800
+ }
1801
+ return { configJson: JSON.stringify(parsed), resolvedPath, format };
1802
+ }