akm-cli 0.8.0-rc1 → 0.8.0

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 (295) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
  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 +93 -3
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2162 -1258
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +20 -12
  12. package/dist/commands/agent-support.js +11 -5
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +129 -517
  15. package/dist/commands/consolidate.js +1533 -144
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +5 -3
  19. package/dist/commands/distill.js +906 -100
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +3 -0
  22. package/dist/commands/events.js +3 -0
  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 +260 -5
  28. package/dist/commands/health.js +977 -51
  29. package/dist/commands/help/help-accept.md +6 -3
  30. package/dist/commands/help/help-improve.md +36 -8
  31. package/dist/commands/help/help-proposals.md +7 -4
  32. package/dist/commands/help/help-reject.md +5 -2
  33. package/dist/commands/history.js +51 -16
  34. package/dist/commands/improve-auto-accept.js +97 -0
  35. package/dist/commands/improve-cli.js +236 -0
  36. package/dist/commands/improve-profiles.js +184 -0
  37. package/dist/commands/improve-result-file.js +167 -0
  38. package/dist/commands/improve.js +1725 -332
  39. package/dist/commands/info.js +3 -0
  40. package/dist/commands/init.js +49 -1
  41. package/dist/commands/installed-stashes.js +6 -23
  42. package/dist/commands/knowledge.js +3 -0
  43. package/dist/commands/lint/agent-linter.js +3 -0
  44. package/dist/commands/lint/base-linter.js +233 -5
  45. package/dist/commands/lint/command-linter.js +3 -0
  46. package/dist/commands/lint/default-linter.js +3 -0
  47. package/dist/commands/lint/env-key-rules.js +154 -0
  48. package/dist/commands/lint/index.js +92 -3
  49. package/dist/commands/lint/knowledge-linter.js +3 -0
  50. package/dist/commands/lint/markdown-insertion.js +343 -0
  51. package/dist/commands/lint/memory-linter.js +3 -0
  52. package/dist/commands/lint/registry.js +3 -0
  53. package/dist/commands/lint/skill-linter.js +3 -0
  54. package/dist/commands/lint/task-linter.js +15 -12
  55. package/dist/commands/lint/types.js +3 -0
  56. package/dist/commands/lint/workflow-linter.js +3 -0
  57. package/dist/commands/lint.js +3 -0
  58. package/dist/commands/migration-help.js +5 -2
  59. package/dist/commands/proposal-drain-policies.js +128 -0
  60. package/dist/commands/proposal-drain.js +477 -0
  61. package/dist/commands/proposal.js +60 -6
  62. package/dist/commands/propose.js +24 -19
  63. package/dist/commands/reflect.js +1004 -94
  64. package/dist/commands/registry-cli.js +150 -0
  65. package/dist/commands/registry-search.js +3 -0
  66. package/dist/commands/remember-cli.js +257 -0
  67. package/dist/commands/remember.js +15 -6
  68. package/dist/commands/schema-repair.js +88 -15
  69. package/dist/commands/search.js +99 -14
  70. package/dist/commands/secret.js +173 -0
  71. package/dist/commands/self-update.js +3 -0
  72. package/dist/commands/show.js +32 -13
  73. package/dist/commands/source-add.js +7 -35
  74. package/dist/commands/source-clone.js +3 -0
  75. package/dist/commands/source-manage.js +3 -0
  76. package/dist/commands/tasks.js +161 -95
  77. package/dist/commands/url-checker.js +3 -0
  78. package/dist/core/action-contributors.js +3 -0
  79. package/dist/core/asset-ref.js +17 -2
  80. package/dist/core/asset-registry.js +9 -2
  81. package/dist/core/asset-serialize.js +88 -0
  82. package/dist/core/asset-spec.js +61 -5
  83. package/dist/core/common.js +93 -5
  84. package/dist/core/concurrent.js +3 -0
  85. package/dist/core/config-io.js +347 -0
  86. package/dist/core/config-migration.js +622 -0
  87. package/dist/core/config-schema.js +558 -0
  88. package/dist/core/config-sources.js +108 -0
  89. package/dist/core/config-types.js +4 -0
  90. package/dist/core/config-walker.js +337 -0
  91. package/dist/core/config.js +366 -1077
  92. package/dist/core/errors.js +42 -20
  93. package/dist/core/events.js +31 -25
  94. package/dist/core/file-lock.js +104 -0
  95. package/dist/core/frontmatter.js +75 -10
  96. package/dist/core/lesson-lint.js +3 -0
  97. package/dist/core/markdown.js +3 -0
  98. package/dist/core/memory-belief.js +62 -0
  99. package/dist/core/memory-contradiction-detect.js +274 -0
  100. package/dist/core/memory-improve.js +142 -14
  101. package/dist/core/parse.js +3 -0
  102. package/dist/core/paths.js +218 -50
  103. package/dist/core/proposal-quality-validators.js +380 -0
  104. package/dist/core/proposal-validators.js +11 -3
  105. package/dist/core/proposals.js +464 -5
  106. package/dist/core/state-db.js +349 -56
  107. package/dist/core/text-truncation.js +107 -0
  108. package/dist/core/time.js +3 -0
  109. package/dist/core/tty.js +59 -0
  110. package/dist/core/warn.js +7 -2
  111. package/dist/core/write-source.js +12 -0
  112. package/dist/indexer/db-backup.js +391 -0
  113. package/dist/indexer/db-search.js +136 -28
  114. package/dist/indexer/db.js +662 -166
  115. package/dist/indexer/ensure-index.js +3 -0
  116. package/dist/indexer/file-context.js +3 -0
  117. package/dist/indexer/graph-boost.js +162 -40
  118. package/dist/indexer/graph-db.js +241 -51
  119. package/dist/indexer/graph-dedup.js +3 -7
  120. package/dist/indexer/graph-extraction.js +242 -149
  121. package/dist/indexer/index-context.js +3 -9
  122. package/dist/indexer/indexer.js +84 -14
  123. package/dist/indexer/llm-cache.js +24 -19
  124. package/dist/indexer/manifest.js +3 -0
  125. package/dist/indexer/matchers.js +184 -11
  126. package/dist/indexer/memory-inference.js +94 -50
  127. package/dist/indexer/metadata-contributors.js +3 -0
  128. package/dist/indexer/metadata.js +114 -48
  129. package/dist/indexer/path-resolver.js +3 -0
  130. package/dist/indexer/project-context.js +192 -0
  131. package/dist/indexer/ranking-contributors.js +134 -7
  132. package/dist/indexer/ranking.js +8 -1
  133. package/dist/indexer/search-fields.js +5 -9
  134. package/dist/indexer/search-hit-enrichers.js +91 -2
  135. package/dist/indexer/search-source.js +20 -1
  136. package/dist/indexer/semantic-status.js +4 -1
  137. package/dist/indexer/staleness-detect.js +447 -0
  138. package/dist/indexer/usage-events.js +12 -9
  139. package/dist/indexer/walker.js +3 -0
  140. package/dist/integrations/agent/builders.js +135 -0
  141. package/dist/integrations/agent/config.js +121 -401
  142. package/dist/integrations/agent/detect.js +3 -0
  143. package/dist/integrations/agent/index.js +6 -14
  144. package/dist/integrations/agent/model-aliases.js +55 -0
  145. package/dist/integrations/agent/profiles.js +3 -0
  146. package/dist/integrations/agent/prompts.js +137 -8
  147. package/dist/integrations/agent/runner.js +208 -0
  148. package/dist/integrations/agent/sdk-runner.js +8 -2
  149. package/dist/integrations/agent/spawn.js +54 -14
  150. package/dist/integrations/github.js +3 -0
  151. package/dist/integrations/lockfile.js +22 -51
  152. package/dist/integrations/session-logs/index.js +4 -0
  153. package/dist/integrations/session-logs/inline-refs.js +35 -0
  154. package/dist/integrations/session-logs/pre-filter.js +152 -0
  155. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  156. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  157. package/dist/integrations/session-logs/types.js +3 -0
  158. package/dist/llm/call-ai.js +14 -26
  159. package/dist/llm/client.js +16 -2
  160. package/dist/llm/embedder.js +20 -29
  161. package/dist/llm/embedders/cache.js +3 -7
  162. package/dist/llm/embedders/local.js +42 -1
  163. package/dist/llm/embedders/remote.js +20 -8
  164. package/dist/llm/embedders/types.js +3 -7
  165. package/dist/llm/feature-gate.js +92 -56
  166. package/dist/llm/graph-extract.js +401 -30
  167. package/dist/llm/index-passes.js +44 -29
  168. package/dist/llm/memory-infer.js +30 -2
  169. package/dist/llm/metadata-enhance.js +3 -7
  170. package/dist/llm/prompts/extract-session.md +80 -0
  171. package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
  172. package/dist/output/cli-hints-full.md +60 -32
  173. package/dist/output/cli-hints-short.md +10 -7
  174. package/dist/output/cli-hints.js +5 -2
  175. package/dist/output/context.js +60 -8
  176. package/dist/output/renderers.js +170 -194
  177. package/dist/output/shapes/curate.js +56 -0
  178. package/dist/output/shapes/distill.js +10 -0
  179. package/dist/output/shapes/env-list.js +19 -0
  180. package/dist/output/shapes/events.js +11 -0
  181. package/dist/output/shapes/helpers.js +424 -0
  182. package/dist/output/shapes/history.js +7 -0
  183. package/dist/output/shapes/passthrough.js +105 -0
  184. package/dist/output/shapes/proposal-accept.js +7 -0
  185. package/dist/output/shapes/proposal-diff.js +7 -0
  186. package/dist/output/shapes/proposal-list.js +7 -0
  187. package/dist/output/shapes/proposal-producer.js +11 -0
  188. package/dist/output/shapes/proposal-reject.js +7 -0
  189. package/dist/output/shapes/proposal-show.js +7 -0
  190. package/dist/output/shapes/registry-search.js +6 -0
  191. package/dist/output/shapes/registry.js +30 -0
  192. package/dist/output/shapes/search.js +6 -0
  193. package/dist/output/shapes/secret-list.js +19 -0
  194. package/dist/output/shapes/show.js +6 -0
  195. package/dist/output/shapes/vault-list.js +19 -0
  196. package/dist/output/shapes.js +51 -549
  197. package/dist/output/text/add.js +6 -0
  198. package/dist/output/text/clone.js +6 -0
  199. package/dist/output/text/config.js +6 -0
  200. package/dist/output/text/curate.js +6 -0
  201. package/dist/output/text/distill.js +7 -0
  202. package/dist/output/text/enable-disable.js +7 -0
  203. package/dist/output/text/events.js +10 -0
  204. package/dist/output/text/feedback.js +6 -0
  205. package/dist/output/text/helpers.js +1059 -0
  206. package/dist/output/text/history.js +7 -0
  207. package/dist/output/text/import.js +6 -0
  208. package/dist/output/text/index.js +6 -0
  209. package/dist/output/text/info.js +6 -0
  210. package/dist/output/text/init.js +6 -0
  211. package/dist/output/text/list.js +6 -0
  212. package/dist/output/text/proposal-producer.js +8 -0
  213. package/dist/output/text/proposal.js +12 -0
  214. package/dist/output/text/registry-commands.js +11 -0
  215. package/dist/output/text/registry.js +30 -0
  216. package/dist/output/text/remember.js +6 -0
  217. package/dist/output/text/remove.js +6 -0
  218. package/dist/output/text/save.js +6 -0
  219. package/dist/output/text/search.js +6 -0
  220. package/dist/output/text/show.js +6 -0
  221. package/dist/output/text/update.js +6 -0
  222. package/dist/output/text/upgrade.js +6 -0
  223. package/dist/output/text/vault.js +16 -0
  224. package/dist/output/text/wiki.js +15 -0
  225. package/dist/output/text/workflow.js +14 -0
  226. package/dist/output/text.js +44 -1329
  227. package/dist/registry/build-index.js +3 -0
  228. package/dist/registry/create-provider-registry.js +3 -0
  229. package/dist/registry/factory.js +4 -1
  230. package/dist/registry/origin-resolve.js +3 -0
  231. package/dist/registry/providers/index.js +3 -0
  232. package/dist/registry/providers/skills-sh.js +11 -2
  233. package/dist/registry/providers/static-index.js +10 -1
  234. package/dist/registry/providers/types.js +3 -24
  235. package/dist/registry/resolve.js +11 -16
  236. package/dist/registry/types.js +3 -0
  237. package/dist/scripts/migrate-storage.js +17767 -0
  238. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  239. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  240. package/dist/setup/detect.js +3 -0
  241. package/dist/setup/ripgrep-install.js +3 -0
  242. package/dist/setup/ripgrep-resolve.js +3 -0
  243. package/dist/setup/setup.js +306 -67
  244. package/dist/setup/steps.js +3 -15
  245. package/dist/sources/include.js +3 -0
  246. package/dist/sources/provider-factory.js +3 -11
  247. package/dist/sources/provider.js +3 -20
  248. package/dist/sources/providers/filesystem.js +19 -23
  249. package/dist/sources/providers/git.js +171 -21
  250. package/dist/sources/providers/index.js +3 -0
  251. package/dist/sources/providers/install-types.js +3 -13
  252. package/dist/sources/providers/npm.js +3 -4
  253. package/dist/sources/providers/provider-utils.js +3 -0
  254. package/dist/sources/providers/sync-from-ref.js +3 -11
  255. package/dist/sources/providers/tar-utils.js +3 -0
  256. package/dist/sources/providers/website.js +18 -22
  257. package/dist/sources/resolve.js +3 -0
  258. package/dist/sources/types.js +3 -0
  259. package/dist/sources/website-ingest.js +3 -0
  260. package/dist/tasks/backends/cron.js +3 -0
  261. package/dist/tasks/backends/exec-utils.js +3 -0
  262. package/dist/tasks/backends/index.js +3 -11
  263. package/dist/tasks/backends/launchd.js +3 -0
  264. package/dist/tasks/backends/schtasks.js +3 -0
  265. package/dist/tasks/parser.js +51 -38
  266. package/dist/tasks/resolveAkmBin.js +3 -0
  267. package/dist/tasks/runner.js +35 -9
  268. package/dist/tasks/schedule.js +20 -1
  269. package/dist/tasks/schema.js +5 -3
  270. package/dist/tasks/validator.js +6 -3
  271. package/dist/version.js +3 -0
  272. package/dist/wiki/wiki-templates.js +3 -0
  273. package/dist/wiki/wiki.js +3 -0
  274. package/dist/workflows/authoring.js +3 -0
  275. package/dist/workflows/cli.js +3 -0
  276. package/dist/workflows/db.js +140 -10
  277. package/dist/workflows/document-cache.js +3 -10
  278. package/dist/workflows/parser.js +3 -0
  279. package/dist/workflows/renderer.js +3 -0
  280. package/dist/workflows/runs.js +18 -1
  281. package/dist/workflows/schema.js +3 -0
  282. package/dist/workflows/scope-key.js +3 -0
  283. package/dist/workflows/validator.js +5 -9
  284. package/docs/README.md +7 -2
  285. package/docs/data-and-telemetry.md +225 -0
  286. package/docs/migration/release-notes/0.7.5.md +2 -2
  287. package/docs/migration/release-notes/0.8.0.md +57 -5
  288. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  289. package/package.json +28 -11
  290. package/.github/LICENSE +0 -374
  291. package/dist/commands/install-audit.js +0 -385
  292. package/dist/commands/vault.js +0 -307
  293. package/dist/indexer/match-contributors.js +0 -141
  294. package/dist/integrations/agent/pipeline.js +0 -39
  295. package/dist/integrations/agent/runners.js +0 -31
