akm-cli 0.6.1 → 0.7.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 (333) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{cli.js → src/cli.js} +712 -34
  3. package/dist/{commands → src/commands}/config-cli.js +47 -4
  4. package/dist/src/commands/distill.js +283 -0
  5. package/dist/src/commands/events.js +108 -0
  6. package/dist/src/commands/history.js +191 -0
  7. package/dist/{commands → src/commands}/installed-stashes.js +1 -1
  8. package/dist/src/commands/proposal.js +119 -0
  9. package/dist/src/commands/propose.js +171 -0
  10. package/dist/src/commands/reflect.js +193 -0
  11. package/dist/{commands → src/commands}/registry-search.js +71 -7
  12. package/dist/{commands → src/commands}/remember.js +12 -0
  13. package/dist/{commands → src/commands}/search.js +104 -4
  14. package/dist/{commands → src/commands}/self-update.js +4 -3
  15. package/dist/{commands → src/commands}/show.js +73 -0
  16. package/dist/{commands → src/commands}/source-add.js +5 -1
  17. package/dist/{commands → src/commands}/source-manage.js +7 -1
  18. package/dist/{core → src/core}/asset-ref.js +5 -5
  19. package/dist/{core → src/core}/asset-spec.js +12 -0
  20. package/dist/{core → src/core}/common.js +1 -1
  21. package/dist/{core → src/core}/config.js +203 -121
  22. package/dist/{core → src/core}/errors.js +4 -0
  23. package/dist/src/core/events.js +239 -0
  24. package/dist/src/core/lesson-lint.js +86 -0
  25. package/dist/src/core/proposals.js +406 -0
  26. package/dist/src/core/warn.js +72 -0
  27. package/dist/{core → src/core}/write-source.js +80 -5
  28. package/dist/{indexer → src/indexer}/db-search.js +114 -24
  29. package/dist/{indexer → src/indexer}/db.js +76 -23
  30. package/dist/{indexer → src/indexer}/file-context.js +0 -3
  31. package/dist/src/indexer/graph-boost.js +179 -0
  32. package/dist/src/indexer/graph-extraction.js +212 -0
  33. package/dist/{indexer → src/indexer}/indexer.js +88 -7
  34. package/dist/{indexer → src/indexer}/matchers.js +1 -1
  35. package/dist/src/indexer/memory-inference.js +263 -0
  36. package/dist/{indexer → src/indexer}/metadata.js +111 -3
  37. package/dist/{indexer → src/indexer}/search-source.js +4 -2
  38. package/dist/src/integrations/agent/config.js +292 -0
  39. package/dist/src/integrations/agent/detect.js +94 -0
  40. package/dist/src/integrations/agent/index.js +17 -0
  41. package/dist/src/integrations/agent/profiles.js +65 -0
  42. package/dist/src/integrations/agent/prompts.js +167 -0
  43. package/dist/src/integrations/agent/spawn.js +272 -0
  44. package/dist/{integrations → src/integrations}/github.js +9 -3
  45. package/dist/{integrations → src/integrations}/lockfile.js +0 -26
  46. package/dist/{llm → src/llm}/client.js +33 -2
  47. package/dist/{llm → src/llm}/embedders/remote.js +37 -3
  48. package/dist/src/llm/feature-gate.js +108 -0
  49. package/dist/src/llm/graph-extract.js +107 -0
  50. package/dist/src/llm/index-passes.js +35 -0
  51. package/dist/src/llm/memory-infer.js +86 -0
  52. package/dist/{output → src/output}/cli-hints.js +15 -2
  53. package/dist/{output → src/output}/renderers.js +63 -2
  54. package/dist/src/output/shapes.js +523 -0
  55. package/dist/src/output/text.js +1116 -0
  56. package/dist/{registry → src/registry}/build-index.js +19 -8
  57. package/dist/{registry → src/registry}/factory.js +0 -8
  58. package/dist/{registry → src/registry}/providers/static-index.js +6 -3
  59. package/dist/{registry → src/registry}/resolve.js +68 -2
  60. package/dist/{setup → src/setup}/setup.js +52 -5
  61. package/dist/{sources → src/sources}/providers/git.js +7 -15
  62. package/dist/{wiki → src/wiki}/wiki.js +54 -6
  63. package/dist/{workflows → src/workflows}/runs.js +37 -3
  64. package/dist/tests/add-website-source.test.js +119 -0
  65. package/dist/tests/agent/agent-config-loader.test.js +70 -0
  66. package/dist/tests/agent/agent-config.test.js +221 -0
  67. package/dist/tests/agent/agent-detect.test.js +100 -0
  68. package/dist/tests/agent/agent-spawn.test.js +234 -0
  69. package/dist/tests/agent-output.test.js +186 -0
  70. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
  71. package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
  72. package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
  73. package/dist/tests/asset-ref.test.js +192 -0
  74. package/dist/tests/asset-registry.test.js +103 -0
  75. package/dist/tests/asset-spec.test.js +241 -0
  76. package/dist/tests/bench/attribution.test.js +996 -0
  77. package/dist/tests/bench/cleanup-sigint.test.js +83 -0
  78. package/dist/tests/bench/cleanup.js +234 -0
  79. package/dist/tests/bench/cleanup.test.js +166 -0
  80. package/dist/tests/bench/cli.js +1018 -0
  81. package/dist/tests/bench/cli.test.js +445 -0
  82. package/dist/tests/bench/compare.test.js +556 -0
  83. package/dist/tests/bench/corpus.js +317 -0
  84. package/dist/tests/bench/corpus.test.js +258 -0
  85. package/dist/tests/bench/doctor.js +525 -0
  86. package/dist/tests/bench/driver.js +401 -0
  87. package/dist/tests/bench/driver.test.js +584 -0
  88. package/dist/tests/bench/environment.js +233 -0
  89. package/dist/tests/bench/environment.test.js +199 -0
  90. package/dist/tests/bench/evolve-metrics.js +179 -0
  91. package/dist/tests/bench/evolve-metrics.test.js +187 -0
  92. package/dist/tests/bench/evolve.js +647 -0
  93. package/dist/tests/bench/evolve.test.js +624 -0
  94. package/dist/tests/bench/failure-modes.test.js +349 -0
  95. package/dist/tests/bench/feedback-integrity.test.js +457 -0
  96. package/dist/tests/bench/leakage.test.js +228 -0
  97. package/dist/tests/bench/learning-curve.test.js +134 -0
  98. package/dist/tests/bench/metrics.js +2395 -0
  99. package/dist/tests/bench/metrics.test.js +1150 -0
  100. package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
  101. package/dist/tests/bench/opencode-config.js +194 -0
  102. package/dist/tests/bench/opencode-config.test.js +370 -0
  103. package/dist/tests/bench/report.js +1885 -0
  104. package/dist/tests/bench/report.test.js +1038 -0
  105. package/dist/tests/bench/run-config.js +355 -0
  106. package/dist/tests/bench/run-config.test.js +298 -0
  107. package/dist/tests/bench/run-curate-test.js +32 -0
  108. package/dist/tests/bench/run-failing-tasks.js +56 -0
  109. package/dist/tests/bench/run-full-bench.js +51 -0
  110. package/dist/tests/bench/run-items36-targeted.js +69 -0
  111. package/dist/tests/bench/run-nano-quick.js +42 -0
  112. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  113. package/dist/tests/bench/runner.js +699 -0
  114. package/dist/tests/bench/runner.test.js +958 -0
  115. package/dist/tests/bench/search-bridge.test.js +331 -0
  116. package/dist/tests/bench/tmp.js +131 -0
  117. package/dist/tests/bench/trajectory.js +116 -0
  118. package/dist/tests/bench/trajectory.test.js +127 -0
  119. package/dist/tests/bench/verifier.js +114 -0
  120. package/dist/tests/bench/verifier.test.js +118 -0
  121. package/dist/tests/bench/workflow-evaluator.js +557 -0
  122. package/dist/tests/bench/workflow-evaluator.test.js +421 -0
  123. package/dist/tests/bench/workflow-spec.js +345 -0
  124. package/dist/tests/bench/workflow-spec.test.js +363 -0
  125. package/dist/tests/bench/workflow-trace.js +472 -0
  126. package/dist/tests/bench/workflow-trace.test.js +254 -0
  127. package/dist/tests/benchmark-search-quality.js +536 -0
  128. package/dist/tests/benchmark-suite.js +1441 -0
  129. package/dist/tests/capture-cli.test.js +112 -0
  130. package/dist/tests/cli-errors.test.js +204 -0
  131. package/dist/tests/commands/events.test.js +370 -0
  132. package/dist/tests/commands/history.test.js +418 -0
  133. package/dist/tests/commands/import.test.js +103 -0
  134. package/dist/tests/commands/proposal-cli.test.js +209 -0
  135. package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
  136. package/dist/tests/commands/remember.test.js +97 -0
  137. package/dist/tests/commands/scope-flags.test.js +300 -0
  138. package/dist/tests/commands/search.test.js +537 -0
  139. package/dist/tests/commands/show-indexer-parity.test.js +117 -0
  140. package/dist/tests/commands/show.test.js +294 -0
  141. package/dist/tests/common.test.js +266 -0
  142. package/dist/tests/completions.test.js +142 -0
  143. package/dist/tests/config-cli.test.js +193 -0
  144. package/dist/tests/config-llm-features.test.js +139 -0
  145. package/dist/tests/config.test.js +569 -0
  146. package/dist/tests/contracts/migration-baseline.test.js +43 -0
  147. package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
  148. package/dist/tests/contracts/spec-helpers.js +46 -0
  149. package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
  150. package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
  151. package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
  152. package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
  153. package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
  154. package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
  155. package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
  156. package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
  157. package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
  158. package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
  159. package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
  160. package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
  161. package/dist/tests/core/write-source.test.js +366 -0
  162. package/dist/tests/curate-command.test.js +87 -0
  163. package/dist/tests/db-scoring.test.js +201 -0
  164. package/dist/tests/db.test.js +654 -0
  165. package/dist/tests/distill-cli-flag.test.js +208 -0
  166. package/dist/tests/distill.test.js +515 -0
  167. package/dist/tests/docker-install.test.js +120 -0
  168. package/dist/tests/e2e.test.js +1419 -0
  169. package/dist/tests/embedder.test.js +340 -0
  170. package/dist/tests/embedding-model-config.test.js +379 -0
  171. package/dist/tests/feedback-command.test.js +172 -0
  172. package/dist/tests/file-context.test.js +552 -0
  173. package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
  174. package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
  175. package/dist/tests/fixtures/stashes/load.js +166 -0
  176. package/dist/tests/fixtures/stashes/load.test.js +97 -0
  177. package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
  178. package/dist/tests/frontmatter.test.js +190 -0
  179. package/dist/tests/fts-field-weighting.test.js +254 -0
  180. package/dist/tests/fuzzy-search.test.js +230 -0
  181. package/dist/tests/git-provider-clone.test.js +45 -0
  182. package/dist/tests/github.test.js +161 -0
  183. package/dist/tests/graph-boost-ranking.test.js +305 -0
  184. package/dist/tests/graph-extraction.test.js +282 -0
  185. package/dist/tests/helpers/usage-events.js +8 -0
  186. package/dist/tests/index-pass-llm.test.js +161 -0
  187. package/dist/tests/indexer.test.js +570 -0
  188. package/dist/tests/info-command.test.js +166 -0
  189. package/dist/tests/init.test.js +69 -0
  190. package/dist/tests/install-script.test.js +246 -0
  191. package/dist/tests/integration/agent-real-profile.test.js +94 -0
  192. package/dist/tests/issue-36-repro.test.js +304 -0
  193. package/dist/tests/issues-191-194.test.js +160 -0
  194. package/dist/tests/lesson-lint.test.js +111 -0
  195. package/dist/tests/llm-client.test.js +115 -0
  196. package/dist/tests/llm-feature-gate.test.js +151 -0
  197. package/dist/tests/llm.test.js +139 -0
  198. package/dist/tests/lockfile.test.js +216 -0
  199. package/dist/tests/manifest.test.js +205 -0
  200. package/dist/tests/markdown.test.js +126 -0
  201. package/dist/tests/matchers-unit.test.js +189 -0
  202. package/dist/tests/memory-inference.test.js +299 -0
  203. package/dist/tests/merge-scoring.test.js +136 -0
  204. package/dist/tests/metadata.test.js +313 -0
  205. package/dist/tests/migration-help.test.js +89 -0
  206. package/dist/tests/origin-resolve.test.js +124 -0
  207. package/dist/tests/output-baseline.test.js +218 -0
  208. package/dist/tests/output-shapes-unit.test.js +478 -0
  209. package/dist/tests/parallel-search.test.js +272 -0
  210. package/dist/tests/parameter-metadata.test.js +365 -0
  211. package/dist/tests/paths.test.js +177 -0
  212. package/dist/tests/progressive-disclosure.test.js +280 -0
  213. package/dist/tests/proposals.test.js +279 -0
  214. package/dist/tests/proposed-quality.test.js +271 -0
  215. package/dist/tests/provider-registry.test.js +32 -0
  216. package/dist/tests/ranking-regression.test.js +548 -0
  217. package/dist/tests/reflect-propose.test.js +455 -0
  218. package/dist/tests/registry-build-index.test.js +394 -0
  219. package/dist/tests/registry-cli.test.js +290 -0
  220. package/dist/tests/registry-index-v2.test.js +430 -0
  221. package/dist/tests/registry-install.test.js +728 -0
  222. package/dist/tests/registry-providers/parity.test.js +189 -0
  223. package/dist/tests/registry-providers/skills-sh.test.js +309 -0
  224. package/dist/tests/registry-providers/static-index.test.js +238 -0
  225. package/dist/tests/registry-resolve.test.js +126 -0
  226. package/dist/tests/registry-search.test.js +923 -0
  227. package/dist/tests/remember-frontmatter.test.js +378 -0
  228. package/dist/tests/remember-unit.test.js +123 -0
  229. package/dist/tests/ripgrep-install.test.js +251 -0
  230. package/dist/tests/ripgrep-resolve.test.js +108 -0
  231. package/dist/tests/ripgrep.test.js +163 -0
  232. package/dist/tests/save-command.test.js +94 -0
  233. package/dist/tests/save-trust-qa-fixes.test.js +270 -0
  234. package/dist/tests/scoring-pipeline.test.js +648 -0
  235. package/dist/tests/search-include-proposed-cli.test.js +118 -0
  236. package/dist/tests/self-update.test.js +442 -0
  237. package/dist/tests/semantic-search-e2e.test.js +512 -0
  238. package/dist/tests/semantic-status.test.js +471 -0
  239. package/dist/tests/setup-run.integration.js +877 -0
  240. package/dist/tests/setup-wizard.test.js +198 -0
  241. package/dist/tests/setup.test.js +131 -0
  242. package/dist/tests/source-add.test.js +11 -0
  243. package/dist/tests/source-clone.test.js +254 -0
  244. package/dist/tests/source-manage.test.js +366 -0
  245. package/dist/tests/source-providers/filesystem.test.js +82 -0
  246. package/dist/tests/source-providers/git.test.js +252 -0
  247. package/dist/tests/source-providers/website.test.js +128 -0
  248. package/dist/tests/source-qa-fixes.test.js +286 -0
  249. package/dist/tests/source-registry.test.js +350 -0
  250. package/dist/tests/source-resolve.test.js +100 -0
  251. package/dist/tests/source-source.test.js +281 -0
  252. package/dist/tests/source.test.js +533 -0
  253. package/dist/tests/tar-utils-scan.test.js +73 -0
  254. package/dist/tests/toggle-components.test.js +73 -0
  255. package/dist/tests/usage-telemetry.test.js +265 -0
  256. package/dist/tests/utility-scoring.test.js +558 -0
  257. package/dist/tests/vault-load-error.test.js +78 -0
  258. package/dist/tests/vault-qa-fixes.test.js +194 -0
  259. package/dist/tests/vault.test.js +429 -0
  260. package/dist/tests/vector-search.test.js +608 -0
  261. package/dist/tests/walker.test.js +252 -0
  262. package/dist/tests/wave2-cluster-bc.test.js +228 -0
  263. package/dist/tests/wave2-cluster-d.test.js +180 -0
  264. package/dist/tests/wave2-cluster-e.test.js +179 -0
  265. package/dist/tests/wiki-qa-fixes.test.js +270 -0
  266. package/dist/tests/wiki.test.js +529 -0
  267. package/dist/tests/workflow-cli.test.js +271 -0
  268. package/dist/tests/workflow-markdown.test.js +171 -0
  269. package/dist/tests/workflow-path-escape.test.js +132 -0
  270. package/dist/tests/workflow-qa-fixes.test.js +395 -0
  271. package/dist/tests/workflows/indexer-rejection.test.js +213 -0
  272. package/docs/README.md +8 -0
  273. package/docs/migration/release-notes/0.7.0.md +244 -0
  274. package/package.json +2 -2
  275. package/dist/core/warn.js +0 -27
  276. package/dist/output/shapes.js +0 -212
  277. package/dist/output/text.js +0 -520
  278. /package/dist/{commands → src/commands}/completions.js +0 -0
  279. /package/dist/{commands → src/commands}/curate.js +0 -0
  280. /package/dist/{commands → src/commands}/info.js +0 -0
  281. /package/dist/{commands → src/commands}/init.js +0 -0
  282. /package/dist/{commands → src/commands}/install-audit.js +0 -0
  283. /package/dist/{commands → src/commands}/migration-help.js +0 -0
  284. /package/dist/{commands → src/commands}/source-clone.js +0 -0
  285. /package/dist/{commands → src/commands}/vault.js +0 -0
  286. /package/dist/{core → src/core}/asset-registry.js +0 -0
  287. /package/dist/{core → src/core}/frontmatter.js +0 -0
  288. /package/dist/{core → src/core}/markdown.js +0 -0
  289. /package/dist/{core → src/core}/paths.js +0 -0
  290. /package/dist/{indexer → src/indexer}/manifest.js +0 -0
  291. /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
  292. /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
  293. /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
  294. /package/dist/{indexer → src/indexer}/walker.js +0 -0
  295. /package/dist/{llm → src/llm}/embedder.js +0 -0
  296. /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
  297. /package/dist/{llm → src/llm}/embedders/local.js +0 -0
  298. /package/dist/{llm → src/llm}/embedders/types.js +0 -0
  299. /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
  300. /package/dist/{output → src/output}/context.js +0 -0
  301. /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
  302. /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
  303. /package/dist/{registry → src/registry}/providers/index.js +0 -0
  304. /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
  305. /package/dist/{registry → src/registry}/providers/types.js +0 -0
  306. /package/dist/{registry → src/registry}/types.js +0 -0
  307. /package/dist/{setup → src/setup}/detect.js +0 -0
  308. /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
  309. /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
  310. /package/dist/{setup → src/setup}/steps.js +0 -0
  311. /package/dist/{sources → src/sources}/include.js +0 -0
  312. /package/dist/{sources → src/sources}/provider-factory.js +0 -0
  313. /package/dist/{sources → src/sources}/provider.js +0 -0
  314. /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
  315. /package/dist/{sources → src/sources}/providers/index.js +0 -0
  316. /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
  317. /package/dist/{sources → src/sources}/providers/npm.js +0 -0
  318. /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
  319. /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
  320. /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
  321. /package/dist/{sources → src/sources}/providers/website.js +0 -0
  322. /package/dist/{sources → src/sources}/resolve.js +0 -0
  323. /package/dist/{sources → src/sources}/types.js +0 -0
  324. /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
  325. /package/dist/{version.js → src/version.js} +0 -0
  326. /package/dist/{workflows → src/workflows}/authoring.js +0 -0
  327. /package/dist/{workflows → src/workflows}/cli.js +0 -0
  328. /package/dist/{workflows → src/workflows}/db.js +0 -0
  329. /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
  330. /package/dist/{workflows → src/workflows}/parser.js +0 -0
  331. /package/dist/{workflows → src/workflows}/renderer.js +0 -0
  332. /package/dist/{workflows → src/workflows}/schema.js +0 -0
  333. /package/dist/{workflows → src/workflows}/validator.js +0 -0
@@ -0,0 +1,1419 @@
1
+ /**
2
+ * End-to-end tests that replicate real-world usage of akm.
3
+ *
4
+ * Uses realistic fixtures in tests/fixtures/ representing a typical user's
5
+ * stash directory with scripts, skills, commands, and agents.
6
+ *
7
+ * Tests cover:
8
+ * - Full lifecycle: index → search → show
9
+ * - CLI interface via subprocess
10
+ * - Metadata generation and persistence
11
+ * - Semantic search ranking quality
12
+ * - Ripgrep pre-filtering
13
+ * - Multi-script directories with .stash.json
14
+ * - Graceful degradation (no index, no ripgrep)
15
+ * - Edge cases and error handling
16
+ */
17
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { spawnSync } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { akmSearch } from "../src/commands/search";
23
+ import { akmShowUnified as akmShow } from "../src/commands/show";
24
+ import { loadConfig, saveConfig } from "../src/core/config";
25
+ import { closeDatabase, DB_VERSION, getAllEntries, getMeta, openDatabase } from "../src/indexer/db";
26
+ import { akmIndex } from "../src/indexer/indexer";
27
+ import { loadStashFile } from "../src/indexer/metadata";
28
+ // ── Helpers ─────────────────────────────────────────────────────────────────
29
+ const FIXTURES = path.join(__dirname, "fixtures");
30
+ const CLI = path.join(__dirname, "..", "src", "cli.ts");
31
+ function expectDefined(value) {
32
+ expect(value).toBeDefined();
33
+ if (value === undefined || value === null) {
34
+ throw new Error("Expected value to be defined");
35
+ }
36
+ return value;
37
+ }
38
+ function isLocalHit(hit) {
39
+ return hit.type !== "registry";
40
+ }
41
+ function copyFixturesToTmp() {
42
+ const tmpStash = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-"));
43
+ copyDirRecursive(FIXTURES, tmpStash);
44
+ return tmpStash;
45
+ }
46
+ // `tests/fixtures/stashes/` is the shared fixture-stash tree (issue #235); it
47
+ // must not be copied into the per-scenario e2e fixture root or its assets
48
+ // will leak into search results.
49
+ const E2E_FIXTURE_SKIP_AT_ROOT = new Set(["stashes"]);
50
+ function copyDirRecursive(src, dest, depth = 0) {
51
+ fs.mkdirSync(dest, { recursive: true });
52
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
53
+ if (depth === 0 && E2E_FIXTURE_SKIP_AT_ROOT.has(entry.name))
54
+ continue;
55
+ const srcPath = path.join(src, entry.name);
56
+ const destPath = path.join(dest, entry.name);
57
+ if (entry.isDirectory()) {
58
+ copyDirRecursive(srcPath, destPath, depth + 1);
59
+ }
60
+ else {
61
+ fs.copyFileSync(srcPath, destPath);
62
+ }
63
+ }
64
+ }
65
+ function runCli(...args) {
66
+ const result = spawnSync("bun", [CLI, ...args], {
67
+ encoding: "utf8",
68
+ timeout: 30_000,
69
+ env: { ...process.env },
70
+ });
71
+ return {
72
+ stdout: result.stdout?.trim() ?? "",
73
+ stderr: result.stderr?.trim() ?? "",
74
+ exitCode: result.status ?? 1,
75
+ };
76
+ }
77
+ function parseJson(text) {
78
+ return JSON.parse(text);
79
+ }
80
+ function createEmptyStashDir(prefix) {
81
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
82
+ for (const sub of ["skills", "commands", "agents", "knowledge", "scripts"]) {
83
+ fs.mkdirSync(path.join(stashDir, sub), { recursive: true });
84
+ }
85
+ return stashDir;
86
+ }
87
+ async function withMockedFetch(handler, run) {
88
+ const originalFetch = globalThis.fetch;
89
+ globalThis.fetch = (async (input) => {
90
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
91
+ return handler(url);
92
+ });
93
+ try {
94
+ return await run();
95
+ }
96
+ finally {
97
+ globalThis.fetch = originalFetch;
98
+ }
99
+ }
100
+ const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
101
+ const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
102
+ const originalAkmStashDir = process.env.AKM_STASH_DIR;
103
+ let testCacheDir = "";
104
+ let testConfigDir = "";
105
+ beforeAll(async () => {
106
+ testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-"));
107
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-config-"));
108
+ process.env.XDG_CACHE_HOME = testCacheDir;
109
+ process.env.XDG_CONFIG_HOME = testConfigDir;
110
+ });
111
+ beforeEach(() => {
112
+ // Re-create per-test config dir for isolation (describe-level beforeAll
113
+ // already set XDG_CONFIG_HOME so akmIndex doesn't read real user config)
114
+ if (testConfigDir && fs.existsSync(testConfigDir)) {
115
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
116
+ }
117
+ testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-config-"));
118
+ process.env.XDG_CONFIG_HOME = testConfigDir;
119
+ });
120
+ afterAll(() => {
121
+ if (originalXdgCacheHome === undefined) {
122
+ delete process.env.XDG_CACHE_HOME;
123
+ }
124
+ else {
125
+ process.env.XDG_CACHE_HOME = originalXdgCacheHome;
126
+ }
127
+ if (originalXdgConfigHome === undefined) {
128
+ delete process.env.XDG_CONFIG_HOME;
129
+ }
130
+ else {
131
+ process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
132
+ }
133
+ if (originalAkmStashDir === undefined) {
134
+ delete process.env.AKM_STASH_DIR;
135
+ }
136
+ else {
137
+ process.env.AKM_STASH_DIR = originalAkmStashDir;
138
+ }
139
+ if (testCacheDir) {
140
+ fs.rmSync(testCacheDir, { recursive: true, force: true });
141
+ testCacheDir = "";
142
+ }
143
+ if (testConfigDir) {
144
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
145
+ testConfigDir = "";
146
+ }
147
+ });
148
+ afterEach(() => {
149
+ // Don't restore XDG_CONFIG_HOME here — it's managed by beforeAll/afterAll.
150
+ // Restoring it between tests would expose real user config to describe-level
151
+ // beforeAll hooks that run between describe blocks.
152
+ if (testConfigDir) {
153
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
154
+ testConfigDir = "";
155
+ }
156
+ });
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+ // Scenario 1: Full lifecycle — user sets up stash, indexes, searches, runs
159
+ // ═══════════════════════════════════════════════════════════════════════════
160
+ describe("Scenario: Full lifecycle (index → search → show)", () => {
161
+ let stashDir;
162
+ let scenarioCacheDir;
163
+ beforeAll(async () => {
164
+ stashDir = copyFixturesToTmp();
165
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s1-"));
166
+ process.env.AKM_STASH_DIR = stashDir;
167
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
168
+ });
169
+ beforeEach(() => {
170
+ process.env.AKM_STASH_DIR = stashDir;
171
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
172
+ });
173
+ afterAll(() => {
174
+ fs.rmSync(stashDir, { recursive: true, force: true });
175
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
176
+ });
177
+ test("search works without index (substring fallback)", async () => {
178
+ const result = await akmSearch({ query: "deploy", type: "script" });
179
+ expect(result.hits.length).toBeGreaterThan(0);
180
+ expect(result.hits.some((h) => h.name.includes("deploy"))).toBe(true);
181
+ // Substring mode computes a relevance score
182
+ expect(result.hits[0].score).toBeGreaterThan(0);
183
+ expect(result.hits[0].score).toBeLessThanOrEqual(1);
184
+ });
185
+ test("index generates metadata and builds search index", async () => {
186
+ const result = await akmIndex({ stashDir });
187
+ expect(result.stashDir).toBe(stashDir);
188
+ expect(result.totalEntries).toBeGreaterThanOrEqual(8);
189
+ expect(result.generatedMetadata).toBeGreaterThan(0);
190
+ expect(fs.existsSync(result.indexPath)).toBe(true);
191
+ });
192
+ test("index generates metadata in database for directories that lack .stash.json", async () => {
193
+ // git/ directory had no .stash.json — metadata generated in DB only
194
+ expect(fs.existsSync(path.join(stashDir, "scripts", "git", ".stash.json"))).toBe(false);
195
+ const db = openDatabase();
196
+ const entries = getAllEntries(db, "script");
197
+ const gitEntries = entries.filter((e) => e.dirPath.includes(path.join("scripts", "git")));
198
+ expect(gitEntries.length).toBeGreaterThanOrEqual(2);
199
+ // Each generated entry should be marked
200
+ for (const e of gitEntries) {
201
+ expect(e.entry.quality).toBe("generated");
202
+ expect(e.entry.type).toBe("script");
203
+ expect(e.entry.filename).toBeTruthy();
204
+ }
205
+ closeDatabase(db);
206
+ });
207
+ test("index preserves hand-written .stash.json (docker/ has intent fields)", async () => {
208
+ const dockerStash = loadStashFile(path.join(stashDir, "scripts", "docker"));
209
+ expect(dockerStash).not.toBeNull();
210
+ expect(dockerStash?.entries.length).toBe(2);
211
+ // These were hand-written, should NOT have generated flag
212
+ expect(dockerStash?.entries[0].quality).not.toBe("generated");
213
+ expect(dockerStash?.entries[0].intent).toBeDefined();
214
+ expect(dockerStash?.entries[0].intent?.when).toBeTruthy();
215
+ });
216
+ test("index extracts description from code comments", async () => {
217
+ const db = openDatabase();
218
+ const entries = getAllEntries(db, "script");
219
+ const diffEntry = entries.find((e) => e.entry.name.includes("summarize-diff"));
220
+ expect(diffEntry).toBeDefined();
221
+ // Should have extracted the JSDoc comment as description
222
+ expect(diffEntry?.entry.description).toBeTruthy();
223
+ expect(diffEntry?.entry.description?.toLowerCase()).toContain("git diff");
224
+ closeDatabase(db);
225
+ });
226
+ test("index extracts metadata from package.json", async () => {
227
+ const db = openDatabase();
228
+ const entries = getAllEntries(db, "script");
229
+ const lintEntry = entries.find((e) => e.entry.name.includes("eslint-check"));
230
+ expect(lintEntry).toBeDefined();
231
+ // package.json had description and keywords
232
+ expect(lintEntry?.entry.description).toContain("ESLint");
233
+ expect(lintEntry?.entry.tags).toContain("eslint");
234
+ closeDatabase(db);
235
+ });
236
+ test("search with index returns scored results with descriptions", async () => {
237
+ const result = await akmSearch({ query: "docker build image", type: "any" });
238
+ expect(result.schemaVersion).toBe(1);
239
+ expect(result.hits.length).toBeGreaterThan(0);
240
+ // Docker-build should be ranked first
241
+ const topHit = result.hits[0];
242
+ expect(topHit.name).toContain("docker");
243
+ expect(topHit.score).toBeDefined();
244
+ expect(topHit.score).toBeGreaterThan(0);
245
+ expect(topHit.description).toBeTruthy();
246
+ });
247
+ test.skipIf(!!process.env.CI)("search ranks keyword-relevant results higher (FTS-only)", async () => {
248
+ // NOTE: This test uses FTS keyword matching (BM25), not semantic/vector search.
249
+ const result = await akmSearch({ query: "summarize commit changes", type: "any" });
250
+ expect(result.hits.length).toBeGreaterThan(0);
251
+ // Git scripts should rank higher than docker scripts for this query
252
+ const topNames = result.hits.slice(0, 5).map((h) => h.name.toLowerCase());
253
+ const hasGitRelated = topNames.some((n) => n.includes("git") || n.includes("diff") || n.includes("commit") || n.includes("summarize"));
254
+ expect(hasGitRelated).toBe(true);
255
+ });
256
+ test("search type filter restricts results to that type", async () => {
257
+ const skillResult = await akmSearch({ query: "review", type: "skill" });
258
+ expect(skillResult.hits.every((h) => h.type === "skill")).toBe(true);
259
+ const cmdResult = await akmSearch({ query: "", type: "command" });
260
+ expect(cmdResult.hits.every((h) => h.type === "command")).toBe(true);
261
+ });
262
+ test("search with empty query returns all entries of that type", async () => {
263
+ const result = await akmSearch({ query: "", type: "agent" });
264
+ expect(result.hits.length).toBe(2); // architect.md and debugger.md
265
+ });
266
+ test("search respects limit parameter", async () => {
267
+ const result = await akmSearch({ query: "", type: "any", limit: 3 });
268
+ expect(result.hits.length).toBeLessThanOrEqual(3);
269
+ });
270
+ test("show a script returns run", async () => {
271
+ const searchResult = await akmSearch({ query: "deploy", type: "script" });
272
+ const deployHit = searchResult.hits.find((h) => isLocalHit(h) && h.name.includes("deploy"));
273
+ const resolvedDeployHit = expectDefined(deployHit);
274
+ const openResult = await akmShow({ ref: expectDefined(resolvedDeployHit.ref) });
275
+ expect(openResult.type).toBe("script");
276
+ expect(openResult.run).toBeTruthy();
277
+ expect(openResult.run).toContain("bash");
278
+ });
279
+ test("show a skill returns full SKILL.md content", async () => {
280
+ const openResult = await akmShow({ ref: "skill:code-review" });
281
+ expect(openResult.type).toBe("skill");
282
+ expect(openResult.content).toContain("Code Review Skill");
283
+ expect(openResult.content).toContain("security vulnerabilities");
284
+ });
285
+ test("show a command returns template and description", async () => {
286
+ const openResult = await akmShow({ ref: "command:release.md" });
287
+ expect(openResult.type).toBe("command");
288
+ expect(openResult.description).toBe("Create a new release with changelog and version bump");
289
+ expect(openResult.template).toContain("npm version");
290
+ });
291
+ test("show an agent returns prompt, description, model hint, and tool policy", async () => {
292
+ const openResult = await akmShow({ ref: "agent:architect.md" });
293
+ expect(openResult.type).toBe("agent");
294
+ expect(openResult.description).toContain("architect");
295
+ expect(openResult.prompt).toContain("software architect");
296
+ expect(openResult.modelHint).toBe("claude-sonnet-4-20250514");
297
+ expect(openResult.toolPolicy).toEqual({ allow: "Read,Glob,Grep" });
298
+ });
299
+ });
300
+ // ═══════════════════════════════════════════════════════════════════════════
301
+ // Scenario 2: Agent workflow — discover capability for a natural language task
302
+ // ═══════════════════════════════════════════════════════════════════════════
303
+ describe("Scenario: Agent discovers capabilities for task", () => {
304
+ let stashDir;
305
+ let scenarioCacheDir;
306
+ beforeAll(async () => {
307
+ stashDir = copyFixturesToTmp();
308
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s2-"));
309
+ process.env.AKM_STASH_DIR = stashDir;
310
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
311
+ await akmIndex({ stashDir });
312
+ });
313
+ beforeEach(() => {
314
+ process.env.AKM_STASH_DIR = stashDir;
315
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
316
+ });
317
+ afterAll(() => {
318
+ fs.rmSync(stashDir, { recursive: true, force: true });
319
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
320
+ });
321
+ test.skipIf(!!process.env.CI)("agent asks 'set up local dev environment' → docker-compose ranks high", async () => {
322
+ const result = await akmSearch({ query: "set up local development environment" });
323
+ const names = result.hits.map((h) => h.name.toLowerCase());
324
+ // Docker compose should appear because its intent says "start local development services"
325
+ expect(names.some((n) => n.includes("compose") || n.includes("docker"))).toBe(true);
326
+ });
327
+ test("agent asks 'check code quality' → lint script ranks high", async () => {
328
+ const result = await akmSearch({ query: "check code quality style" });
329
+ expect(result.hits.length).toBeGreaterThan(0);
330
+ const names = result.hits.map((h) => h.name.toLowerCase());
331
+ expect(names.some((n) => n.includes("lint") || n.includes("eslint"))).toBe(true);
332
+ });
333
+ test.skipIf(!!process.env.CI)("agent asks 'review my pull request' → code-review skill found", async () => {
334
+ const result = await akmSearch({ query: "review pull request code changes" });
335
+ expect(result.hits.length).toBeGreaterThan(0);
336
+ // Skill ref contains "code-review" (directory name), even though display name is "SKILL"
337
+ expect(result.hits.some((h) => (isLocalHit(h) && h.ref.includes("code-review")) || h.description?.toLowerCase().includes("review"))).toBe(true);
338
+ });
339
+ test.skipIf(!!process.env.CI)("agent asks 'help me design the system' → architect agent found", async () => {
340
+ const result = await akmSearch({ query: "system design architecture" });
341
+ expect(result.hits.length).toBeGreaterThan(0);
342
+ expect(result.hits.some((h) => h.name.includes("architect"))).toBe(true);
343
+ });
344
+ test("agent workflow: search → show (end-to-end)", async () => {
345
+ // Step 1: Agent searches for a script to run tests
346
+ const searchResult = await akmSearch({ query: "run tests" });
347
+ expect(searchResult.hits.length).toBeGreaterThan(0);
348
+ const testScript = searchResult.hits.find((h) => isLocalHit(h) && h.type === "script" && h.name.includes("test"));
349
+ const resolvedTestScript = expectDefined(testScript);
350
+ // Step 2: Agent reads the script to get run command for host execution
351
+ const showResult = await akmShow({ ref: expectDefined(resolvedTestScript.ref) });
352
+ expect(showResult.run).toBeTruthy();
353
+ });
354
+ });
355
+ describe("Scenario: Mixed local + registry search compatibility", () => {
356
+ let stashDir;
357
+ let scenarioCacheDir;
358
+ beforeAll(async () => {
359
+ stashDir = copyFixturesToTmp();
360
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s2b-"));
361
+ process.env.AKM_STASH_DIR = stashDir;
362
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
363
+ await akmIndex({ stashDir });
364
+ });
365
+ // Isolate registry index cache per test so mocked fetch responses
366
+ // aren't shadowed by a cached index from a previous test.
367
+ // We use a per-test cache dir but copy the index DB so local search still works.
368
+ beforeEach(() => {
369
+ process.env.AKM_STASH_DIR = stashDir;
370
+ const perTestCache = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-reg-cache-"));
371
+ // Copy the scenario's index DB so local search still finds it
372
+ const srcDb = path.join(scenarioCacheDir, "akm", "index.db");
373
+ const destDir = path.join(perTestCache, "akm");
374
+ if (fs.existsSync(srcDb)) {
375
+ fs.mkdirSync(destDir, { recursive: true });
376
+ fs.copyFileSync(srcDb, path.join(destDir, "index.db"));
377
+ }
378
+ process.env.XDG_CACHE_HOME = perTestCache;
379
+ });
380
+ afterEach(() => {
381
+ const tmpCache = process.env.XDG_CACHE_HOME;
382
+ if (tmpCache && tmpCache !== scenarioCacheDir) {
383
+ fs.rmSync(tmpCache, { recursive: true, force: true });
384
+ }
385
+ });
386
+ afterAll(() => {
387
+ fs.rmSync(stashDir, { recursive: true, force: true });
388
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
389
+ });
390
+ test("local source does not call registry providers", async () => {
391
+ const result = await withMockedFetch(() => {
392
+ throw new Error("fetch should not be called for source=local");
393
+ }, () => akmSearch({ query: "docker", source: "local" }));
394
+ expect(result.source).toBe("stash");
395
+ expect(result.hits.length).toBeGreaterThan(0);
396
+ expect(result.hits.every((h) => h.type !== "registry")).toBe(true);
397
+ });
398
+ test("registry source returns install guidance", async () => {
399
+ const registryIndex = {
400
+ version: 3,
401
+ updatedAt: "2026-03-09T00:00:00Z",
402
+ stashes: [
403
+ {
404
+ id: "npm:@scope/stash",
405
+ name: "@scope/stash",
406
+ description: "Example registry stash",
407
+ ref: "@scope/stash",
408
+ source: "npm",
409
+ homepage: "https://www.npmjs.com/package/@scope/stash",
410
+ tags: ["stash"],
411
+ latestVersion: "1.2.3",
412
+ },
413
+ {
414
+ id: "github:itlackey/example-stash",
415
+ name: "Example Stash",
416
+ description: "Example GitHub stash",
417
+ ref: "itlackey/example-stash",
418
+ source: "github",
419
+ homepage: "https://github.com/itlackey/example-stash",
420
+ tags: ["stash"],
421
+ },
422
+ ],
423
+ };
424
+ const result = await withMockedFetch(() => new Response(JSON.stringify(registryIndex), { status: 200 }), () => akmSearch({ query: "stash", source: "registry" }));
425
+ expect(result.source).toBe("registry");
426
+ expect(result.hits.length).toBe(0);
427
+ expect(result.registryHits).toBeDefined();
428
+ expect(result.registryHits?.length).toBeGreaterThan(0);
429
+ const registryHits = result.registryHits ?? [];
430
+ for (const hit of registryHits) {
431
+ expect(hit.type).toBe("registry");
432
+ expect(hit.action?.startsWith("akm add ")).toBe(true);
433
+ expect(hit.id.length).toBeGreaterThan(0);
434
+ }
435
+ });
436
+ test("both source includes local and registry hits", async () => {
437
+ const registryIndex = {
438
+ version: 3,
439
+ updatedAt: "2026-03-09T00:00:00Z",
440
+ stashes: [
441
+ {
442
+ id: "npm:docker-stash",
443
+ name: "docker-stash",
444
+ description: "Registry docker helper",
445
+ ref: "docker-stash",
446
+ source: "npm",
447
+ tags: ["docker"],
448
+ latestVersion: "0.1.0",
449
+ },
450
+ ],
451
+ };
452
+ const result = await withMockedFetch(() => new Response(JSON.stringify(registryIndex), { status: 200 }), () => akmSearch({ query: "docker", source: "both", limit: 10 }));
453
+ expect(result.source).toBe("both");
454
+ expect(result.hits.some((h) => h.type !== "registry")).toBe(true);
455
+ expect(result.registryHits).toBeDefined();
456
+ expect(result.registryHits?.length).toBeGreaterThan(0);
457
+ });
458
+ });
459
+ // ═══════════════════════════════════════════════════════════════════════════
460
+ // Scenario 3: CLI interface — real subprocess execution
461
+ // ═══════════════════════════════════════════════════════════════════════════
462
+ describe("Scenario: CLI subprocess execution", () => {
463
+ let stashDir;
464
+ let scenarioCacheDir;
465
+ beforeAll(async () => {
466
+ stashDir = copyFixturesToTmp();
467
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s3-"));
468
+ process.env.AKM_STASH_DIR = stashDir;
469
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
470
+ await akmIndex({ stashDir });
471
+ });
472
+ beforeEach(() => {
473
+ process.env.AKM_STASH_DIR = stashDir;
474
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
475
+ });
476
+ afterAll(() => {
477
+ fs.rmSync(stashDir, { recursive: true, force: true });
478
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
479
+ });
480
+ test("cli: akm search returns JSON with hits", async () => {
481
+ const result = runCli("search", "docker");
482
+ expect(result.exitCode).toBe(0);
483
+ const json = parseJson(result.stdout);
484
+ expect(json.hits).toBeInstanceOf(Array);
485
+ expect(json.hits.length).toBeGreaterThan(0);
486
+ expect(json.stashDir).toBeUndefined();
487
+ expect(json.hits[0].name).toBeTruthy();
488
+ expect(json.hits[0].action).toBeTruthy();
489
+ });
490
+ test("cli: akm search --type script filters by type", async () => {
491
+ const result = runCli("search", "deploy", "--type", "script");
492
+ expect(result.exitCode).toBe(0);
493
+ const json = parseJson(result.stdout);
494
+ expect(json.hits.every((h) => h.type === "script")).toBe(true);
495
+ });
496
+ test("cli: akm search --type knowledge filters by type", async () => {
497
+ const result = runCli("search", "guide", "--type", "knowledge");
498
+ expect(result.exitCode).toBe(0);
499
+ const json = parseJson(result.stdout);
500
+ expect(json.hits.length).toBeGreaterThan(0);
501
+ expect(json.hits.every((h) => h.type === "knowledge")).toBe(true);
502
+ });
503
+ // Issue 9: empty query must list all assets rather than throwing UsageError.
504
+ // The CLI contract (--help text) promises omitting the query returns all
505
+ // indexed assets. The akmSearch function handles empty queries end-to-end
506
+ // via getAllEntries (DB path) and the substring-fallback's query-less branch.
507
+ test("cli: akm search with no query lists all assets (empty-query listing)", async () => {
508
+ const result = runCli("search");
509
+ expect(result.exitCode).toBe(0);
510
+ const json = parseJson(result.stdout);
511
+ expect(json.hits).toBeInstanceOf(Array);
512
+ expect(json.hits.length).toBeGreaterThan(0);
513
+ // Brief output: ref is NOT present (only full/agent levels include ref)
514
+ expect(json.hits.every((h) => h.type !== undefined)).toBe(true);
515
+ expect(json.hits.every((h) => h.name !== undefined)).toBe(true);
516
+ });
517
+ test("cli: akm search with no query and --type filters by type", async () => {
518
+ const result = runCli("search", "--type", "skill");
519
+ expect(result.exitCode).toBe(0);
520
+ const json = parseJson(result.stdout);
521
+ expect(json.hits).toBeInstanceOf(Array);
522
+ expect(json.hits.every((h) => h.type === "skill")).toBe(true);
523
+ });
524
+ test("cli: akm search --limit 2 respects limit", async () => {
525
+ const result = runCli("search", "docker", "--limit", "2");
526
+ expect(result.exitCode).toBe(0);
527
+ const json = parseJson(result.stdout);
528
+ expect(json.hits.length).toBeLessThanOrEqual(2);
529
+ });
530
+ test("cli: akm search defaults to brief JSON output", async () => {
531
+ const result = runCli("search", "docker", "--type", "script");
532
+ expect(result.exitCode).toBe(0);
533
+ const json = parseJson(result.stdout);
534
+ expect(json.source).toBeUndefined();
535
+ expect(json.timing).toBeUndefined();
536
+ // REC-03: ref is now included at brief detail so agents can run `akm show <ref>`
537
+ expect(json.hits.every((h) => h.ref !== undefined)).toBe(true);
538
+ expect(json.hits.every((h) => h.type !== undefined)).toBe(true);
539
+ expect(json.hits.every((h) => h.name !== undefined)).toBe(true);
540
+ expect(json.hits.every((h) => h.action !== undefined)).toBe(true);
541
+ });
542
+ test("cli: akm search default source is stash", async () => {
543
+ const result = runCli("search", "docker", "--detail", "full");
544
+ expect(result.exitCode).toBe(0);
545
+ const json = parseJson(result.stdout);
546
+ expect(json.source).toBe("stash");
547
+ expect(json.hits.every((h) => h.type !== "registry")).toBe(true);
548
+ });
549
+ test("cli: akm search --detail normal includes score and description", async () => {
550
+ const result = runCli("search", "docker", "--type", "script", "--detail", "normal");
551
+ expect(result.exitCode).toBe(0);
552
+ const json = parseJson(result.stdout);
553
+ expect(json.hits.some((h) => h.score !== undefined)).toBe(true);
554
+ expect(json.hits.some((h) => h.action !== undefined)).toBe(true);
555
+ });
556
+ test("cli: akm search --detail full includes timing and whyMatched", async () => {
557
+ const result = runCli("search", "docker", "--detail", "full");
558
+ expect(result.exitCode).toBe(0);
559
+ const json = parseJson(result.stdout);
560
+ expect(json.timing).toBeDefined();
561
+ expect(json.hits.every((h) => h.whyMatched !== undefined)).toBe(true);
562
+ });
563
+ test("cli: akm search --format yaml returns valid YAML output", async () => {
564
+ const result = spawnSync("bun", [CLI, "search", "docker", "--format", "yaml"], {
565
+ encoding: "utf8",
566
+ timeout: 30_000,
567
+ env: { ...process.env },
568
+ });
569
+ expect(result.status).toBe(0);
570
+ // Output must be YAML (unquoted keys like "hits:"), not JSON (quoted keys like '"hits"')
571
+ const hasYaml = result.stdout.includes("hits:") && !result.stdout.includes('"hits"');
572
+ expect(hasYaml).toBe(true);
573
+ expect(result.stdout).toContain("docker");
574
+ // Should not contain the fallback warning
575
+ expect(result.stderr).not.toContain("YAML output not available");
576
+ });
577
+ test("cli: akm show --format text includes execution fields", async () => {
578
+ const result = spawnSync("bun", [CLI, "show", "command:release.md", "--format", "text"], {
579
+ encoding: "utf8",
580
+ timeout: 30_000,
581
+ env: { ...process.env },
582
+ });
583
+ expect(result.status).toBe(0);
584
+ expect(result.stdout).toContain("# command: release");
585
+ expect(result.stdout).toContain("description:");
586
+ expect(result.stdout).toContain("Create a new release");
587
+ expect(result.stdout).toContain("npm version");
588
+ });
589
+ test("cli: akm search --format invalid value fails with clear error", async () => {
590
+ const result = runCli("search", "docker", "--format", "xml");
591
+ expect(result.exitCode).not.toBe(0);
592
+ const output = result.stdout + result.stderr;
593
+ expect(output).toContain("Invalid value for --format: xml. Expected one of: json|yaml|text");
594
+ });
595
+ test("cli: akm search --detail invalid value fails with clear error", async () => {
596
+ const result = runCli("search", "docker", "--detail", "max");
597
+ expect(result.exitCode).not.toBe(0);
598
+ const output = result.stdout + result.stderr;
599
+ expect(output).toContain("Invalid value for --detail: max. Expected one of: brief|normal|full");
600
+ });
601
+ test("cli: akm search --source invalid value fails with clear error", async () => {
602
+ const result = runCli("search", "docker", "--source", "bad");
603
+ expect(result.exitCode).not.toBe(0);
604
+ const output = result.stdout + result.stderr;
605
+ expect(output).toContain("Invalid value for --source: bad. Expected one of: stash|registry|both");
606
+ });
607
+ test("cli: akm search default output excludes full-detail fields", async () => {
608
+ const result = runCli("search", "docker");
609
+ expect(result.exitCode).toBe(0);
610
+ const json = parseJson(result.stdout);
611
+ expect(json.hits.length).toBeGreaterThan(0);
612
+ for (const hit of json.hits) {
613
+ expect(hit.whyMatched).toBeUndefined();
614
+ expect(hit.editable).toBeUndefined();
615
+ expect(hit.editHint).toBeUndefined();
616
+ }
617
+ expect(json.timing).toBeUndefined();
618
+ });
619
+ test("cli: akm show returns asset content", async () => {
620
+ const result = runCli("show", "skill:code-review");
621
+ expect(result.exitCode).toBe(0);
622
+ const json = parseJson(result.stdout);
623
+ expect(json.type).toBe("skill");
624
+ expect(json.content).toContain("Code Review Skill");
625
+ // QA #7: path is now always included in the JSON shape (not just --detail full)
626
+ expect(json.path).toBeDefined();
627
+ });
628
+ test("cli: akm show --detail full includes schemaVersion and path", async () => {
629
+ const result = runCli("show", "command:release.md", "--detail", "full");
630
+ expect(result.exitCode).toBe(0);
631
+ const json = parseJson(result.stdout);
632
+ expect(json.type).toBe("command");
633
+ expect(json.schemaVersion).toBe(1);
634
+ expect(json.path).toBeTruthy();
635
+ });
636
+ test("cli: akm show command returns template", async () => {
637
+ const result = runCli("show", "command:release.md");
638
+ expect(result.exitCode).toBe(0);
639
+ const json = parseJson(result.stdout);
640
+ expect(json.type).toBe("command");
641
+ expect(json.description).toBeTruthy();
642
+ expect(json.template).toContain("npm version");
643
+ });
644
+ test("cli: akm index builds index and reports stats", async () => {
645
+ const result = runCli("index");
646
+ expect(result.exitCode).toBe(0);
647
+ const json = parseJson(result.stdout);
648
+ expect(json.totalEntries).toBeGreaterThan(0);
649
+ expect(json.indexPath).toBeTruthy();
650
+ });
651
+ test("cli: akm index --full returns mode full", async () => {
652
+ const result = runCli("index", "--full");
653
+ expect(result.exitCode).toBe(0);
654
+ const json = parseJson(result.stdout);
655
+ expect(json.mode).toBe("full");
656
+ });
657
+ test("cli: akm config set/get manages llm settings via JSON", async () => {
658
+ const setResult = runCli("config", "set", "llm", '{"endpoint":"http://localhost:11434/v1/chat/completions","model":"llama3.2","maxTokens":256}');
659
+ expect(setResult.exitCode).toBe(0);
660
+ const getResult = runCli("config", "get", "llm");
661
+ expect(getResult.exitCode).toBe(0);
662
+ const json = parseJson(getResult.stdout);
663
+ expect(json).toMatchObject({
664
+ endpoint: "http://localhost:11434/v1/chat/completions",
665
+ model: "llama3.2",
666
+ maxTokens: 256,
667
+ });
668
+ });
669
+ test("cli: akm with no command prints usage", async () => {
670
+ const result = runCli();
671
+ expect(result.exitCode).not.toBe(0);
672
+ const output = result.stdout + result.stderr;
673
+ expect(output).toContain("No command specified");
674
+ });
675
+ test("cli: akm show with no ref prints error", async () => {
676
+ const result = runCli("show");
677
+ expect(result.exitCode).not.toBe(0);
678
+ const output = result.stdout + result.stderr;
679
+ expect(output).toContain("Missing required positional argument");
680
+ });
681
+ });
682
+ describe("Scenario: Registry lifecycle CLI (no network)", () => {
683
+ let savedStashDir;
684
+ beforeEach(() => {
685
+ savedStashDir = process.env.AKM_STASH_DIR;
686
+ });
687
+ afterEach(() => {
688
+ if (savedStashDir === undefined) {
689
+ delete process.env.AKM_STASH_DIR;
690
+ }
691
+ else {
692
+ process.env.AKM_STASH_DIR = savedStashDir;
693
+ }
694
+ });
695
+ test("cli: akm list returns empty installed set when none configured", async () => {
696
+ const stashDir = createEmptyStashDir("akm-e2e-registry-empty-");
697
+ process.env.AKM_STASH_DIR = stashDir;
698
+ saveConfig({ semanticSearchMode: "off" });
699
+ try {
700
+ const result = runCli("list");
701
+ expect(result.exitCode).toBe(0);
702
+ const json = parseJson(result.stdout);
703
+ expect(json.totalSources).toBe(0);
704
+ expect(json.sources).toEqual([]);
705
+ }
706
+ finally {
707
+ fs.rmSync(stashDir, { recursive: true, force: true });
708
+ }
709
+ });
710
+ test("cli: registered external wikis appear in list and wiki remove clears stale search hits", async () => {
711
+ const stashDir = createEmptyStashDir("akm-e2e-wiki-source-");
712
+ const externalWiki = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-ext-wiki-"));
713
+ process.env.AKM_STASH_DIR = stashDir;
714
+ saveConfig({ semanticSearchMode: "off" });
715
+ fs.mkdirSync(path.join(externalWiki, "tools", "documentation", "how-to"), { recursive: true });
716
+ fs.writeFileSync(path.join(externalWiki, "tools", "documentation", "how-to", "001-get-started.md"), "---\ndescription: External docs\n---\n# Start\n", "utf8");
717
+ try {
718
+ const registerResult = runCli("wiki", "register", "ics-docs", externalWiki, "--format", "json");
719
+ expect(registerResult.exitCode).toBe(0);
720
+ const registerJson = parseJson(registerResult.stdout);
721
+ expect(registerJson.ref).toBe("ics-docs");
722
+ expect(registerJson.sourceAdded).toEqual(expect.objectContaining({
723
+ type: "filesystem",
724
+ name: "ics-docs",
725
+ wiki: "ics-docs",
726
+ }));
727
+ const listResult = runCli("list", "--format", "json");
728
+ expect(listResult.exitCode).toBe(0);
729
+ const listJson = parseJson(listResult.stdout);
730
+ expect(listJson.sources).toEqual(expect.arrayContaining([
731
+ expect.objectContaining({
732
+ name: "ics-docs",
733
+ kind: "filesystem",
734
+ wiki: "ics-docs",
735
+ }),
736
+ ]));
737
+ const searchResult = runCli("search", "documentation", "--type", "wiki", "--detail", "normal", "--format", "json");
738
+ expect(searchResult.exitCode).toBe(0);
739
+ const searchJson = parseJson(searchResult.stdout);
740
+ expect(searchJson.hits).toEqual(expect.arrayContaining([
741
+ expect.objectContaining({
742
+ name: "ics-docs/tools/documentation/how-to/001-get-started",
743
+ action: "akm show wiki:ics-docs/tools/documentation/how-to/001-get-started -> read the wiki page",
744
+ }),
745
+ ]));
746
+ const removeResult = runCli("wiki", "remove", "ics-docs", "--force", "--format", "json");
747
+ expect(removeResult.exitCode).toBe(0);
748
+ const removedSearchResult = runCli("search", "documentation", "--type", "wiki", "--format", "json");
749
+ expect(removedSearchResult.exitCode).toBe(0);
750
+ const removedSearchJson = parseJson(removedSearchResult.stdout);
751
+ expect(removedSearchJson.hits).toEqual([]);
752
+ }
753
+ finally {
754
+ fs.rmSync(stashDir, { recursive: true, force: true });
755
+ fs.rmSync(externalWiki, { recursive: true, force: true });
756
+ }
757
+ });
758
+ test("cli: wiki remove --force hides preserved raw-only leftovers from wiki list", async () => {
759
+ const stashDir = createEmptyStashDir("akm-e2e-wiki-remove-");
760
+ const rawSource = path.join(stashDir, "source.md");
761
+ process.env.AKM_STASH_DIR = stashDir;
762
+ saveConfig({ semanticSearchMode: "off" });
763
+ fs.writeFileSync(rawSource, "# Test\n", "utf8");
764
+ try {
765
+ const createResult = runCli("wiki", "create", "my-notes", "--format", "json");
766
+ expect(createResult.exitCode).toBe(0);
767
+ const stashResult = runCli("wiki", "stash", "my-notes", rawSource, "--as", "test-page", "--format", "json");
768
+ expect(stashResult.exitCode).toBe(0);
769
+ const removeResult = runCli("wiki", "remove", "my-notes", "--force", "--format", "text");
770
+ expect(removeResult.exitCode).toBe(0);
771
+ expect(removeResult.stdout).toContain("raw/ preserved at");
772
+ const listResult = runCli("wiki", "list", "--format", "json");
773
+ expect(listResult.exitCode).toBe(0);
774
+ const listJson = parseJson(listResult.stdout);
775
+ expect(listJson.wikis).toEqual([]);
776
+ const showResult = runCli("wiki", "show", "my-notes", "--format", "json");
777
+ expect(showResult.exitCode).not.toBe(0);
778
+ expect(showResult.stderr).toContain("Wiki not found: my-notes");
779
+ const cleanupResult = runCli("wiki", "remove", "my-notes", "--force", "--with-sources", "--format", "json");
780
+ expect(cleanupResult.exitCode).toBe(0);
781
+ }
782
+ finally {
783
+ fs.rmSync(stashDir, { recursive: true, force: true });
784
+ }
785
+ });
786
+ test("cli: akm index text output surfaces skipped-asset warnings", async () => {
787
+ const stashDir = createEmptyStashDir("akm-e2e-index-warn-");
788
+ process.env.AKM_STASH_DIR = stashDir;
789
+ saveConfig({ semanticSearchMode: "off" });
790
+ fs.mkdirSync(path.join(stashDir, "workflows"), { recursive: true });
791
+ fs.writeFileSync(path.join(stashDir, "workflows", "good.md"), [
792
+ "---",
793
+ "description: Good workflow",
794
+ "---",
795
+ "",
796
+ "# Workflow: Good",
797
+ "",
798
+ "## Step: First",
799
+ "Step ID: first",
800
+ "### Instructions",
801
+ "Do it.",
802
+ "",
803
+ ].join("\n"), "utf8");
804
+ // Truly malformed: no `# Workflow:` heading. Intro prose between the
805
+ // title and the first step is now permitted (#158).
806
+ fs.writeFileSync(path.join(stashDir, "workflows", "bad.md"), [
807
+ "---",
808
+ "description: Bad workflow",
809
+ "---",
810
+ "",
811
+ "## Step: First",
812
+ "Step ID: first",
813
+ "### Instructions",
814
+ "Do it.",
815
+ "",
816
+ ].join("\n"), "utf8");
817
+ try {
818
+ const result = runCli("index", "--full", "--format", "text");
819
+ expect(result.exitCode).toBe(0);
820
+ expect(result.stdout).toContain("Warnings (");
821
+ expect(result.stdout).toContain(path.join(stashDir, "workflows", "bad.md"));
822
+ }
823
+ finally {
824
+ fs.rmSync(stashDir, { recursive: true, force: true });
825
+ }
826
+ });
827
+ test("cli: akm remove resolves parsed ref id and removes cache directory", async () => {
828
+ const stashDir = createEmptyStashDir("akm-e2e-registry-remove-");
829
+ const stashRoot = path.join(stashDir, "registry-stash");
830
+ const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-remove-"));
831
+ fs.mkdirSync(path.join(stashRoot, "scripts"), { recursive: true });
832
+ process.env.AKM_STASH_DIR = stashDir;
833
+ saveConfig({
834
+ semanticSearchMode: "off",
835
+ installed: [
836
+ {
837
+ id: "npm:@scope/stash",
838
+ source: "npm",
839
+ ref: "npm:@scope/stash@1.0.0",
840
+ artifactUrl: "https://registry.npmjs.org/@scope/stash/-/stash-1.0.0.tgz",
841
+ resolvedVersion: "1.0.0",
842
+ resolvedRevision: "abc123",
843
+ stashRoot,
844
+ cacheDir,
845
+ installedAt: new Date().toISOString(),
846
+ },
847
+ ],
848
+ });
849
+ try {
850
+ const result = runCli("remove", "npm:@scope/stash@latest");
851
+ expect(result.exitCode).toBe(0);
852
+ const json = parseJson(result.stdout);
853
+ expect(expectDefined(json.removed).id).toBe("npm:@scope/stash");
854
+ const config = loadConfig();
855
+ expect(config.installed).toBeUndefined();
856
+ expect(fs.existsSync(cacheDir)).toBe(false);
857
+ }
858
+ finally {
859
+ fs.rmSync(stashDir, { recursive: true, force: true });
860
+ fs.rmSync(cacheDir, { recursive: true, force: true });
861
+ }
862
+ });
863
+ test("cli: akm update requires target or --all", async () => {
864
+ const stashDir = createEmptyStashDir("akm-e2e-registry-update-");
865
+ process.env.AKM_STASH_DIR = stashDir;
866
+ saveConfig({ semanticSearchMode: "off" });
867
+ try {
868
+ const result = runCli("update");
869
+ expect(result.exitCode).not.toBe(0);
870
+ const output = result.stdout + result.stderr;
871
+ expect(output).toContain("Either <target> or --all is required.");
872
+ }
873
+ finally {
874
+ fs.rmSync(stashDir, { recursive: true, force: true });
875
+ }
876
+ });
877
+ test("cli: akm update rejects target with --all", async () => {
878
+ const stashDir = createEmptyStashDir("akm-e2e-registry-update-both-");
879
+ process.env.AKM_STASH_DIR = stashDir;
880
+ saveConfig({ semanticSearchMode: "off" });
881
+ try {
882
+ const result = runCli("update", "npm:@scope/stash", "--all");
883
+ expect(result.exitCode).not.toBe(0);
884
+ const output = result.stdout + result.stderr;
885
+ expect(output).toContain("Specify either <target> or --all, not both.");
886
+ }
887
+ finally {
888
+ fs.rmSync(stashDir, { recursive: true, force: true });
889
+ }
890
+ });
891
+ test("cli: akm update missing target returns stable not-installed error", async () => {
892
+ const stashDir = createEmptyStashDir("akm-e2e-registry-missing-");
893
+ process.env.AKM_STASH_DIR = stashDir;
894
+ saveConfig({ semanticSearchMode: "off" });
895
+ try {
896
+ const result = runCli("update", "npm:@scope/stash");
897
+ expect(result.exitCode).not.toBe(0);
898
+ const output = result.stdout + result.stderr;
899
+ expect(output).toContain("No matching source for target: npm:@scope/stash");
900
+ }
901
+ finally {
902
+ fs.rmSync(stashDir, { recursive: true, force: true });
903
+ }
904
+ });
905
+ });
906
+ // ═══════════════════════════════════════════════════════════════════════════
907
+ // Scenario 3a: CLI upgrade and update --force commands
908
+ // ═══════════════════════════════════════════════════════════════════════════
909
+ describe("Scenario: upgrade and update --force (no network)", () => {
910
+ let savedStashDir;
911
+ beforeEach(() => {
912
+ savedStashDir = process.env.AKM_STASH_DIR;
913
+ });
914
+ afterEach(() => {
915
+ if (savedStashDir === undefined) {
916
+ delete process.env.AKM_STASH_DIR;
917
+ }
918
+ else {
919
+ process.env.AKM_STASH_DIR = savedStashDir;
920
+ }
921
+ });
922
+ test("upgrade --check returns version info (mocked fetch)", async () => {
923
+ const { checkForUpdate } = await import("../src/commands/self-update");
924
+ const result = await withMockedFetch(() => Response.json({ tag_name: "v0.0.14" }), () => checkForUpdate("0.0.13"));
925
+ expect(result.currentVersion).toBe("0.0.13");
926
+ expect(result.latestVersion).toBe("0.0.14");
927
+ expect(result.updateAvailable).toBe(true);
928
+ expect(["binary", "bun", "npm", "pnpm", "unknown"]).toContain(result.installMethod);
929
+ });
930
+ test("performUpgrade detects non-binary install and returns guidance", async () => {
931
+ const { performUpgrade } = await import("../src/commands/self-update");
932
+ const result = await performUpgrade({
933
+ currentVersion: "0.0.13",
934
+ latestVersion: "0.0.14",
935
+ updateAvailable: true,
936
+ installMethod: "unknown",
937
+ });
938
+ expect(result.upgraded).toBe(false);
939
+ expect(["bun", "npm", "pnpm", "unknown"]).toContain(result.installMethod);
940
+ expect(result.message).toBeTruthy();
941
+ });
942
+ test("cli: akm update --help shows --force flag", async () => {
943
+ const result = spawnSync("bun", [CLI, "update", "--help"], {
944
+ encoding: "utf8",
945
+ timeout: 10_000,
946
+ });
947
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
948
+ expect(output).toContain("--force");
949
+ expect(output).toContain("Force fresh download");
950
+ });
951
+ test("cli: akm update --force requires target or --all", async () => {
952
+ const stashDir = createEmptyStashDir("akm-e2e-update-force-");
953
+ process.env.AKM_STASH_DIR = stashDir;
954
+ saveConfig({ semanticSearchMode: "off" });
955
+ try {
956
+ const result = runCli("update", "--force");
957
+ expect(result.exitCode).not.toBe(0);
958
+ const output = result.stdout + result.stderr;
959
+ expect(output).toContain("Either <target> or --all is required.");
960
+ }
961
+ finally {
962
+ fs.rmSync(stashDir, { recursive: true, force: true });
963
+ }
964
+ });
965
+ test("cli: akm upgrade --help shows --check and --force flags", async () => {
966
+ const result = spawnSync("bun", [CLI, "upgrade", "--help"], {
967
+ encoding: "utf8",
968
+ timeout: 10_000,
969
+ });
970
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
971
+ expect(output).toContain("--check");
972
+ expect(output).toContain("--force");
973
+ });
974
+ test("cli: akm help migrate prints migration guidance for a release", async () => {
975
+ const result = spawnSync("bun", [CLI, "help", "migrate", "0.5.0"], {
976
+ encoding: "utf8",
977
+ timeout: 10_000,
978
+ });
979
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
980
+ expect(result.status).toBe(0);
981
+ expect(output).toContain("Migration notes for akm v0.5.0");
982
+ expect(output).toContain("## [0.5.0]");
983
+ });
984
+ test("cli: akm --help lists the help command", async () => {
985
+ const result = spawnSync("bun", [CLI, "--help"], {
986
+ encoding: "utf8",
987
+ timeout: 10_000,
988
+ });
989
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
990
+ expect(output).toContain("help");
991
+ expect(output).toContain("focused help topics");
992
+ });
993
+ test("cli: akm setup --help shows the wizard description (issue #273)", async () => {
994
+ // The setup subcommand should advertise its purpose so operators can
995
+ // see what the wizard configures (embeddings, LLM, registries, sources,
996
+ // agent profiles) without running the interactive flow.
997
+ const result = spawnSync("bun", [CLI, "setup", "--help"], {
998
+ encoding: "utf8",
999
+ timeout: 10_000,
1000
+ });
1001
+ const output = (result.stdout ?? "") + (result.stderr ?? "");
1002
+ expect(output).toContain("Interactive configuration wizard");
1003
+ expect(output).toContain("embeddings");
1004
+ expect(output).toContain("LLM");
1005
+ expect(output).toContain("registries");
1006
+ expect(output).toContain("sources");
1007
+ expect(output).toContain("agent profiles");
1008
+ });
1009
+ });
1010
+ // ═══════════════════════════════════════════════════════════════════════════
1011
+ // Scenario 3b: CLI knowledge view modes (positional syntax)
1012
+ // ═══════════════════════════════════════════════════════════════════════════
1013
+ describe("Scenario: CLI knowledge view modes (positional)", () => {
1014
+ let stashDir;
1015
+ beforeAll(async () => {
1016
+ stashDir = copyFixturesToTmp();
1017
+ process.env.AKM_STASH_DIR = stashDir;
1018
+ });
1019
+ beforeEach(() => {
1020
+ process.env.AKM_STASH_DIR = stashDir;
1021
+ });
1022
+ afterAll(() => {
1023
+ fs.rmSync(stashDir, { recursive: true, force: true });
1024
+ });
1025
+ test("cli: show knowledge with positional toc", async () => {
1026
+ const result = runCli("show", "knowledge:guide.md", "toc");
1027
+ expect(result.exitCode).toBe(0);
1028
+ const json = parseJson(result.stdout);
1029
+ expect(json.type).toBe("knowledge");
1030
+ expect(json.content).toContain("# API Reference Guide");
1031
+ expect(json.content).toContain("## Getting Started");
1032
+ expect(json.content).toContain("lines total");
1033
+ });
1034
+ test("cli: show knowledge with positional section heading", async () => {
1035
+ const result = runCli("show", "knowledge:guide.md", "section", "Getting Started");
1036
+ expect(result.exitCode).toBe(0);
1037
+ const json = parseJson(result.stdout);
1038
+ expect(json.type).toBe("knowledge");
1039
+ expect(json.content).toContain("Getting Started");
1040
+ expect(json.content).toContain("install the package");
1041
+ // Should include sub-headings but not sibling headings
1042
+ expect(json.content).toContain("Prerequisites");
1043
+ expect(json.content).not.toContain("Authentication");
1044
+ });
1045
+ test("cli: show knowledge with positional lines start end", async () => {
1046
+ const result = runCli("show", "knowledge:guide.md", "lines", "1", "5");
1047
+ expect(result.exitCode).toBe(0);
1048
+ const json = parseJson(result.stdout);
1049
+ expect(json.type).toBe("knowledge");
1050
+ // Lines 1-5 cover the frontmatter start
1051
+ expect(json.content).toContain("---");
1052
+ });
1053
+ test("cli: show knowledge rejects legacy --view flag", async () => {
1054
+ const result = runCli("show", "knowledge:guide.md", "--view", "toc");
1055
+ expect(result.exitCode).not.toBe(0);
1056
+ expect(result.stderr).toContain("Legacy show flags are no longer supported");
1057
+ });
1058
+ test("cli: show knowledge default (no view mode) returns full content", async () => {
1059
+ const result = runCli("show", "knowledge:guide.md");
1060
+ expect(result.exitCode).toBe(0);
1061
+ const json = parseJson(result.stdout);
1062
+ expect(json.type).toBe("knowledge");
1063
+ expect(json.content).toBeDefined();
1064
+ // Full content should include everything
1065
+ expect(json.content).toContain("API Reference Guide");
1066
+ expect(json.content).toContain("Getting Started");
1067
+ expect(json.content).toContain("Authentication");
1068
+ });
1069
+ test("cli: show knowledge --detail brief omits full content", async () => {
1070
+ const result = runCli("show", "knowledge:guide.md", "--detail", "brief");
1071
+ expect(result.exitCode).toBe(0);
1072
+ const json = parseJson(result.stdout);
1073
+ expect(json.type).toBe("knowledge");
1074
+ expect(json.name).toBe("guide.md");
1075
+ expect(json.description).toBe("Comprehensive guide for the example API");
1076
+ expect(json.action).toContain("Reference material");
1077
+ expect(json.content).toBeUndefined();
1078
+ });
1079
+ });
1080
+ // ═══════════════════════════════════════════════════════════════════════════
1081
+ // Scenario 4: Progressive improvement — user drops scripts, indexes later
1082
+ // ═══════════════════════════════════════════════════════════════════════════
1083
+ describe("Scenario: Zero-config progressive improvement", () => {
1084
+ let stashDir;
1085
+ let scenarioCacheDir;
1086
+ beforeAll(async () => {
1087
+ stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-prog-"));
1088
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s4-"));
1089
+ for (const sub of ["scripts", "skills", "commands", "agents"]) {
1090
+ fs.mkdirSync(path.join(stashDir, sub), { recursive: true });
1091
+ }
1092
+ process.env.AKM_STASH_DIR = stashDir;
1093
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1094
+ });
1095
+ beforeEach(() => {
1096
+ process.env.AKM_STASH_DIR = stashDir;
1097
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1098
+ });
1099
+ afterAll(() => {
1100
+ fs.rmSync(stashDir, { recursive: true, force: true });
1101
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
1102
+ });
1103
+ test("user drops a script in scripts/ — search finds it by name (no index)", async () => {
1104
+ fs.mkdirSync(path.join(stashDir, "scripts", "format"), { recursive: true });
1105
+ fs.writeFileSync(path.join(stashDir, "scripts", "format", "prettier-check.sh"), "#!/usr/bin/env bash\n# Format code with Prettier\nprettier --check .\n");
1106
+ const result = await akmSearch({ query: "prettier", type: "script" });
1107
+ expect(result.hits.length).toBe(1);
1108
+ expect(result.hits[0].name).toContain("prettier");
1109
+ });
1110
+ test("user runs index — metadata generated in database with description from comments", async () => {
1111
+ await akmIndex({ stashDir });
1112
+ // No .stash.json should be auto-generated
1113
+ expect(fs.existsSync(path.join(stashDir, "scripts", "format", ".stash.json"))).toBe(false);
1114
+ const db = openDatabase();
1115
+ const entries = getAllEntries(db, "script");
1116
+ const formatEntry = entries.find((e) => e.entry.name.includes("prettier"));
1117
+ expect(formatEntry).toBeDefined();
1118
+ expect(formatEntry?.entry.quality).toBe("generated");
1119
+ expect(formatEntry?.entry.description).toContain("Format code");
1120
+ closeDatabase(db);
1121
+ });
1122
+ test("user adds more scripts — re-index picks them up", async () => {
1123
+ fs.mkdirSync(path.join(stashDir, "scripts", "db"), { recursive: true });
1124
+ fs.writeFileSync(path.join(stashDir, "scripts", "db", "migrate.sh"), "#!/usr/bin/env bash\n# Run database migrations\necho 'migrating...'\n");
1125
+ const result = await akmIndex({ stashDir });
1126
+ expect(result.totalEntries).toBeGreaterThanOrEqual(2);
1127
+ const db = openDatabase();
1128
+ const entries = getAllEntries(db, "script");
1129
+ const migrateEntry = entries.find((e) => e.entry.name.includes("migrate"));
1130
+ expect(migrateEntry).toBeDefined();
1131
+ expect(migrateEntry?.entry.description).toContain("database migrations");
1132
+ closeDatabase(db);
1133
+ });
1134
+ test("user creates .stash.json manually — overrides used on next index", async () => {
1135
+ // User creates a .stash.json override for the format script
1136
+ const stashPath = path.join(stashDir, "scripts", "format", ".stash.json");
1137
+ fs.writeFileSync(stashPath, JSON.stringify({
1138
+ entries: [
1139
+ {
1140
+ name: "prettier-check",
1141
+ type: "script",
1142
+ description: "Check code formatting with Prettier",
1143
+ tags: ["prettier", "format", "style"],
1144
+ filename: "prettier-check.sh",
1145
+ },
1146
+ ],
1147
+ }, null, 2));
1148
+ // Re-index — should use user override
1149
+ await akmIndex({ stashDir });
1150
+ const reloaded = expectDefined(loadStashFile(path.join(stashDir, "scripts", "format")));
1151
+ expect(reloaded.entries[0].description).toBe("Check code formatting with Prettier");
1152
+ expect(reloaded.entries[0].tags).toContain("prettier");
1153
+ expect(reloaded.entries[0].quality).not.toBe("generated");
1154
+ });
1155
+ test("semantic search finds user-edited metadata after re-index", async () => {
1156
+ // Re-index to pick up manual edits in the search index
1157
+ await akmIndex({ stashDir });
1158
+ const result = await akmSearch({ query: "format code style" });
1159
+ expect(result.hits.length).toBeGreaterThan(0);
1160
+ expect(result.hits.some((h) => h.name.includes("prettier"))).toBe(true);
1161
+ });
1162
+ });
1163
+ // ═══════════════════════════════════════════════════════════════════════════
1164
+ // Scenario 5: Multi-script directory with .stash.json
1165
+ // ═══════════════════════════════════════════════════════════════════════════
1166
+ describe("Scenario: Multi-script directory with hand-written .stash.json", () => {
1167
+ let stashDir;
1168
+ let scenarioCacheDir;
1169
+ beforeAll(async () => {
1170
+ stashDir = copyFixturesToTmp();
1171
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s5-"));
1172
+ process.env.AKM_STASH_DIR = stashDir;
1173
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1174
+ await akmIndex({ stashDir });
1175
+ });
1176
+ beforeEach(() => {
1177
+ process.env.AKM_STASH_DIR = stashDir;
1178
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1179
+ });
1180
+ afterAll(() => {
1181
+ fs.rmSync(stashDir, { recursive: true, force: true });
1182
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
1183
+ });
1184
+ test("docker/ directory exposes two scripts from single .stash.json", async () => {
1185
+ const stash = expectDefined(loadStashFile(path.join(stashDir, "scripts", "docker")));
1186
+ expect(stash.entries).toHaveLength(2);
1187
+ const names = stash.entries.map((e) => e.name);
1188
+ expect(names).toContain("docker-build");
1189
+ expect(names).toContain("docker-compose");
1190
+ });
1191
+ test("search for 'docker build' returns docker-build as top result", async () => {
1192
+ const result = await akmSearch({ query: "docker build" });
1193
+ expect(result.hits[0].name).toContain("docker");
1194
+ expect(result.hits[0].description).toContain("Docker image");
1195
+ });
1196
+ test("search for 'compose development' returns docker-compose", async () => {
1197
+ const result = await akmSearch({ query: "compose development" });
1198
+ const composeHit = result.hits.find((h) => h.name.includes("compose"));
1199
+ expect(composeHit).toBeDefined();
1200
+ expect(composeHit?.tags).toContain("compose");
1201
+ });
1202
+ });
1203
+ // ═══════════════════════════════════════════════════════════════════════════
1204
+ // Scenario 6: Index persistence and cache
1205
+ // ═══════════════════════════════════════════════════════════════════════════
1206
+ describe("Scenario: Index persistence across sessions", () => {
1207
+ let stashDir;
1208
+ let scenarioCacheDir;
1209
+ beforeAll(async () => {
1210
+ stashDir = copyFixturesToTmp();
1211
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s6-"));
1212
+ process.env.AKM_STASH_DIR = stashDir;
1213
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1214
+ });
1215
+ beforeEach(() => {
1216
+ process.env.AKM_STASH_DIR = stashDir;
1217
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1218
+ });
1219
+ afterAll(() => {
1220
+ fs.rmSync(stashDir, { recursive: true, force: true });
1221
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
1222
+ });
1223
+ test("index is persisted and loadable", async () => {
1224
+ await akmIndex({ stashDir });
1225
+ const db = openDatabase();
1226
+ const version = getMeta(db, "version");
1227
+ expect(version).toBe(String(DB_VERSION));
1228
+ const storedStashDir = getMeta(db, "stashDir");
1229
+ expect(storedStashDir).toBe(stashDir);
1230
+ const entries = getAllEntries(db);
1231
+ expect(entries.length).toBeGreaterThan(0);
1232
+ const builtAt = getMeta(db, "builtAt");
1233
+ expect(builtAt).toBeTruthy();
1234
+ closeDatabase(db);
1235
+ });
1236
+ test("search uses persisted index (simulates new session)", async () => {
1237
+ // First index
1238
+ await akmIndex({ stashDir });
1239
+ // Simulate a new session by just doing search (no re-index)
1240
+ const result = await akmSearch({ query: "docker" });
1241
+ expect(result.hits.length).toBeGreaterThan(0);
1242
+ // Should have scores from semantic search, not substring
1243
+ expect(result.hits[0].score).toBeDefined();
1244
+ });
1245
+ test("re-index updates the persisted index", async () => {
1246
+ await akmIndex({ stashDir });
1247
+ const db1 = openDatabase();
1248
+ const entries1 = getAllEntries(db1);
1249
+ const builtAt1 = expectDefined(getMeta(db1, "builtAt"));
1250
+ closeDatabase(db1);
1251
+ // Add a new script
1252
+ fs.mkdirSync(path.join(stashDir, "scripts", "new-script"), { recursive: true });
1253
+ fs.writeFileSync(path.join(stashDir, "scripts", "new-script", "hello.sh"), "#!/bin/bash\necho hello\n");
1254
+ await akmIndex({ stashDir });
1255
+ const db2 = openDatabase();
1256
+ const entries2 = getAllEntries(db2);
1257
+ const builtAt2 = expectDefined(getMeta(db2, "builtAt"));
1258
+ closeDatabase(db2);
1259
+ expect(entries2.length).toBeGreaterThan(entries1.length);
1260
+ expect(new Date(builtAt2).getTime()).toBeGreaterThanOrEqual(new Date(builtAt1).getTime());
1261
+ });
1262
+ });
1263
+ // ═══════════════════════════════════════════════════════════════════════════
1264
+ // Scenario 7: Error handling and edge cases
1265
+ // ═══════════════════════════════════════════════════════════════════════════
1266
+ describe("Scenario: Error handling and edge cases", () => {
1267
+ let savedStashDir;
1268
+ beforeEach(() => {
1269
+ savedStashDir = process.env.AKM_STASH_DIR;
1270
+ });
1271
+ afterEach(() => {
1272
+ if (savedStashDir === undefined) {
1273
+ delete process.env.AKM_STASH_DIR;
1274
+ }
1275
+ else {
1276
+ process.env.AKM_STASH_DIR = savedStashDir;
1277
+ }
1278
+ });
1279
+ test("search with non-existent AKM_STASH_DIR throws clear error", async () => {
1280
+ const orig = process.env.AKM_STASH_DIR;
1281
+ process.env.AKM_STASH_DIR = "/nonexistent/path";
1282
+ try {
1283
+ await expect(akmSearch({ query: "test" })).rejects.toThrow(/Unable to read/);
1284
+ }
1285
+ finally {
1286
+ if (orig === undefined)
1287
+ delete process.env.AKM_STASH_DIR;
1288
+ else
1289
+ process.env.AKM_STASH_DIR = orig;
1290
+ }
1291
+ });
1292
+ test("search with unset AKM_STASH_DIR throws clear error", async () => {
1293
+ const orig = process.env.AKM_STASH_DIR;
1294
+ const origHome = process.env.HOME;
1295
+ delete process.env.AKM_STASH_DIR;
1296
+ // Point HOME somewhere without an akm directory to force the "no stash" error
1297
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-nohome-"));
1298
+ process.env.HOME = tmpHome;
1299
+ try {
1300
+ await expect(akmSearch({ query: "test" })).rejects.toThrow(/No stash directory found/);
1301
+ }
1302
+ finally {
1303
+ if (orig === undefined)
1304
+ delete process.env.AKM_STASH_DIR;
1305
+ else
1306
+ process.env.AKM_STASH_DIR = orig;
1307
+ if (origHome === undefined)
1308
+ delete process.env.HOME;
1309
+ else
1310
+ process.env.HOME = origHome;
1311
+ fs.rmSync(tmpHome, { recursive: true, force: true });
1312
+ }
1313
+ });
1314
+ test("show with invalid ref format throws", async () => {
1315
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-err-"));
1316
+ process.env.AKM_STASH_DIR = stashDir;
1317
+ try {
1318
+ await expect(akmShow({ ref: "badref" })).rejects.toThrow(/Invalid ref/);
1319
+ }
1320
+ finally {
1321
+ fs.rmSync(stashDir, { recursive: true, force: true });
1322
+ }
1323
+ });
1324
+ test("show with unknown type throws", async () => {
1325
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-err-"));
1326
+ process.env.AKM_STASH_DIR = stashDir;
1327
+ try {
1328
+ await expect(akmShow({ ref: "widget:foo" })).rejects.toThrow(/Invalid asset type/);
1329
+ }
1330
+ finally {
1331
+ fs.rmSync(stashDir, { recursive: true, force: true });
1332
+ }
1333
+ });
1334
+ test("show with path traversal attempt throws", async () => {
1335
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-err-"));
1336
+ fs.mkdirSync(path.join(stashDir, "scripts"), { recursive: true });
1337
+ process.env.AKM_STASH_DIR = stashDir;
1338
+ try {
1339
+ await expect(akmShow({ ref: "script:../../etc/passwd" })).rejects.toThrow(/Path traversal/);
1340
+ }
1341
+ finally {
1342
+ fs.rmSync(stashDir, { recursive: true, force: true });
1343
+ }
1344
+ });
1345
+ test("search on empty stash returns no hits with tip", async () => {
1346
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-empty-"));
1347
+ for (const sub of ["scripts", "skills", "commands", "agents"]) {
1348
+ fs.mkdirSync(path.join(stashDir, sub), { recursive: true });
1349
+ }
1350
+ process.env.AKM_STASH_DIR = stashDir;
1351
+ try {
1352
+ const result = await akmSearch({ query: "anything" });
1353
+ expect(result.hits).toHaveLength(0);
1354
+ expect(result.tip).toBeTruthy();
1355
+ }
1356
+ finally {
1357
+ fs.rmSync(stashDir, { recursive: true, force: true });
1358
+ }
1359
+ });
1360
+ test("index on empty stash succeeds with zero entries", async () => {
1361
+ const stashDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-empty-"));
1362
+ for (const sub of ["scripts", "skills", "commands", "agents"]) {
1363
+ fs.mkdirSync(path.join(stashDir, sub), { recursive: true });
1364
+ }
1365
+ try {
1366
+ const result = await akmIndex({ stashDir });
1367
+ expect(result.totalEntries).toBe(0);
1368
+ expect(result.generatedMetadata).toBe(0);
1369
+ }
1370
+ finally {
1371
+ fs.rmSync(stashDir, { recursive: true, force: true });
1372
+ }
1373
+ });
1374
+ });
1375
+ // ═══════════════════════════════════════════════════════════════════════════
1376
+ // Scenario 8: Mixed asset type discovery
1377
+ // ═══════════════════════════════════════════════════════════════════════════
1378
+ describe("Scenario: Cross-type discovery", () => {
1379
+ let stashDir;
1380
+ let scenarioCacheDir;
1381
+ beforeAll(async () => {
1382
+ stashDir = copyFixturesToTmp();
1383
+ scenarioCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-e2e-cache-s8-"));
1384
+ process.env.AKM_STASH_DIR = stashDir;
1385
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1386
+ await akmIndex({ stashDir });
1387
+ });
1388
+ beforeEach(() => {
1389
+ process.env.AKM_STASH_DIR = stashDir;
1390
+ process.env.XDG_CACHE_HOME = scenarioCacheDir;
1391
+ });
1392
+ afterAll(() => {
1393
+ fs.rmSync(stashDir, { recursive: true, force: true });
1394
+ fs.rmSync(scenarioCacheDir, { recursive: true, force: true });
1395
+ });
1396
+ test("search 'any' type returns mixed results across scripts, skills, commands, agents", async () => {
1397
+ const result = await akmSearch({ query: "", type: "any" });
1398
+ const types = new Set(result.hits.map((h) => h.type));
1399
+ // Should have at least scripts and one other type
1400
+ expect(types.has("script")).toBe(true);
1401
+ expect(types.size).toBeGreaterThan(1);
1402
+ });
1403
+ test("each hit has a valid ref that can be used with show", async () => {
1404
+ const result = await akmSearch({ query: "", type: "any", limit: 10 });
1405
+ for (const hit of result.hits.filter(isLocalHit)) {
1406
+ expect(hit.ref).toBeTruthy();
1407
+ expect(hit.ref).toContain(":");
1408
+ // Should not throw when opening
1409
+ const openResult = await akmShow({ ref: expectDefined(hit.ref) });
1410
+ expect(openResult.type).toBe(hit.type);
1411
+ }
1412
+ });
1413
+ test("script hits have run", async () => {
1414
+ const result = await akmSearch({ query: "", type: "script" });
1415
+ for (const hit of result.hits) {
1416
+ expect(hit.type).toBe("script");
1417
+ }
1418
+ });
1419
+ });