@@ -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,23 +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, loadUserConfig, saveConfig } from "../core/config";
15
- import { getConfigPath, 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";
16
21
  import { warn } from "../core/warn";
17
22
  import { closeDatabase, isVecAvailable, openDatabase } from "../indexer/db";
18
23
  import { akmIndex } from "../indexer/indexer";
19
24
  import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "../indexer/semantic-status";
20
- import { detectAgentCliProfiles, pickDefaultAgentProfile, } from "../integrations/agent";
25
+ import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../integrations/agent";
21
26
  import { probeLlmCapabilities } from "../llm/client";
22
27
  import { checkEmbeddingAvailability, DEFAULT_LOCAL_MODEL, isTransformersAvailable } from "../llm/embedder";
23
28
  import { detectAgentPlatforms, detectOllama } from "./detect";
24
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
+ }
25
152
  /**
26
153
  * Recommended GitHub repositories shown during setup.
27
154
  */
@@ -122,7 +249,6 @@ function cloneLlmConfig(llm) {
122
249
  return {
123
250
  ...llm,
124
251
  ...(llm.capabilities ? { capabilities: { ...llm.capabilities } } : {}),
125
- ...(llm.features ? { features: { ...llm.features } } : {}),
126
252
  ...(llm.extraParams ? { extraParams: { ...llm.extraParams } } : {}),
127
253
  };
128
254
  }
@@ -202,15 +328,27 @@ async function stepAdditionalSources(currentSources) {
202
328
  return sources;
203
329
  }
204
330
  /**
205
- * Quick connectivity check. Returns true if we can reach a public
206
- * endpoint within 3 seconds, false otherwise. Used to skip network-
207
- * 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."
208
343
  *
209
344
  * @internal Exported for testing only.
210
345
  */
211
346
  export async function isOnline() {
212
347
  try {
213
- 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
+ ]);
214
352
  return true;
215
353
  }
216
354
  catch {
@@ -358,6 +496,14 @@ async function stepStashDir(current, options) {
358
496
  validate: (v) => {
359
497
  if (!v?.trim())
360
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
+ }
361
507
  },
362
508
  }));
363
509
  return customPath.trim();
@@ -492,21 +638,22 @@ export async function stepLlm(current, ollamaEndpoint, ollamaChatModels) {
492
638
  }
493
639
  options.push({ value: "custom", label: "Custom OpenAI-compatible endpoint" });
494
640
  options.push({ value: "none", label: "Skip LLM", hint: "no metadata enhancement during indexing" });
495
- if (current.llm) {
641
+ const currentLlm = getCurrentLlm(current);
642
+ if (currentLlm) {
496
643
  options.push({
497
644
  value: "keep",
498
- label: `Keep current: ${current.llm.provider ?? current.llm.endpoint}`,
499
- hint: current.llm.model,
645
+ label: `Keep current: ${currentLlm.provider ?? currentLlm.endpoint}`,
646
+ hint: currentLlm.model,
500
647
  });
501
648
  }
502
- const initialValue = current.llm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
649
+ const initialValue = currentLlm ? "keep" : ollamaAvailable ? "ollama" : (LLM_PRESETS[0]?.value ?? "none");
503
650
  const choice = await prompt(() => p.select({
504
651
  message: "Configure an LLM for richer metadata during indexing:",
505
652
  options,
506
653
  initialValue,
507
654
  }));
508
655
  if (choice === "keep")
509
- return cloneLlmConfig(current.llm);
656
+ return cloneLlmConfig(currentLlm);
510
657
  if (choice === "none")
511
658
  return undefined;
512
659
  let llm;
@@ -768,21 +915,22 @@ export async function stepSmallModelConnection(current) {
768
915
  });
769
916
  }
770
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" });
771
- if (current.llm) {
918
+ const currentLlmSmall = getCurrentLlm(current);
919
+ if (currentLlmSmall) {
772
920
  providerOptions.push({
773
921
  value: "keep",
774
- label: `Keep current: ${current.llm.provider ?? current.llm.endpoint}`,
775
- hint: current.llm.model,
922
+ label: `Keep current: ${currentLlmSmall.provider ?? currentLlmSmall.endpoint}`,
923
+ hint: currentLlmSmall.model,
776
924
  });
777
925
  }
778
- const initialValue = current.llm ? "keep" : ollama.available ? "ollama" : "openai";
926
+ const initialValue = currentLlmSmall ? "keep" : ollama.available ? "ollama" : "openai";
779
927
  const providerChoice = await prompt(() => p.select({
780
928
  message: "Provider:",
781
929
  options: providerOptions,
782
930
  initialValue,
783
931
  }));
784
932
  if (providerChoice === "keep") {
785
- return { llm: cloneLlmConfig(current.llm), skipped: false, ollamaEndpoint };
933
+ return { llm: cloneLlmConfig(currentLlmSmall), skipped: false, ollamaEndpoint };
786
934
  }
787
935
  if (providerChoice === "skip") {
788
936
  p.note([
@@ -934,11 +1082,12 @@ export async function stepAgentConnection(current, smallModel) {
934
1082
  p.note([
935
1083
  "This connection is used for agentic commands:",
936
1084
  " • akm propose (generate improvement proposals)",
937
- " • akm reflect (reflect on assets and generate proposals)",
1085
+ " • akm improve (run the reflect/distill/consolidate self-improvement pipeline)",
938
1086
  " • akm tasks run (run automated task prompts)",
939
1087
  ].join("\n"));
940
1088
  // Detect available CLI agents.
941
- const detections = detectAgentCliProfiles(current.agent);
1089
+ const detections = detectAgentCliProfiles(current);
1090
+ const currentAgentBlock = getCurrentAgentBlock(current);
942
1091
  const availableClis = detections.filter((d) => d.available);
943
1092
  const agentOptions = [];
944
1093
  if (!smallModel.skipped && smallModel.llm) {
@@ -957,15 +1106,15 @@ export async function stepAgentConnection(current, smallModel) {
957
1106
  });
958
1107
  }
959
1108
  agentOptions.push({ value: "none", label: "None — disable agentic features" });
960
- if (current.agent) {
961
- const currentDesc = current.agent.default
962
- ? `CLI: ${current.agent.default}`
963
- : current.agent.profiles?.default?.model
964
- ? `SDK: ${current.agent.profiles.default.model}`
1109
+ if (currentAgentBlock) {
1110
+ const currentDesc = currentAgentBlock.default
1111
+ ? `CLI: ${currentAgentBlock.default}`
1112
+ : currentAgentBlock.profiles?.default?.model
1113
+ ? `SDK: ${currentAgentBlock.profiles.default.model}`
965
1114
  : "configured";
966
1115
  agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
967
1116
  }
968
- const initialAgentValue = current.agent
1117
+ const initialAgentValue = currentAgentBlock
969
1118
  ? "keep"
970
1119
  : availableClis.length > 0 && smallModel.skipped
971
1120
  ? "cli-agent"
@@ -980,13 +1129,13 @@ export async function stepAgentConnection(current, smallModel) {
980
1129
  initialValue: initialAgentValue,
981
1130
  }));
982
1131
  if (agentChoice === "keep") {
983
- return current.agent;
1132
+ return currentAgentBlock;
984
1133
  }
985
1134
  if (agentChoice === "none") {
986
1135
  p.note([
987
1136
  "Agentic features disabled:",
988
1137
  ' • akm propose — will show "no agent configured" error',
989
- ' • akm reflect — will show "no agent configured" error',
1138
+ ' • akm improve — will show "no agent configured" error',
990
1139
  ' • akm tasks run — will show "no agent configured" error',
991
1140
  "",
992
1141
  "You can configure this later with `akm setup`.",
@@ -1008,11 +1157,11 @@ export async function stepAgentConnection(current, smallModel) {
1008
1157
  }));
1009
1158
  const profileName = smallModel.llm.provider ?? "default";
1010
1159
  return {
1011
- ...(current.agent ?? {}),
1160
+ ...(currentAgentBlock ?? {}),
1012
1161
  profiles: {
1013
- ...(current.agent?.profiles ?? {}),
1162
+ ...(currentAgentBlock?.profiles ?? {}),
1014
1163
  [profileName]: {
1015
- ...(current.agent?.profiles?.[profileName] ?? {}),
1164
+ ...(currentAgentBlock?.profiles?.[profileName] ?? {}),
1016
1165
  sdkMode: true,
1017
1166
  model: agentModel.trim(),
1018
1167
  endpoint: smallModel.llm.endpoint,
@@ -1025,9 +1174,9 @@ export async function stepAgentConnection(current, smallModel) {
1025
1174
  if (agentChoice === "cli-agent") {
1026
1175
  if (availableClis.length === 0) {
1027
1176
  p.log.warn("No agent CLIs detected on PATH.");
1028
- return current.agent;
1177
+ return currentAgentBlock;
1029
1178
  }
1030
- const initialCli = pickDefaultAgentProfile(detections, current.agent?.default) ?? availableClis[0]?.name;
1179
+ const initialCli = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? availableClis[0]?.name;
1031
1180
  const selectedCli = await prompt(() => p.select({
1032
1181
  message: "Which CLI agent?",
1033
1182
  options: availableClis.map((d) => ({
@@ -1038,7 +1187,7 @@ export async function stepAgentConnection(current, smallModel) {
1038
1187
  initialValue: initialCli,
1039
1188
  }));
1040
1189
  return {
1041
- ...(current.agent ?? {}),
1190
+ ...(currentAgentBlock ?? {}),
1042
1191
  default: selectedCli,
1043
1192
  };
1044
1193
  }
@@ -1069,9 +1218,9 @@ export async function stepAgentConnection(current, smallModel) {
1069
1218
  ...(newApiKeyInput?.trim() ? { apiKey: newApiKeyInput.trim() } : {}),
1070
1219
  };
1071
1220
  return {
1072
- ...(current.agent ?? {}),
1221
+ ...(currentAgentBlock ?? {}),
1073
1222
  profiles: {
1074
- ...(current.agent?.profiles ?? {}),
1223
+ ...(currentAgentBlock?.profiles ?? {}),
1075
1224
  custom: customProfile,
1076
1225
  },
1077
1226
  default: "custom",
@@ -1090,19 +1239,20 @@ function printCapabilitySummary(smallModelSkipped, agentConfigured) {
1090
1239
  lines.push(" ✗ akm index, akm distill, akm remember — run `akm setup` to enable");
1091
1240
  }
1092
1241
  if (agentConfigured) {
1093
- lines.push(" ✓ akm propose, akm reflect, akm tasks — agent configured");
1242
+ lines.push(" ✓ akm propose, akm improve, akm tasks — agent configured");
1094
1243
  }
1095
1244
  else {
1096
- lines.push(" ✗ akm propose, akm reflect, akm tasks — run `akm setup` to enable");
1245
+ lines.push(" ✗ akm propose, akm improve, akm tasks — run `akm setup` to enable");
1097
1246
  }
1098
1247
  p.note(lines.join("\n"), "Feature Summary");
1099
1248
  }
1100
1249
  export async function stepAgentSelection(current, detections) {
1250
+ const currentAgentBlock = getCurrentAgentBlock(current);
1101
1251
  const available = detections.filter((d) => d.available);
1102
1252
  if (available.length === 0) {
1103
- return current.agent;
1253
+ return currentAgentBlock;
1104
1254
  }
1105
- const initialValue = pickDefaultAgentProfile(detections, current.agent?.default) ?? available[0]?.name;
1255
+ const initialValue = pickDefaultAgentProfile(detections, currentAgentBlock?.default) ?? available[0]?.name;
1106
1256
  const selectedDefault = await prompt(() => p.select({
1107
1257
  message: "Which detected agent CLI should be the default?",
1108
1258
  options: [
@@ -1116,16 +1266,16 @@ export async function stepAgentSelection(current, detections) {
1116
1266
  initialValue,
1117
1267
  }));
1118
1268
  if (selectedDefault === "disabled") {
1119
- if (!current.agent?.profiles && !current.agent?.timeoutMs) {
1269
+ if (!currentAgentBlock?.profiles && !currentAgentBlock?.timeoutMs) {
1120
1270
  return undefined;
1121
1271
  }
1122
1272
  return {
1123
- ...(current.agent ?? {}),
1273
+ ...(currentAgentBlock ?? {}),
1124
1274
  default: undefined,
1125
1275
  };
1126
1276
  }
1127
1277
  return {
1128
- ...(current.agent ?? {}),
1278
+ ...(currentAgentBlock ?? {}),
1129
1279
  default: selectedDefault,
1130
1280
  };
1131
1281
  }
@@ -1162,14 +1312,15 @@ export async function stepOutputConfig(current) {
1162
1312
  * @internal Exported for testing only.
1163
1313
  */
1164
1314
  export function stepAgentCliDetection(current, detectFn = detectAgentCliProfiles) {
1165
- const detections = detectFn(current.agent);
1166
- const defaultName = pickDefaultAgentProfile(detections, current.agent?.default);
1315
+ const detections = detectFn(current);
1316
+ const currentAgentBlock = getCurrentAgentBlock(current);
1317
+ const defaultName = pickDefaultAgentProfile(detections, currentAgentBlock?.default);
1167
1318
  // No installed agents found and no existing config → leave block absent.
1168
- if (!defaultName && !current.agent) {
1319
+ if (!defaultName && !currentAgentBlock) {
1169
1320
  return { detections };
1170
1321
  }
1171
1322
  const agent = {
1172
- ...(current.agent ?? {}),
1323
+ ...(currentAgentBlock ?? {}),
1173
1324
  ...(defaultName ? { default: defaultName } : {}),
1174
1325
  };
1175
1326
  return { agent, detections };
@@ -1221,11 +1372,10 @@ export function buildSetupSteps(options) {
1221
1372
  label: "LLM Provider",
1222
1373
  async run(ctx) {
1223
1374
  if (!options.online) {
1224
- ctx.apply({ llm: ctx.config.llm });
1225
1375
  return;
1226
1376
  }
1227
1377
  const llm = await stepLlm(ctx.config, ollamaEndpoint, ollamaChatModels);
1228
- ctx.apply({ llm });
1378
+ ctx.apply(applyLegacyLlm(ctx.config, llm));
1229
1379
  },
1230
1380
  },
1231
1381
  {
@@ -1273,8 +1423,11 @@ export function buildSetupSteps(options) {
1273
1423
  else {
1274
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.");
1275
1425
  }
1276
- const agent = await stepAgentSelection({ ...ctx.config, agent: result.agent }, result.detections);
1277
- 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));
1278
1431
  },
1279
1432
  },
1280
1433
  {
@@ -1294,6 +1447,10 @@ export async function runSetupWizard(opts) {
1294
1447
  const configPath = getConfigPath();
1295
1448
  // Resolve stash directory early so akmInit can run before any prompts
1296
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);
1297
1454
  // Bootstrap directory structure before any prompts so the stash exists
1298
1455
  // even if the wizard is interrupted after this point.
1299
1456
  if (!opts?.noInit) {
@@ -1326,11 +1483,11 @@ export async function runSetupWizard(opts) {
1326
1483
  // Step 1/2: Small model connection (for enrichment features)
1327
1484
  const smallModelResult = await stepSmallModelConnection(ctx.config);
1328
1485
  if (!smallModelResult.skipped) {
1329
- ctx.apply({ llm: smallModelResult.llm });
1486
+ ctx.apply(applyLegacyLlm(ctx.config, smallModelResult.llm));
1330
1487
  }
1331
1488
  // Step 2/2: Agent connection (for agentic features)
1332
1489
  const agentConfig = await stepAgentConnection(ctx.config, smallModelResult);
1333
- ctx.apply({ agent: agentConfig });
1490
+ ctx.apply(applyLegacyAgent(ctx.config, agentConfig));
1334
1491
  const newConfig = {
1335
1492
  ...ctx.config,
1336
1493
  // Preserve fields the steps don't manage explicitly.
@@ -1339,7 +1496,7 @@ export async function runSetupWizard(opts) {
1339
1496
  const semanticSearchMode = outcome.semantic;
1340
1497
  const stashDir = newConfig.stashDir ?? current.stashDir ?? getDefaultStashDir();
1341
1498
  const embedding = newConfig.embedding;
1342
- const llm = newConfig.llm;
1499
+ const llm = getDefaultLlmConfig(newConfig);
1343
1500
  const registries = newConfig.registries;
1344
1501
  const allStashes = newConfig.sources ?? [];
1345
1502
  // Feature capability summary
@@ -1354,7 +1511,7 @@ export async function runSetupWizard(opts) {
1354
1511
  `Semantic search: ${semanticSearchMode.mode}`,
1355
1512
  `Registries: ${effectiveRegistries.filter((r) => r.enabled !== false).length} enabled`,
1356
1513
  `Stash sources: ${allStashes.length}`,
1357
- `Agent default: ${newConfig.agent?.default ?? "disabled"}`,
1514
+ `Agent default: ${newConfig.defaults?.agent ?? "disabled"}`,
1358
1515
  `Output: ${newConfig.output?.format ?? "json"} / ${newConfig.output?.detail ?? "brief"}`,
1359
1516
  ].join("\n"), "Configuration Summary");
1360
1517
  const shouldSave = await prompt(() => p.confirm({
@@ -1454,6 +1611,8 @@ export async function runSetupWizard(opts) {
1454
1611
  export async function runSetupWithDefaults(opts) {
1455
1612
  const current = loadUserConfig();
1456
1613
  const stashDir = opts.dir ? path.resolve(opts.dir) : (current.stashDir ?? getDefaultStashDir());
1614
+ assertSetupSandbox(stashDir, opts.dir != null);
1615
+ applyStashIsolationToEnv(stashDir, opts.dir != null);
1457
1616
  // Bootstrap directory structure first
1458
1617
  let initResult;
1459
1618
  if (!opts.noInit) {
@@ -1471,11 +1630,11 @@ export async function runSetupWithDefaults(opts) {
1471
1630
  if (!ctx.config.stashDir)
1472
1631
  ctx.apply({ stashDir });
1473
1632
  // Auto-detect agent CLI if not already configured
1474
- if (!ctx.config.agent) {
1633
+ if (!ctx.config.defaults?.agent) {
1475
1634
  const detected = detectAgentCliProfiles(undefined);
1476
1635
  const defaultProfile = pickDefaultAgentProfile(detected, undefined);
1477
1636
  if (defaultProfile) {
1478
- ctx.apply({ agent: { default: defaultProfile } });
1637
+ ctx.apply(applyLegacyAgent(ctx.config, { default: defaultProfile }));
1479
1638
  }
1480
1639
  }
1481
1640
  saveConfig(ctx.config);
@@ -1493,7 +1652,6 @@ export async function runSetupWithDefaults(opts) {
1493
1652
  * Validates required sub-fields and strips unknown/restricted keys.
1494
1653
  */
1495
1654
  export async function runSetupFromConfig(opts) {
1496
- // Phase 1: Parse JSON
1497
1655
  let incoming;
1498
1656
  try {
1499
1657
  incoming = JSON.parse(opts.configJson);
@@ -1502,7 +1660,16 @@ export async function runSetupFromConfig(opts) {
1502
1660
  throw new Error(`Invalid JSON in --config: ${e.message}`);
1503
1661
  }
1504
1662
  // Phase 2: Validate — only allow safe top-level keys
1505
- const ALLOWED_KEYS = new Set(["stashDir", "llm", "embedding", "agent", "semanticSearchMode", "output"]);
1663
+ const ALLOWED_KEYS = new Set([
1664
+ "stashDir",
1665
+ "llm",
1666
+ "embedding",
1667
+ "agent",
1668
+ "semanticSearchMode",
1669
+ "output",
1670
+ "profiles",
1671
+ "defaults",
1672
+ ]);
1506
1673
  for (const key of Object.keys(incoming)) {
1507
1674
  if (!ALLOWED_KEYS.has(key)) {
1508
1675
  warn(`[akm setup] Ignoring unknown or restricted config key: "${key}"`);
@@ -1529,22 +1696,42 @@ export async function runSetupFromConfig(opts) {
1529
1696
  : incoming.stashDir
1530
1697
  ? path.resolve(incoming.stashDir)
1531
1698
  : (current.stashDir ?? getDefaultStashDir());
1532
- const merged = {
1533
- ...current,
1534
- ...incoming,
1535
- stashDir,
1536
- };
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
+ }
1537
1717
  // Bootstrap directory structure
1538
1718
  let initResult;
1539
1719
  if (!opts.noInit) {
1540
1720
  initResult = await akmInit({ dir: stashDir });
1541
1721
  }
1542
1722
  // Optional probe
1543
- if (opts.probe && merged.llm) {
1723
+ const mergedLlm = getDefaultLlmConfig(merged);
1724
+ if (opts.probe && mergedLlm) {
1544
1725
  try {
1545
- const caps = await probeLlmCapabilities(merged.llm);
1726
+ const caps = await probeLlmCapabilities(mergedLlm);
1546
1727
  if (caps.reachable) {
1547
- merged.llm = { ...merged.llm, capabilities: { structuredOutput: caps.structuredOutput ?? false } };
1728
+ merged = {
1729
+ ...merged,
1730
+ ...applyLegacyLlm(merged, {
1731
+ ...mergedLlm,
1732
+ capabilities: { structuredOutput: caps.structuredOutput ?? false },
1733
+ }),
1734
+ };
1548
1735
  }
1549
1736
  }
1550
1737
  catch {
@@ -1561,3 +1748,55 @@ export async function runSetupFromConfig(opts) {
1561
1748
  ripgrep: initResult?.ripgrep,
1562
1749
  };
1563
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
+ }