akm-cli 0.8.6 → 0.8.14

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 (324) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/dist/assets/help/help-proposals.md +1 -2
  3. package/dist/assets/hints/cli-hints-full.md +34 -19
  4. package/dist/assets/hints/cli-hints-short.md +1 -1
  5. package/dist/assets/profiles/catchup.json +13 -0
  6. package/dist/assets/profiles/consolidate.json +13 -0
  7. package/dist/assets/profiles/frequent.json +13 -0
  8. package/dist/assets/tasks/core/backup.yml +4 -0
  9. package/dist/assets/tasks/core/extract.yml +4 -0
  10. package/dist/assets/tasks/core/improve.yml +4 -0
  11. package/dist/assets/tasks/core/index-refresh.yml +4 -0
  12. package/dist/assets/tasks/core/sync.yml +4 -0
  13. package/dist/assets/tasks/core/update-stashes.yml +4 -0
  14. package/dist/assets/tasks/core/version-check.yml +4 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +560 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/config-migrate.js +6 -6
  19. package/dist/cli/config-validate.js +4 -4
  20. package/dist/cli/confirm.js +3 -3
  21. package/dist/cli/parse-args.js +1 -1
  22. package/dist/cli/shared.js +72 -19
  23. package/dist/cli-node.mjs +26 -0
  24. package/dist/cli.js +206 -3866
  25. package/dist/commands/{agent-dispatch.js → agent/agent-dispatch.js} +6 -6
  26. package/dist/commands/{agent-support.js → agent/agent-support.js} +2 -2
  27. package/dist/commands/agent/contribute-cli.js +200 -0
  28. package/dist/commands/completions.js +1 -1
  29. package/dist/commands/config-cli.js +230 -3
  30. package/dist/commands/db-cli.js +2 -2
  31. package/dist/commands/env/env-cli.js +529 -0
  32. package/dist/commands/env/env.js +410 -0
  33. package/dist/commands/env/secret-cli.js +259 -0
  34. package/dist/commands/{secret.js → env/secret.js} +6 -47
  35. package/dist/commands/events.js +4 -4
  36. package/dist/commands/feedback-cli.js +18 -34
  37. package/dist/commands/graph/graph-cli.js +132 -0
  38. package/dist/commands/{graph.js → graph/graph.js} +22 -16
  39. package/dist/commands/health/checks.js +279 -0
  40. package/dist/commands/health/html-report.js +448 -0
  41. package/dist/commands/health.js +189 -266
  42. package/dist/commands/{consolidate.js → improve/consolidate.js} +63 -38
  43. package/dist/commands/{distill-promotion-policy.js → improve/distill-promotion-policy.js} +3 -3
  44. package/dist/commands/{distill.js → improve/distill.js} +39 -18
  45. package/dist/commands/{eval-cases.js → improve/eval-cases.js} +1 -1
  46. package/dist/commands/{extract-cli.js → improve/extract-cli.js} +4 -4
  47. package/dist/commands/{extract-prompt.js → improve/extract-prompt.js} +2 -2
  48. package/dist/commands/{extract.js → improve/extract.js} +221 -26
  49. package/dist/commands/{improve-auto-accept.js → improve/improve-auto-accept.js} +30 -4
  50. package/dist/commands/{improve-cli.js → improve/improve-cli.js} +44 -22
  51. package/dist/commands/{improve-profiles.js → improve/improve-profiles.js} +13 -7
  52. package/dist/commands/{improve-result-file.js → improve/improve-result-file.js} +1 -1
  53. package/dist/commands/{improve.js → improve/improve.js} +672 -292
  54. package/dist/{core → commands/improve/memory}/memory-belief.js +2 -2
  55. package/dist/{core → commands/improve/memory}/memory-contradiction-detect.js +5 -5
  56. package/dist/{core → commands/improve/memory}/memory-improve.js +4 -4
  57. package/dist/commands/improve/reflect-noise.js +0 -0
  58. package/dist/commands/{reflect.js → improve/reflect.js} +58 -28
  59. package/dist/commands/improve/session-asset.js +248 -0
  60. package/dist/commands/lint/agent-linter.js +1 -1
  61. package/dist/commands/lint/base-linter.js +55 -37
  62. package/dist/commands/lint/command-linter.js +1 -1
  63. package/dist/commands/lint/default-linter.js +1 -1
  64. package/dist/commands/lint/env-key-rules.js +1 -1
  65. package/dist/commands/lint/index.js +19 -25
  66. package/dist/commands/lint/knowledge-linter.js +1 -1
  67. package/dist/commands/lint/memory-linter.js +1 -1
  68. package/dist/commands/lint/registry.js +8 -8
  69. package/dist/commands/lint/skill-linter.js +1 -1
  70. package/dist/commands/lint/task-linter.js +1 -1
  71. package/dist/commands/lint/workflow-linter.js +1 -1
  72. package/dist/commands/lint.js +1 -1
  73. package/dist/commands/observability-cli.js +244 -0
  74. package/dist/commands/proposal/drain-policies.js +3 -3
  75. package/dist/commands/proposal/drain.js +87 -15
  76. package/dist/commands/proposal/proposal-cli.js +490 -0
  77. package/dist/commands/{proposal.js → proposal/proposal.js} +17 -6
  78. package/dist/commands/{propose.js → proposal/propose.js} +11 -11
  79. package/dist/{core → commands/proposal/validators}/proposal-quality-validators.js +8 -3
  80. package/dist/{core → commands/proposal/validators}/proposal-validators.js +5 -5
  81. package/dist/{core → commands/proposal/validators}/proposals.js +374 -345
  82. package/dist/commands/{curate.js → read/curate.js} +7 -7
  83. package/dist/commands/{knowledge.js → read/knowledge.js} +22 -9
  84. package/dist/commands/{registry-search.js → read/registry-search.js} +5 -5
  85. package/dist/commands/{remember-cli.js → read/remember-cli.js} +15 -7
  86. package/dist/commands/read/search-cli.js +207 -0
  87. package/dist/commands/{search.js → read/search.js} +22 -27
  88. package/dist/commands/{show.js → read/show.js} +31 -45
  89. package/dist/commands/registry-cli.js +8 -8
  90. package/dist/commands/remember.js +14 -10
  91. package/dist/commands/sources/add-cli.js +293 -0
  92. package/dist/commands/{history.js → sources/history.js} +27 -25
  93. package/dist/commands/{info.js → sources/info.js} +6 -6
  94. package/dist/commands/{init.js → sources/init.js} +6 -6
  95. package/dist/commands/{installed-stashes.js → sources/installed-stashes.js} +12 -12
  96. package/dist/commands/{migration-help.js → sources/migration-help.js} +3 -2
  97. package/dist/commands/{schema-repair.js → sources/schema-repair.js} +8 -8
  98. package/dist/commands/{self-update.js → sources/self-update.js} +10 -9
  99. package/dist/commands/{source-add.js → sources/source-add.js} +10 -10
  100. package/dist/commands/{source-clone.js → sources/source-clone.js} +7 -7
  101. package/dist/commands/{source-manage.js → sources/source-manage.js} +4 -4
  102. package/dist/commands/sources/sources-cli.js +305 -0
  103. package/dist/commands/sources/stash-cli.js +219 -0
  104. package/dist/commands/{stash-skeleton.js → sources/stash-skeleton.js} +2 -1
  105. package/dist/commands/tasks/default-tasks.js +173 -0
  106. package/dist/commands/tasks/tasks-cli.js +210 -0
  107. package/dist/commands/{tasks.js → tasks/tasks.js} +14 -14
  108. package/dist/commands/wiki-cli.js +307 -0
  109. package/dist/commands/workflow-cli.js +329 -0
  110. package/dist/core/action-contributors.js +1 -1
  111. package/dist/core/assert.js +40 -0
  112. package/dist/core/asset/asset-create.js +54 -0
  113. package/dist/core/{asset-ref.js → asset/asset-ref.js} +21 -4
  114. package/dist/core/{asset-registry.js → asset/asset-registry.js} +3 -3
  115. package/dist/core/{asset-spec.js → asset/asset-spec.js} +17 -31
  116. package/dist/core/{markdown.js → asset/markdown.js} +1 -1
  117. package/dist/core/{stash-meta.js → asset/stash-meta.js} +1 -1
  118. package/dist/core/best-effort.js +64 -0
  119. package/dist/core/common.js +32 -18
  120. package/dist/core/{config-io.js → config/config-io.js} +29 -19
  121. package/dist/core/{config-migration.js → config/config-migration.js} +11 -9
  122. package/dist/core/{config-schema.js → config/config-schema.js} +50 -7
  123. package/dist/core/config/config-types.js +16 -0
  124. package/dist/core/{config-walker.js → config/config-walker.js} +2 -2
  125. package/dist/core/{config.js → config/config.js} +10 -8
  126. package/dist/core/env-secret-ref.js +90 -0
  127. package/dist/core/errors.js +13 -3
  128. package/dist/core/events.js +27 -4
  129. package/dist/core/file-lock.js +1 -1
  130. package/dist/core/improve-types.js +48 -0
  131. package/dist/core/lesson-lint.js +2 -2
  132. package/dist/core/logs-db.js +304 -0
  133. package/dist/core/paths.js +2 -2
  134. package/dist/core/ripgrep/install.js +2 -2
  135. package/dist/core/ripgrep/resolve.js +2 -2
  136. package/dist/core/state-db.js +195 -60
  137. package/dist/core/text-truncation.js +148 -0
  138. package/dist/core/time.js +1 -1
  139. package/dist/core/write-source.js +98 -85
  140. package/dist/indexer/{db-backup.js → db/db-backup.js} +9 -24
  141. package/dist/indexer/{db.js → db/db.js} +128 -118
  142. package/dist/indexer/{graph-db.js → db/graph-db.js} +9 -4
  143. package/dist/indexer/{llm-cache.js → db/llm-cache.js} +15 -12
  144. package/dist/indexer/ensure-index.js +4 -4
  145. package/dist/indexer/{graph-boost.js → graph/graph-boost.js} +1 -1
  146. package/dist/indexer/{graph-extraction.js → graph/graph-extraction.js} +55 -13
  147. package/dist/indexer/indexer.js +37 -30
  148. package/dist/indexer/init.js +54 -0
  149. package/dist/indexer/manifest.js +10 -10
  150. package/dist/indexer/{memory-inference.js → passes/memory-inference.js} +141 -33
  151. package/dist/indexer/{metadata-contributors.js → passes/metadata-contributors.js} +10 -8
  152. package/dist/indexer/{metadata.js → passes/metadata.js} +15 -19
  153. package/dist/indexer/{staleness-detect.js → passes/staleness-detect.js} +53 -12
  154. package/dist/indexer/{db-search.js → search/db-search.js} +28 -16
  155. package/dist/indexer/{ranking-contributors.js → search/ranking-contributors.js} +1 -1
  156. package/dist/indexer/{ranking.js → search/ranking.js} +2 -2
  157. package/dist/indexer/{search-hit-enrichers.js → search/search-hit-enrichers.js} +3 -3
  158. package/dist/indexer/{search-source.js → search/search-source.js} +8 -8
  159. package/dist/indexer/{semantic-status.js → search/semantic-status.js} +3 -3
  160. package/dist/indexer/usage/unmigrated-vaults-guard.js +94 -0
  161. package/dist/indexer/{usage-events.js → usage/usage-events.js} +32 -0
  162. package/dist/indexer/{file-context.js → walk/file-context.js} +10 -15
  163. package/dist/indexer/{matchers.js → walk/matchers.js} +13 -9
  164. package/dist/indexer/{path-resolver.js → walk/path-resolver.js} +6 -6
  165. package/dist/indexer/{project-context.js → walk/project-context.js} +1 -1
  166. package/dist/indexer/{walker.js → walk/walker.js} +4 -3
  167. package/dist/integrations/agent/builder-shared.js +39 -0
  168. package/dist/integrations/agent/builders.js +14 -81
  169. package/dist/integrations/agent/config.js +6 -4
  170. package/dist/integrations/agent/detect.js +1 -1
  171. package/dist/integrations/agent/index.js +23 -8
  172. package/dist/integrations/agent/prompts.js +2 -3
  173. package/dist/integrations/agent/runner.js +22 -3
  174. package/dist/integrations/agent/spawn.js +9 -10
  175. package/dist/integrations/harnesses/claude/agent-builder.js +48 -0
  176. package/dist/integrations/harnesses/claude/config-import.js +70 -0
  177. package/dist/integrations/harnesses/claude/index.js +64 -0
  178. package/dist/integrations/{session-logs/providers/claude-code.js → harnesses/claude/session-log.js} +32 -5
  179. package/dist/integrations/harnesses/index.js +144 -0
  180. package/dist/integrations/harnesses/opencode/agent-builder.js +43 -0
  181. package/dist/integrations/harnesses/opencode/config-import.js +82 -0
  182. package/dist/integrations/harnesses/opencode/index.js +59 -0
  183. package/dist/integrations/{session-logs/providers/opencode.js → harnesses/opencode/session-log.js} +1 -1
  184. package/dist/integrations/harnesses/opencode-sdk/index.js +49 -0
  185. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +234 -0
  186. package/dist/integrations/harnesses/types.js +43 -0
  187. package/dist/integrations/lockfile.js +7 -16
  188. package/dist/integrations/session-logs/index.js +82 -9
  189. package/dist/llm/call-ai.js +4 -4
  190. package/dist/llm/client.js +146 -6
  191. package/dist/llm/embedder.js +6 -6
  192. package/dist/llm/embedders/local.js +9 -22
  193. package/dist/llm/embedders/remote.js +2 -2
  194. package/dist/llm/embedders/types.js +1 -1
  195. package/dist/llm/graph-extract.js +31 -12
  196. package/dist/llm/index-passes.js +1 -1
  197. package/dist/llm/memory-infer.js +12 -5
  198. package/dist/llm/metadata-enhance.js +2 -2
  199. package/dist/llm/usage-persist.js +77 -0
  200. package/dist/llm/usage-telemetry.js +103 -0
  201. package/dist/output/context.js +9 -46
  202. package/dist/output/html-render.js +73 -0
  203. package/dist/output/renderers.js +88 -58
  204. package/dist/output/shapes/curate.js +7 -3
  205. package/dist/output/shapes/distill.js +7 -3
  206. package/dist/output/shapes/env-list.js +18 -16
  207. package/dist/output/shapes/events.js +5 -4
  208. package/dist/output/shapes/helpers.js +19 -5
  209. package/dist/output/shapes/history.js +7 -3
  210. package/dist/output/shapes/passthrough.js +8 -11
  211. package/dist/output/shapes/{proposal-accept.js → proposal/accept.js} +7 -3
  212. package/dist/output/shapes/{proposal-diff.js → proposal/diff.js} +7 -3
  213. package/dist/output/shapes/{proposal-list.js → proposal/list.js} +7 -3
  214. package/dist/output/shapes/{proposal-producer.js → proposal/producer.js} +5 -4
  215. package/dist/output/shapes/{proposal-reject.js → proposal/reject.js} +7 -3
  216. package/dist/output/shapes/{proposal-show.js → proposal/show.js} +7 -3
  217. package/dist/output/shapes/registry-search.js +7 -3
  218. package/dist/output/shapes/registry.js +12 -0
  219. package/dist/output/shapes/search.js +7 -3
  220. package/dist/output/shapes/secret-list.js +18 -16
  221. package/dist/output/shapes/show.js +7 -3
  222. package/dist/output/shapes.js +55 -30
  223. package/dist/output/text/add.js +2 -3
  224. package/dist/output/text/clone.js +2 -3
  225. package/dist/output/text/config.js +2 -3
  226. package/dist/output/text/curate.js +4 -3
  227. package/dist/output/text/distill.js +2 -3
  228. package/dist/output/text/enable-disable.js +5 -4
  229. package/dist/output/text/env.js +13 -0
  230. package/dist/output/text/events.js +5 -4
  231. package/dist/output/text/feedback.js +4 -3
  232. package/dist/output/text/helpers.js +123 -40
  233. package/dist/output/text/history.js +2 -3
  234. package/dist/output/text/import.js +2 -3
  235. package/dist/output/text/index.js +2 -3
  236. package/dist/output/text/info.js +2 -3
  237. package/dist/output/text/init.js +2 -3
  238. package/dist/output/text/list.js +2 -3
  239. package/dist/output/text/proposal/producer.js +9 -0
  240. package/dist/output/text/proposal/proposal.js +13 -0
  241. package/dist/output/text/registry-commands.js +8 -7
  242. package/dist/output/text/registry.js +12 -0
  243. package/dist/output/text/remember.js +4 -3
  244. package/dist/output/text/remove.js +2 -3
  245. package/dist/output/text/save.js +2 -3
  246. package/dist/output/text/search.js +4 -3
  247. package/dist/output/text/show.js +4 -3
  248. package/dist/output/text/update.js +2 -3
  249. package/dist/output/text/upgrade.js +2 -3
  250. package/dist/output/text/wiki.js +12 -11
  251. package/dist/output/text/workflow.js +12 -10
  252. package/dist/output/text.js +66 -32
  253. package/dist/registry/build-index.js +11 -10
  254. package/dist/registry/factory.js +1 -1
  255. package/dist/registry/origin-resolve.js +1 -1
  256. package/dist/registry/providers/index.js +2 -2
  257. package/dist/registry/providers/skills-sh.js +91 -72
  258. package/dist/registry/providers/static-index.js +75 -52
  259. package/dist/registry/resolve.js +3 -3
  260. package/dist/runtime.js +242 -0
  261. package/dist/scripts/migrate-storage.js +1654 -683
  262. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +254 -168
  263. package/dist/setup/detect.js +311 -9
  264. package/dist/setup/harness-config-import.js +6 -120
  265. package/dist/setup/setup.js +454 -43
  266. package/dist/sources/include.js +1 -1
  267. package/dist/sources/provider-factory.js +2 -2
  268. package/dist/sources/providers/filesystem.js +3 -3
  269. package/dist/sources/providers/git.js +9 -9
  270. package/dist/sources/providers/index.js +4 -4
  271. package/dist/sources/providers/npm.js +6 -6
  272. package/dist/sources/providers/provider-utils.js +13 -20
  273. package/dist/sources/providers/sync-from-ref.js +5 -5
  274. package/dist/sources/providers/tar-utils.js +2 -2
  275. package/dist/sources/providers/website.js +2 -2
  276. package/dist/sources/resolve.js +5 -5
  277. package/dist/sources/website-ingest.js +5 -5
  278. package/dist/storage/database.js +102 -0
  279. package/dist/storage/engines/sqlite-migrations.js +42 -0
  280. package/dist/storage/locations.js +25 -0
  281. package/dist/storage/repositories/index-db.js +43 -0
  282. package/dist/storage/repositories/workflow-runs-repository.js +141 -0
  283. package/dist/tasks/backends/cron.js +4 -4
  284. package/dist/tasks/backends/exec-utils.js +32 -0
  285. package/dist/tasks/backends/index.js +3 -3
  286. package/dist/tasks/backends/launchd.js +7 -14
  287. package/dist/tasks/backends/schtasks.js +7 -16
  288. package/dist/tasks/embedded.js +71 -0
  289. package/dist/tasks/parser.js +2 -2
  290. package/dist/tasks/resolveAkmBin.js +1 -1
  291. package/dist/tasks/runner.js +127 -31
  292. package/dist/tasks/schedule.js +1 -1
  293. package/dist/tasks/validator.js +7 -7
  294. package/dist/text-import-hook.mjs +51 -0
  295. package/dist/version.js +2 -1
  296. package/dist/wiki/wiki.js +7 -7
  297. package/dist/workflows/{authoring.js → authoring/authoring.js} +6 -6
  298. package/dist/workflows/{scope-key.js → authoring/scope-key.js} +1 -1
  299. package/dist/workflows/cli.js +1 -1
  300. package/dist/workflows/db.js +54 -32
  301. package/dist/workflows/parser.js +4 -4
  302. package/dist/workflows/renderer.js +5 -5
  303. package/dist/workflows/runtime/agent-identity.js +56 -0
  304. package/dist/workflows/runtime/checkin.js +57 -0
  305. package/dist/workflows/{runs.js → runtime/runs.js} +197 -101
  306. package/dist/workflows/validate-summary.js +82 -0
  307. package/docs/README.md +1 -1
  308. package/docs/data-and-telemetry.md +6 -6
  309. package/package.json +17 -8
  310. package/dist/commands/add-cli.js +0 -279
  311. package/dist/commands/env.js +0 -213
  312. package/dist/integrations/agent/sdk-runner.js +0 -126
  313. package/dist/output/shapes/vault-list.js +0 -19
  314. package/dist/output/text/proposal-producer.js +0 -8
  315. package/dist/output/text/proposal.js +0 -12
  316. package/dist/output/text/vault.js +0 -16
  317. /package/dist/core/{asset-serialize.js → asset/asset-serialize.js} +0 -0
  318. /package/dist/core/{frontmatter.js → asset/frontmatter.js} +0 -0
  319. /package/dist/core/{config-sources.js → config/config-sources.js} +0 -0
  320. /package/dist/indexer/{graph-dedup.js → graph/graph-dedup.js} +0 -0
  321. /package/dist/{core/config-types.js → indexer/passes/pass-context.js} +0 -0
  322. /package/dist/indexer/{search-fields.js → search/search-fields.js} +0 -0
  323. /package/dist/indexer/{index-context.js → walk/index-context.js} +0 -0
  324. /package/dist/workflows/{document-cache.js → runtime/document-cache.js} +0 -0
package/dist/cli.js CHANGED
@@ -2,23 +2,28 @@
2
2
  // This Source Code Form is subject to the terms of the Mozilla Public
3
3
  // License, v. 2.0. If a copy of the MPL was not distributed with this
4
4
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
- // Runtime guard: akm-cli 0.8 is Bun-only. The `preinstall` hook in
6
- // package.json blocks `npm install`, but it does not protect against a
7
- // stale node-resolved shebang, a wrong PATH entry, or someone running
8
- // `node dist/cli.js` directly from a clone. In any of those cases the
9
- // next line `import { spawnSync } from "node:child_process";` — would
10
- // itself succeed under node, only to die a few imports later with a
11
- // confusing `ERR_MODULE_NOT_FOUND` for our extensionless internal paths.
12
- // Catch the wrong-runtime case here with a friendly message instead of
13
- // a stack trace. Cross-runtime support is planned for 0.9 (issue #465).
14
- if (typeof globalThis.Bun === "undefined") {
15
- console.error("\n ERROR: akm-cli 0.8 requires the Bun runtime (https://bun.sh) or the prebuilt binary.\n" +
16
- " Running under Node.js is not supported in this release.\n" +
17
- " Install options:\n" +
18
- " 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\n" +
19
- " 2. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n" +
20
- " Cross-runtime support is planned for 0.9.0.\n");
21
- process.exit(1);
5
+ // Runtime guard: akm-cli 0.9 runs on Bun (primary) and Node.js >= 20 (#465,
6
+ // #560). The runtime boundary (src/runtime.ts, src/storage/database.ts) makes
7
+ // the Node path additive. Under Node the CLI must be launched via the
8
+ // `dist/cli-node.mjs` wrapper, which registers the text-import loader hook
9
+ // before this module graph loads; running `node dist/cli.js` directly still
10
+ // works for code paths that touch no embedded text asset, but the wrapper is
11
+ // the supported entry. The hard floor is Node 20: `@clack/core` (prompts) imports
12
+ // `node:util`'s `styleText` (added in Node 20.12) Node 18 (EOL) throws at import.
13
+ {
14
+ const isBun = typeof globalThis.Bun !== "undefined";
15
+ if (!isBun) {
16
+ const major = Number.parseInt((process.versions.node ?? "0").split(".")[0], 10);
17
+ if (Number.isNaN(major) || major < 20) {
18
+ console.error("\n ERROR: akm-cli requires the Bun runtime (https://bun.sh) or Node.js >= 20.\n" +
19
+ ` Detected Node.js ${process.versions.node ?? "unknown"}.\n` +
20
+ " Install options:\n" +
21
+ " 1. Bun: curl -fsSL https://bun.sh/install | bash && bun install -g akm-cli\n" +
22
+ " 2. Node: upgrade to Node.js 20 or newer (https://nodejs.org)\n" +
23
+ " 3. Binary: curl -fsSL https://github.com/itlackey/akm/releases/latest/download/install.sh | bash\n");
24
+ process.exit(1);
25
+ }
26
+ }
22
27
  }
23
28
  // Global error handlers (#478) — route any async work outside the
24
29
  // `runWithJsonErrors` envelope through the same JSON shape so users never see
@@ -49,84 +54,42 @@ process.on("uncaughtException", (err) => {
49
54
  console.error(err.stack);
50
55
  process.exit(1);
51
56
  });
52
- import { spawnSync } from "node:child_process";
53
57
  import fs from "node:fs";
54
58
  import path from "node:path";
55
- import * as p from "@clack/prompts";
56
59
  import { defineCommand, runMain } from "citty";
57
- import { getStringArg, hasSubcommand, parsePositiveIntFlag } from "./cli/parse-args";
58
- import { EXIT_CODES, emitJsonError, output, parseAllFlagValues, runWithJsonErrors } from "./cli/shared";
59
- import { addCommand, buildWebsiteOptions } from "./commands/add-cli";
60
- import { akmAgentDispatch } from "./commands/agent-dispatch";
61
- import { generateBashCompletions, installBashCompletions } from "./commands/completions";
62
- import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./commands/config-cli";
63
- import { akmCurate } from "./commands/curate";
64
- import { akmDbBackups } from "./commands/db-cli";
65
- import { akmEventsList, akmEventsTail } from "./commands/events";
66
- import { extractCommand } from "./commands/extract-cli";
67
- import { feedbackCommand } from "./commands/feedback-cli";
68
- import { akmGraphEntities, akmGraphEntity, akmGraphExport, akmGraphOrphans, akmGraphRelated, akmGraphRelations, akmGraphSummary, akmGraphUpdate, } from "./commands/graph";
69
- import { akmHealth, parseWindowSpec, renderRunsDetailMd, renderWindowCompareMd, } from "./commands/health";
70
- import { akmHistory } from "./commands/history";
71
- import { improveCommand } from "./commands/improve-cli";
72
- import { assembleInfo } from "./commands/info";
73
- import { akmInit } from "./commands/init";
74
- import { akmListSources, akmRemove, akmUpdate } from "./commands/installed-stashes";
75
- import { readKnowledgeInput, writeMarkdownAsset } from "./commands/knowledge";
76
- import { akmLint } from "./commands/lint";
77
- import { renderMigrationHelp } from "./commands/migration-help";
78
- import { registryCommand } from "./commands/registry-cli";
79
- import { rememberCommand } from "./commands/remember-cli";
80
- /**
81
- * Resolve the event source from the environment. When `AKM_EVENT_SOURCE` is
82
- * set (e.g. by `akm improve` for agent subprocesses), events are tagged so
83
- * they can be filtered out of user-facing history.
84
- */
85
- function resolveEventSource() {
86
- const raw = process.env.AKM_EVENT_SOURCE;
87
- if (raw === "improve")
88
- return "improve";
89
- if (raw === "user")
90
- return "user";
91
- return undefined;
92
- }
93
- import { resolveImproveProfile } from "./commands/improve-profiles";
94
- import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalRevert, akmProposalShow, } from "./commands/proposal";
95
- import { drainProposals } from "./commands/proposal/drain";
96
- import { resolveDrainPolicy } from "./commands/proposal/drain-policies";
97
- import { akmPropose } from "./commands/propose";
98
- import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
99
- import { checkForUpdate, performUpgrade } from "./commands/self-update";
100
- import { akmShowUnified, normalizeShowArgv } from "./commands/show";
101
- import { akmClone } from "./commands/source-clone";
102
- import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./commands/tasks";
103
- import { parseAssetRef } from "./core/asset-ref";
104
- import { deriveCanonicalAssetName, resolveAssetPathFromName } from "./core/asset-spec";
105
- import { isHttpUrl, isWithin, resolveStashDir, writeFileAtomic } from "./core/common";
106
- import { DEFAULT_CONFIG, loadConfig, loadUserConfig, resolveConfiguredSources, saveConfig } from "./core/config";
107
- import { ConfigError, NotFoundError, UsageError } from "./core/errors";
108
- import { appendEvent } from "./core/events";
109
- import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
110
- import { parseMetaRef } from "./core/stash-meta";
111
- import { plainize } from "./core/tty";
112
- import { clearLogFile, info, isQuiet, isVerbose, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
113
- import { closeDatabase, openExistingDatabase } from "./indexer/db";
114
- import { akmIndex } from "./indexer/indexer";
115
- import { resolveSourceEntries } from "./indexer/search-source";
116
- import { resolveTriageJudgmentRunner } from "./integrations/agent/runner";
117
- import { EMBEDDED_HINTS, EMBEDDED_HINTS_FULL } from "./output/cli-hints";
118
- import { getHyphenatedArg, getHyphenatedBoolean, getOutputMode, hasBooleanFlag, initOutputMode, parseDetailLevel, parseFlagValue, } from "./output/context";
119
- import { formatEventLine } from "./output/text";
120
- import { resolveSourcesForOrigin } from "./registry/origin-resolve";
121
- import { resolveWritableOverride, saveGitStash } from "./sources/providers/git";
122
- import { resolveAssetPath } from "./sources/resolve";
123
- import { pkgVersion } from "./version";
124
- import { createWorkflowAsset, formatWorkflowErrors, getWorkflowTemplate, validateWorkflowSource, } from "./workflows/authoring";
125
- import { hasWorkflowSubcommand, parseWorkflowJsonObject, parseWorkflowStepState, WORKFLOW_STEP_STATES, } from "./workflows/cli";
126
- import { completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, listWorkflowRuns, resumeWorkflowRun, startWorkflowRun, } from "./workflows/runs";
127
- const SKILLS_SH_NAME = "skills.sh";
128
- const SKILLS_SH_URL = "https://skills.sh";
129
- const SKILLS_SH_PROVIDER = "skills-sh";
60
+ import { EXIT_CODES, emitJsonError, output, parseAllFlagValues, runWithJsonErrors } from "./cli/shared.js";
61
+ import { agentCommand, lintCommand, proposeCommand } from "./commands/agent/contribute-cli.js";
62
+ import { generateBashCompletions, installBashCompletions } from "./commands/completions.js";
63
+ import { configCommand } from "./commands/config-cli.js";
64
+ import { envCommand } from "./commands/env/env-cli.js";
65
+ import { secretCommand } from "./commands/env/secret-cli.js";
66
+ import { feedbackCommand } from "./commands/feedback-cli.js";
67
+ import { graphCommand } from "./commands/graph/graph-cli.js";
68
+ import { akmHealth, parseWindowSpec, renderRunsDetailMd, renderWindowCompareMd, } from "./commands/health.js";
69
+ import { extractCommand } from "./commands/improve/extract-cli.js";
70
+ import { improveCommand } from "./commands/improve/improve-cli.js";
71
+ import { hintsCommand, lessonsCommand, logCommand } from "./commands/observability-cli.js";
72
+ import { proposalCommand } from "./commands/proposal/proposal-cli.js";
73
+ import { rememberCommand } from "./commands/read/remember-cli.js";
74
+ import { curateCommand, searchCommand, showCommand } from "./commands/read/search-cli.js";
75
+ import { normalizeShowArgv } from "./commands/read/show.js";
76
+ import { registryCommand } from "./commands/registry-cli.js";
77
+ import { addCommand } from "./commands/sources/add-cli.js";
78
+ import { renderMigrationHelp } from "./commands/sources/migration-help.js";
79
+ import { cloneCommand, historyCommand, listCommand, removeCommand, syncCommand, updateCommand, upgradeCommand, } from "./commands/sources/sources-cli.js";
80
+ import { dbCommand, importKnowledgeCommand, indexCommand, infoCommand, initCommand, } from "./commands/sources/stash-cli.js";
81
+ import { tasksCommand } from "./commands/tasks/tasks-cli.js";
82
+ import { wikiCommand } from "./commands/wiki-cli.js";
83
+ import { workflowCommand } from "./commands/workflow-cli.js";
84
+ import { bestEffort } from "./core/best-effort.js";
85
+ import { loadConfig } from "./core/config/config.js";
86
+ import { UsageError } from "./core/errors.js";
87
+ import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
88
+ import { plainize } from "./core/tty.js";
89
+ import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
90
+ import { getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue } from "./output/context.js";
91
+ import { deliverRendered, renderHtml, resolveTemplatePath } from "./output/html-render.js";
92
+ import { pkgVersion } from "./version.js";
130
93
  function applyEarlyStderrFlags(argv) {
131
94
  if (argv.includes("--quiet") || argv.includes("-q")) {
132
95
  setQuiet(true);
@@ -231,10 +194,36 @@ const setupCommand = defineCommand({
231
194
  default: false,
232
195
  description: "Probe LLM/embedding endpoints after writing config to verify connectivity",
233
196
  },
197
+ "detect-only": {
198
+ type: "boolean",
199
+ default: false,
200
+ description: "Run environment detection only and print the result (no prompts, no writes). Pair with --format json.",
201
+ },
202
+ "reset-recommended": {
203
+ type: "boolean",
204
+ default: false,
205
+ description: "Merge opinionated, detection-derived defaults into the existing config without removing custom keys.",
206
+ },
234
207
  },
235
208
  async run({ args }) {
236
209
  await runWithJsonErrors(async () => {
237
210
  const noInit = getHyphenatedBoolean(args, "no-init");
211
+ const detectOnly = getHyphenatedBoolean(args, "detect-only");
212
+ const resetRecommended = getHyphenatedBoolean(args, "reset-recommended");
213
+ if (detectOnly) {
214
+ // Detection only: no prompts, no writes.
215
+ const { runDetectOnly } = await import("./setup/setup.js");
216
+ const detection = await runDetectOnly();
217
+ output("setup", detection);
218
+ return;
219
+ }
220
+ if (resetRecommended) {
221
+ const { runResetRecommended } = await import("./setup/setup.js");
222
+ const result = await runResetRecommended({ dir: args.dir, noInit, probe: args.probe });
223
+ output("setup", result);
224
+ printSetupTtyHint(result);
225
+ return;
226
+ }
238
227
  if (args.from && args.config) {
239
228
  throw new UsageError("Pass either --from <file> or --config <json>, not both.", "INVALID_FLAG_VALUE");
240
229
  }
@@ -243,32 +232,37 @@ const setupCommand = defineCommand({
243
232
  // `~`, resolves relative paths against cwd, picks the YAML or JSON
244
233
  // parser based on the file extension, and surfaces any
245
234
  // read/parse/shape errors as ConfigError("INVALID_CONFIG_FILE").
246
- const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup");
235
+ // `runSetupFromConfig` is fully non-interactive; with `--yes` it also
236
+ // fills defaults for keys the file leaves missing.
237
+ const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup.js");
247
238
  const loaded = await loadSetupConfigFromFile(args.from);
248
239
  const result = await runSetupFromConfig({
249
240
  configJson: loaded.configJson,
250
241
  dir: args.dir,
251
242
  noInit,
252
243
  probe: args.probe,
244
+ applyDefaults: args.yes,
253
245
  });
254
246
  output("setup", result);
255
247
  printSetupTtyHint(result);
256
248
  }
257
249
  else if (args.config) {
258
- // Non-interactive config mode
259
- const { runSetupFromConfig } = await import("./setup/setup");
250
+ // Non-interactive config mode. With `--yes`, defaults fill any keys
251
+ // the JSON blob leaves missing after the deep merge.
252
+ const { runSetupFromConfig } = await import("./setup/setup.js");
260
253
  const result = await runSetupFromConfig({
261
254
  configJson: args.config,
262
255
  dir: args.dir,
263
256
  noInit,
264
257
  probe: args.probe,
258
+ applyDefaults: args.yes,
265
259
  });
266
260
  output("setup", result);
267
261
  printSetupTtyHint(result);
268
262
  }
269
263
  else if (args.yes) {
270
264
  // Defaults mode — no prompts
271
- const { runSetupWithDefaults } = await import("./setup/setup");
265
+ const { runSetupWithDefaults } = await import("./setup/setup.js");
272
266
  const result = await runSetupWithDefaults({
273
267
  dir: args.dir,
274
268
  noInit,
@@ -279,112 +273,12 @@ const setupCommand = defineCommand({
279
273
  }
280
274
  else {
281
275
  // Interactive wizard
282
- const { runSetupWizard } = await import("./setup/setup");
276
+ const { runSetupWizard } = await import("./setup/setup.js");
283
277
  await runSetupWizard({ dir: args.dir, noInit });
284
278
  }
285
279
  });
286
280
  },
287
281
  });
288
- const initCommand = defineCommand({
289
- meta: {
290
- name: "init",
291
- description: "Initialize akm's working stash directory and persist stashDir in config",
292
- },
293
- args: {
294
- dir: { type: "string", description: "Custom stash directory path (default: ~/akm)" },
295
- },
296
- async run({ args }) {
297
- await runWithJsonErrors(async () => {
298
- // Accept both historical spellings for backwards compatibility with
299
- // older docs/scripts that used `--stashDir`.
300
- const legacyDir = parseFlagValue(process.argv, "--stashDir") ?? parseFlagValue(process.argv, "--stash-dir");
301
- const result = await akmInit({ dir: args.dir ?? legacyDir });
302
- output("init", result);
303
- });
304
- },
305
- });
306
- const indexCommand = defineCommand({
307
- meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
308
- args: {
309
- full: { type: "boolean", description: "Force full reindex", default: false },
310
- clean: {
311
- type: "boolean",
312
- description: "After indexing, remove any entries whose source file no longer exists on disk.",
313
- default: false,
314
- },
315
- "dry-run": {
316
- type: "boolean",
317
- description: "When combined with --clean, report stale entries without deleting them.",
318
- default: false,
319
- },
320
- },
321
- async run({ args }) {
322
- await runWithJsonErrors(async () => {
323
- if (getHyphenatedBoolean(args, "enrich") || parseFlagValue(process.argv, "--enrich") !== undefined) {
324
- throw new UsageError("`akm index --enrich` has been removed. Plain `akm index` now performs metadata enrichment by default.");
325
- }
326
- if (getHyphenatedBoolean(args, "re-enrich") || parseFlagValue(process.argv, "--re-enrich") !== undefined) {
327
- throw new UsageError("`akm index --re-enrich` has been removed. Re-enrichment of index-time LLM passes is not exposed in this slice.");
328
- }
329
- const outputMode = getOutputMode();
330
- const controller = new AbortController();
331
- const abort = () => controller.abort(new Error("index interrupted"));
332
- process.once("SIGINT", abort);
333
- process.once("SIGTERM", abort);
334
- const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
335
- setLogFile(indexLogFile);
336
- const verbose = isVerbose();
337
- const spin = !verbose && outputMode.format === "text" ? p.spinner() : null;
338
- if (spin) {
339
- spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
340
- }
341
- let latestMessage = "";
342
- try {
343
- const result = await akmIndex({
344
- full: args.full,
345
- clean: args.clean,
346
- dryRun: args["dry-run"],
347
- onProgress: ({ phase, message, processed, total }) => {
348
- latestMessage = message;
349
- const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : "";
350
- if (verbose) {
351
- info(`[index:${phase}] ${progressPrefix}${message}`);
352
- }
353
- else if (spin) {
354
- spin.stop(`${progressPrefix}${message}`);
355
- spin.start(`${progressPrefix}${message}`);
356
- }
357
- },
358
- signal: controller.signal,
359
- });
360
- if (spin) {
361
- spin.stop(`Indexed ${result.totalEntries} assets.`);
362
- }
363
- output("index", result);
364
- }
365
- catch (error) {
366
- if (spin) {
367
- spin.stop(latestMessage ? `Indexing failed after: ${latestMessage}` : "Indexing failed.");
368
- }
369
- throw error;
370
- }
371
- finally {
372
- clearLogFile();
373
- process.off("SIGINT", abort);
374
- process.off("SIGTERM", abort);
375
- }
376
- });
377
- },
378
- });
379
- const infoCommand = defineCommand({
380
- meta: { name: "info", description: "Show system capabilities, configuration, and index stats" },
381
- run() {
382
- return runWithJsonErrors(() => {
383
- const result = assembleInfo();
384
- output("info", result);
385
- });
386
- },
387
- });
388
282
  const healthCommand = defineCommand({
389
283
  meta: { name: "health", description: "Check akm runtime health, artifacts, and improve metrics" },
390
284
  args: {
@@ -396,10 +290,6 @@ const healthCommand = defineCommand({
396
290
  type: "string",
397
291
  description: "Group rows by: run (one row per improve_runs entry). Omit for the default summary.",
398
292
  },
399
- detail: {
400
- type: "string",
401
- description: "DEPRECATED: use --group-by run instead of --detail per-run (removed 0.9.0).",
402
- },
403
293
  "window-compare": {
404
294
  type: "string",
405
295
  description: "Compare current window vs prior window of the same duration (e.g. 24h, 7d, 30m)",
@@ -408,35 +298,43 @@ const healthCommand = defineCommand({
408
298
  type: "string",
409
299
  description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
410
300
  },
301
+ compare: {
302
+ type: "string",
303
+ description: "Comparison window for the --format html report's trend deltas (default: 24h)",
304
+ },
411
305
  },
412
306
  async run({ args }) {
413
307
  let resultStatus;
414
- await runWithJsonErrors(() => {
308
+ await runWithJsonErrors(async () => {
415
309
  // citty only surfaces the last value of a repeated flag, so read --windows
416
310
  // directly from argv to support multi-window comparison.
417
311
  const rawWindows = parseAllFlagValues("--windows");
418
312
  const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
419
- const groupByRaw = args["group-by"];
420
- const detailRaw = args.detail;
421
- // Back-compat: `--detail per-run` → `--group-by run` (warns; removed 0.9.0).
422
- let groupBy = groupByRaw;
423
- if (detailRaw !== undefined) {
424
- if (detailRaw === "per-run") {
425
- // Read --quiet from argv (not the warn-module singleton) so the
426
- // warning fires correctly even when the early-stderr flags were not
427
- // applied (e.g. the in-process test harness), matching the WS2
428
- // output-flag deprecations in src/output/context.ts.
429
- const quietRequested = process.argv.includes("--quiet") || process.argv.includes("-q");
430
- if (!quietRequested) {
431
- process.stderr.write("warning: '--detail per-run' is deprecated for 'akm health'; use '--group-by run'. Removed in 0.9.0.\n");
432
- }
433
- groupBy = groupBy ?? "run";
434
- }
435
- else {
436
- throw new UsageError(`Invalid value for --detail: ${detailRaw}. 'akm health' uses --group-by run (not --detail).`, "INVALID_DETAIL_VALUE");
437
- }
438
- }
313
+ const groupBy = args["group-by"];
439
314
  const windowCompareRaw = args["window-compare"];
315
+ const mode = getOutputMode();
316
+ // `--format html` is health-specific: render the full HTML health
317
+ // report (charts, KPI cards, advisories) from the bespoke template.
318
+ // Mirrors the `md` intercept below. Two reads, exactly like the
319
+ // retired akm-health-report skill: the canonical per-run window plus a
320
+ // window-compare read for the trend deltas (defaults to 24h,
321
+ // overridable via --compare).
322
+ if (mode.format === "html") {
323
+ const compare = args.compare ?? windowCompareRaw ?? "24h";
324
+ const result = akmHealth({ since: args.since, groupBy: "run" });
325
+ resultStatus = result.status;
326
+ const deltas = akmHealth({ since: args.since, windowCompare: compare }).deltas;
327
+ const { buildHealthHtmlReplacements } = await import("./commands/health/html-report.js");
328
+ const { listPendingProposals } = await import("./commands/proposal/proposal.js");
329
+ const replacements = buildHealthHtmlReplacements(result, {
330
+ window: args.since ?? "24h",
331
+ compare,
332
+ proposals: listPendingProposals(),
333
+ deltas,
334
+ });
335
+ deliverRendered(renderHtml(resolveTemplatePath("health"), replacements), mode.outputPath);
336
+ return;
337
+ }
440
338
  const result = akmHealth({
441
339
  since: args.since,
442
340
  groupBy: groupBy,
@@ -447,13 +345,12 @@ const healthCommand = defineCommand({
447
345
  // `--format md` is health-specific: render a TSV-shaped per-run or
448
346
  // window-compare table to stdout instead of going through the JSON
449
347
  // envelope. Other modes fall through to the standard output() path.
450
- const mode = getOutputMode();
451
348
  if (mode.format === "md") {
452
349
  if (result.windows && result.windows.length > 0) {
453
- console.log(renderWindowCompareMd(result.windows, result.deltas));
350
+ deliverRendered(renderWindowCompareMd(result.windows, result.deltas), mode.outputPath);
454
351
  }
455
352
  else if (result.runs) {
456
- console.log(renderRunsDetailMd(result.runs));
353
+ deliverRendered(renderRunsDetailMd(result.runs), mode.outputPath);
457
354
  }
458
355
  else {
459
356
  output("health", result);
@@ -471,3624 +368,110 @@ const healthCommand = defineCommand({
471
368
  }
472
369
  },
473
370
  });
474
- const graphCommand = defineCommand({
475
- meta: { name: "graph", description: "Inspect the indexed entity graph stored in SQLite" },
371
+ const helpCommand = defineCommand({
372
+ meta: {
373
+ name: "help",
374
+ description: "Print focused help topics such as migration guidance for a release",
375
+ },
476
376
  subCommands: {
477
- summary: defineCommand({
478
- meta: { name: "summary", description: "Show entity-graph counts and quality telemetry" },
479
- args: {
480
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
481
- },
482
- run({ args }) {
483
- return runWithJsonErrors(() => {
484
- output("graph-summary", akmGraphSummary({ source: args.source }));
485
- });
486
- },
487
- }),
488
- entities: defineCommand({
489
- meta: { name: "entities", description: "List entities with per-file occurrence counts" },
490
- args: {
491
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
492
- limit: { type: "string", description: "Maximum entities to return" },
493
- },
494
- run({ args }) {
495
- return runWithJsonErrors(() => {
496
- output("graph-entities", akmGraphEntities({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
497
- });
498
- },
499
- }),
500
- relations: defineCommand({
501
- meta: { name: "relations", description: "List relations with occurrence counts" },
502
- args: {
503
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
504
- limit: { type: "string", description: "Maximum relations to return" },
505
- },
506
- run({ args }) {
507
- return runWithJsonErrors(() => {
508
- output("graph-relations", akmGraphRelations({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
509
- });
510
- },
511
- }),
512
- related: defineCommand({
513
- meta: { name: "related", description: "Show graph-related neighboring assets for a ref" },
514
- args: {
515
- ref: { type: "positional", description: "Asset ref", required: true },
516
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
517
- limit: { type: "string", description: "Maximum related assets to return" },
518
- },
519
- async run({ args }) {
520
- return runWithJsonErrors(async () => {
521
- output("graph-related", await akmGraphRelated({
522
- ref: args.ref ?? "",
523
- source: args.source,
524
- limit: parsePositiveIntFlag(args.limit ?? undefined),
525
- }));
526
- });
527
- },
528
- }),
529
- entity: defineCommand({
530
- meta: { name: "entity", description: "List assets that contain the given entity" },
531
- args: {
532
- name: { type: "positional", description: "Entity name", required: true },
533
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
534
- limit: { type: "string", description: "Maximum matches to return" },
535
- },
536
- run({ args }) {
537
- return runWithJsonErrors(() => {
538
- output("graph-entity", akmGraphEntity({
539
- name: args.name ?? "",
540
- source: args.source,
541
- limit: parsePositiveIntFlag(args.limit ?? undefined),
542
- }));
543
- });
544
- },
545
- }),
546
- orphans: defineCommand({
547
- meta: { name: "orphans", description: "List assets with no extracted graph entities" },
548
- args: {
549
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
550
- limit: { type: "string", description: "Maximum orphans to return" },
551
- },
552
- run({ args }) {
553
- return runWithJsonErrors(() => {
554
- output("graph-orphans", akmGraphOrphans({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
555
- });
556
- },
557
- }),
558
- export: defineCommand({
559
- meta: { name: "export", description: "Export graph artifact as JSON or JSONL" },
560
- args: {
561
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
562
- out: { type: "string", description: "Output path" },
563
- format: { type: "string", description: "Export format (json|jsonl)", default: "json" },
564
- },
565
- run({ args }) {
566
- return runWithJsonErrors(() => {
567
- output("graph-export", akmGraphExport({
568
- source: args.source,
569
- out: args.out ?? "",
570
- format: args.format,
571
- }));
572
- });
377
+ migrate: defineCommand({
378
+ meta: {
379
+ name: "migrate",
380
+ description: "Print release notes and migration guidance for a version. Bundled notes live in docs/migration/release-notes/<version>.md; an unknown version lists what's available.",
573
381
  },
574
- }),
575
- update: defineCommand({
576
- meta: { name: "update", description: "Re-run graph extraction, optionally scoped to specific asset refs" },
577
382
  args: {
578
- refs: {
383
+ // Optional in citty so run() is invoked even when omitted; we
384
+ // re-validate below to surface a structured UsageError (exit 2)
385
+ // instead of citty's default help-banner exit-0.
386
+ version: {
579
387
  type: "positional",
580
- description: "Zero or more asset refs to scope extraction (omit for a full re-extract)",
388
+ description: "Version to review (for example 0.6.0, v0.6.0, 0.6.0-rc1, or latest)",
581
389
  required: false,
582
- default: "",
583
390
  },
584
- source: { type: "string", description: "Source name/path (default: primary stash source)" },
585
391
  },
586
- async run({ args }) {
587
- return runWithJsonErrors(async () => {
588
- // `refs` is a single positional; collect remaining argv tokens as well.
589
- const rawRefs = [args.refs, ...(Array.isArray(args._) ? args._ : [])].filter((r) => typeof r === "string" && r.trim().length > 0);
590
- output("graph-update", await akmGraphUpdate({ refs: rawRefs.length > 0 ? rawRefs : undefined, source: args.source }));
392
+ run({ args }) {
393
+ return runWithJsonErrors(() => {
394
+ const version = resolveHelpMigrateVersionArg(typeof args.version === "string" ? args.version : undefined);
395
+ if (!version?.trim()) {
396
+ throw new UsageError("Usage: akm help migrate <version>.", "MISSING_REQUIRED_ARGUMENT", "Pass a version like `0.6.0`, `v0.6.0`, `0.6.0-rc1`, or `latest`.");
397
+ }
398
+ process.stdout.write(renderMigrationHelp(version));
591
399
  });
592
400
  },
593
401
  }),
594
402
  },
595
- run({ args }) {
596
- return runWithJsonErrors(() => {
597
- if (hasSubcommand(args, GRAPH_SUBCOMMAND_SET))
598
- return;
599
- output("graph-summary", akmGraphSummary());
600
- });
601
- },
602
403
  });
603
- // MVP DB administration. Currently only `akm db backups`; restore is manual —
604
- // stop akm and run `scripts/migrations/restore-data-dir.sh <backup>`.
605
- const DB_SUBCOMMAND_SET = new Set(["backups"]);
606
- const dbCommand = defineCommand({
404
+ const completionsCommand = defineCommand({
607
405
  meta: {
608
- name: "db",
609
- description: "Inspect the AKM SQLite data directory. Currently exposes `backups`; to restore from a snapshot, stop akm and run scripts/migrations/restore-data-dir.sh against the chosen backup.",
610
- },
611
- subCommands: {
612
- backups: defineCommand({
613
- meta: {
614
- name: "backups",
615
- description: "List pre-upgrade snapshots of the data directory (newest first). Backups are created automatically before destructive DB version upgrades unless AKM_DB_BACKUP=0.",
616
- },
617
- run() {
618
- return runWithJsonErrors(() => {
619
- output("db-backups", akmDbBackups());
620
- });
621
- },
622
- }),
623
- },
624
- run({ args }) {
625
- return runWithJsonErrors(() => {
626
- if (hasSubcommand(args, DB_SUBCOMMAND_SET))
627
- return;
628
- // Default action: list backups.
629
- output("db-backups", akmDbBackups());
630
- });
406
+ name: "completions",
407
+ description: "Generate or install shell completion script",
631
408
  },
632
- });
633
- const searchCommand = defineCommand({
634
- meta: { name: "search", description: "Search the stash" },
635
409
  args: {
636
- query: { type: "positional", description: "Search query (omit to list all assets)", required: false, default: "" },
637
- type: {
638
- type: "string",
639
- description: "Asset type filter (skill, command, agent, knowledge, workflow, script, memory, vault, wiki, lesson, or any). Use workflow to find step-by-step task assets.",
640
- },
641
- limit: { type: "string", description: "Maximum number of results" },
642
- source: { type: "string", description: "Search source (stash|registry|both)", default: "stash" },
643
- filter: {
644
- type: "string",
645
- description: "Scope filter (repeatable): --filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>. Narrows results without changing ranking.",
646
- },
647
- "include-proposed": {
648
- type: "boolean",
649
- description: 'Include entries with quality:"proposed" in the result set. Excluded by default (v1 spec §4.2).',
650
- default: false,
651
- },
652
- belief: {
653
- type: "string",
654
- description: "Memory belief filter: all|current|historical. current keeps active memory beliefs; historical keeps contradicted/superseded/archived memory beliefs.",
655
- default: "all",
656
- },
657
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
658
- detail: { type: "string", description: "Detail level (brief|normal|full)" },
659
- "no-project-context": {
410
+ install: {
660
411
  type: "boolean",
661
- description: "Disable the automatic project-context ranking boost (also disabled by AKM_DISABLE_PROJECT_CONTEXT=1).",
412
+ description: "Install completions to the appropriate directory",
662
413
  default: false,
663
414
  },
664
- },
665
- async run({ args }) {
666
- await runWithJsonErrors(async () => {
667
- const query = (args.query ?? "").trim();
668
- if (!query) {
669
- throw new UsageError('A search query is required. Usage: akm search "<query>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Pass a query like `akm search "docker"` or `akm search "code review" --type skill`.');
670
- }
671
- const type = args.type;
672
- const limit = parsePositiveIntFlag(args.limit ?? undefined);
673
- const source = parseSearchSource(args.source);
674
- // Repeatable; citty exposes only the last `--filter` value, so read all
675
- // occurrences directly from argv (same pattern as `--tag`).
676
- const filterTokens = parseAllFlagValues("--filter");
677
- const filters = parseScopeFilterFlags(filterTokens, "--filter");
678
- const includeProposed = args["include-proposed"] === true;
679
- const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
680
- const noProjectContext = getHyphenatedBoolean(args, "no-project-context");
681
- // --no-project-context sets env so searchDatabase picks it up without
682
- // threading the flag through the entire call stack.
683
- if (noProjectContext)
684
- process.env.AKM_DISABLE_PROJECT_CONTEXT = "1";
685
- const result = await akmSearch({
686
- query,
687
- type,
688
- limit,
689
- source,
690
- filters,
691
- includeProposed,
692
- belief,
693
- eventSource: resolveEventSource(),
694
- });
695
- output("search", result);
696
- });
697
- },
698
- });
699
- const curateCommand = defineCommand({
700
- meta: { name: "curate", description: "Curate the best matching assets for a task or prompt" },
701
- args: {
702
- // Optional in citty so run() is invoked when omitted; we re-validate
703
- // below to surface a structured UsageError (exit 2) instead of citty's
704
- // default help-banner exit-0.
705
- query: { type: "positional", description: "Task or prompt to curate assets for", required: false },
706
- type: {
415
+ shell: {
707
416
  type: "string",
708
- description: "Asset type filter (skill, command, agent, knowledge, workflow, script, memory, vault, wiki, lesson, or any). Use workflow to curate step-by-step task assets.",
417
+ description: "Shell type (bash)",
418
+ default: "bash",
709
419
  },
710
- limit: { type: "string", description: "Maximum number of curated results", default: "4" },
711
- source: { type: "string", description: "Search source (stash|registry|both)", default: "stash" },
712
- // Output-contract flags. The active values are read from the process-level
713
- // singleton (parsed from argv at startup); these declarations make them
714
- // visible in `akm curate --help` and document the supported axes.
715
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
716
- detail: { type: "string", description: "Detail level (brief|normal|full)" },
717
- shape: { type: "string", description: "Output projection (human|agent)" },
718
- },
719
- async run({ args }) {
720
- await runWithJsonErrors(async () => {
721
- if (!args.query || !String(args.query).trim()) {
722
- throw new UsageError('A curate query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", 'Describe the task you want assets for, e.g. `akm curate "deploy to prod"`.');
723
- }
724
- const type = args.type;
725
- const limitParsed = parsePositiveIntFlag(args.limit ?? undefined);
726
- const limit = limitParsed && limitParsed > 0 ? limitParsed : 4;
727
- const source = parseSearchSource(args.source ?? "stash");
728
- const curated = await akmCurate({ query: args.query, type, limit, source });
729
- output("curate", curated);
730
- });
731
420
  },
732
- });
733
- const VALID_SOURCE_KINDS = new Set(["local", "managed", "remote"]);
734
- function parseKindFilter(raw) {
735
- if (!raw)
736
- return undefined;
737
- const kinds = raw.split(",").map((s) => s.trim());
738
- for (const k of kinds) {
739
- if (!VALID_SOURCE_KINDS.has(k)) {
740
- throw new UsageError(`Invalid --kind value: "${k}". Expected one of: local, managed, remote`);
421
+ run({ args }) {
422
+ if (args.shell !== "bash") {
423
+ throw new UsageError(`Unsupported shell: ${args.shell}. Only bash is supported.`);
424
+ }
425
+ const script = generateBashCompletions(main);
426
+ if (args.install) {
427
+ const dest = installBashCompletions(script);
428
+ info(`Completions installed to ${dest}`);
429
+ info(`Restart your shell or run: source ${dest}`);
430
+ }
431
+ else {
432
+ process.stdout.write(script);
741
433
  }
742
- }
743
- return kinds;
744
- }
745
- const listCommand = defineCommand({
746
- meta: { name: "list", description: "List all sources (local directories, managed packages, remote providers)" },
747
- args: {
748
- kind: { type: "string", description: "Filter by source kind (local, managed, remote). Comma-separated." },
749
- },
750
- async run({ args }) {
751
- await runWithJsonErrors(async () => {
752
- const kind = parseKindFilter(args.kind);
753
- const result = await akmListSources({ kind });
754
- output("list", result);
755
- });
756
- },
757
- });
758
- const removeCommand = defineCommand({
759
- meta: { name: "remove", description: "Remove a source by id, ref, path, URL, or name" },
760
- args: {
761
- target: { type: "positional", description: "Source to remove (id, ref, path, URL, or name)", required: true },
762
- yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
763
- },
764
- async run({ args }) {
765
- await runWithJsonErrors(async () => {
766
- const { confirmDestructive } = await import("./cli/confirm.js");
767
- const confirmed = await confirmDestructive(`Remove source "${args.target}"? This cannot be undone.`, {
768
- yes: args.yes === true,
769
- });
770
- if (!confirmed) {
771
- process.stderr.write("Aborted.\n");
772
- return;
773
- }
774
- const result = await akmRemove({ target: args.target });
775
- appendEvent({
776
- eventType: "remove",
777
- metadata: {
778
- target: args.target,
779
- ref: typeof result.removed?.ref === "string" ? result.removed.ref : null,
780
- id: typeof result.removed?.id === "string" ? result.removed.id : null,
781
- },
782
- });
783
- output("remove", result);
784
- });
785
434
  },
786
435
  });
787
- const updateCommand = defineCommand({
788
- meta: { name: "update", description: "Update one or all managed sources" },
789
- args: {
790
- target: { type: "positional", description: "Source to update (id or ref)", required: false },
791
- all: { type: "boolean", description: "Update all installed entries", default: false },
792
- force: { type: "boolean", description: "Force fresh download even if version is unchanged", default: false },
793
- },
794
- async run({ args }) {
795
- await runWithJsonErrors(async () => {
796
- const result = await akmUpdate({ target: args.target, all: args.all, force: args.force });
797
- appendEvent({
798
- eventType: "update",
799
- metadata: {
800
- target: args.target ?? null,
801
- all: args.all === true,
802
- force: args.force === true,
803
- processed: Array.isArray(result.processed)
804
- ? result.processed.length
805
- : 0,
806
- },
807
- });
808
- output("update", result);
809
- });
436
+ export const main = defineCommand({
437
+ meta: {
438
+ name: "akm",
439
+ version: pkgVersion,
440
+ description: "Agent Knowledge Management — search, show, and manage assets from your stash.\n\n" +
441
+ "Exit codes:\n" +
442
+ " 0 success\n" +
443
+ " 1 general error / not found\n" +
444
+ " 2 usage error\n" +
445
+ " 4 health warn (akm health only)\n" +
446
+ " 78 config error",
810
447
  },
811
- });
812
- const upgradeCommand = defineCommand({
813
- meta: { name: "upgrade", description: "Upgrade akm to the latest release" },
814
448
  args: {
815
- check: { type: "boolean", description: "Check for updates without installing", default: false },
816
- force: { type: "boolean", description: "Force upgrade even if on latest", default: false },
817
- "skip-checksum": {
449
+ format: { type: "string", description: "Output format (json|jsonl|text|yaml|md|html)", default: "json" },
450
+ output: {
451
+ type: "string",
452
+ description: "Write rendered output to a file instead of stdout (all formats except jsonl)",
453
+ },
454
+ detail: {
455
+ type: "string",
456
+ description: "Detail level (verbosity): brief|normal|full. Default: brief.",
457
+ default: "brief",
458
+ },
459
+ shape: {
460
+ type: "string",
461
+ description: "Output projection: human|agent|summary. 'agent' trims to agent-essential fields; " +
462
+ "'summary' is only valid on 'akm show'. Default: human.",
463
+ },
464
+ quiet: {
818
465
  type: "boolean",
819
- description: "Skip checksum verification (not recommended)",
466
+ alias: "q",
467
+ description: "Suppress non-essential stderr output (banners, spinners, progress info). " +
468
+ "Safety-critical output is never suppressed: errors, destructive-action confirmation prompts, " +
469
+ "and auto-migration banners always appear regardless of --quiet.",
820
470
  default: false,
821
471
  },
822
- "skip-post-upgrade": {
472
+ verbose: {
823
473
  type: "boolean",
824
- description: "Skip the post-upgrade `akm index` rebuild (config auto-migration still runs on next `akm` invocation)",
825
- default: false,
826
- },
827
- },
828
- async run({ args }) {
829
- await runWithJsonErrors(async () => {
830
- const check = await checkForUpdate(pkgVersion);
831
- if (args.check) {
832
- output("upgrade", check);
833
- return;
834
- }
835
- const skipChecksum = getHyphenatedBoolean(args, "skip-checksum");
836
- const skipPostUpgrade = getHyphenatedBoolean(args, "skip-post-upgrade");
837
- const result = await performUpgrade(check, { force: args.force, skipChecksum, skipPostUpgrade });
838
- output("upgrade", result);
839
- });
840
- },
841
- });
842
- const showCommand = defineCommand({
843
- meta: {
844
- name: "show",
845
- description: "Show a stash asset by ref (e.g. akm show knowledge:guide.md toc, akm show knowledge:guide.md section 'Auth')",
846
- },
847
- args: {
848
- ref: {
849
- type: "positional",
850
- description: 'Asset ref ([origin//]type:name) optionally followed by a view mode. View modes: `toc` (table of contents), `section "Heading"` (extract one section), `lines <start> <end>` (line range), `frontmatter` (YAML metadata only), `full` (raw file). Example: `akm show knowledge:guide.md section "Auth"`.',
851
- required: true,
852
- },
853
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
854
- detail: { type: "string", description: "Detail level (brief|normal|full)" },
855
- shape: { type: "string", description: "Output projection (human|agent|summary)" },
856
- scope: {
857
- type: "string",
858
- description: "Scope filter (repeatable): --scope user=<id> --scope agent=<id> --scope run=<id> --scope channel=<name>. Narrows resolution to assets whose frontmatter scope matches.",
859
- },
860
- },
861
- async run({ args }) {
862
- await runWithJsonErrors(async () => {
863
- const subcommand = Array.isArray(args._) ? args._[0] : undefined;
864
- if (subcommand === "proposal") {
865
- if (!isQuiet()) {
866
- process.stderr.write("warning: 'akm show proposal <id>' is deprecated and will be removed in 0.9.0. Use 'akm proposal show <id>'.\n");
867
- }
868
- const proposalId = Array.isArray(args._) ? args._[1] : undefined;
869
- if (typeof proposalId !== "string" || !proposalId.trim()) {
870
- throw new UsageError("Usage: akm proposal show <id>", "MISSING_REQUIRED_ARGUMENT");
871
- }
872
- const result = akmProposalShow({ id: proposalId.trim() });
873
- output("proposal-show", result);
874
- return;
875
- }
876
- // `[origin//]meta[:name]` targets the stash `.meta/` convention, which is
877
- // not a typed asset ref — skip ref validation and let akmShowUnified
878
- // direct-read it. (`parseAssetRef` would reject the non-type `meta`.)
879
- if (!parseMetaRef(args.ref))
880
- parseAssetRef(args.ref);
881
- // The knowledge-view positional syntax (`akm show knowledge:foo section "Auth"`)
882
- // is rewritten to `--akmView` / `--akmHeading` / `--akmStart` / `--akmEnd`
883
- // by `normalizeShowArgv` before citty parses argv. We read those values
884
- // directly via `parseFlagValue` so the flags don't surface as user-facing
885
- // options in `akm show --help`.
886
- const akmView = parseFlagValue(process.argv, "--akmView");
887
- const akmHeading = parseFlagValue(process.argv, "--akmHeading");
888
- const akmStart = parseFlagValue(process.argv, "--akmStart");
889
- const akmEnd = parseFlagValue(process.argv, "--akmEnd");
890
- let view;
891
- if (akmView) {
892
- switch (akmView) {
893
- case "section":
894
- view = { mode: "section", heading: akmHeading ?? "" };
895
- break;
896
- case "lines":
897
- view = {
898
- mode: "lines",
899
- start: Number(akmStart ?? "1"),
900
- end: akmEnd ? parseInt(akmEnd, 10) : Number.MAX_SAFE_INTEGER,
901
- };
902
- break;
903
- case "toc":
904
- case "frontmatter":
905
- case "full":
906
- view = { mode: akmView };
907
- break;
908
- default:
909
- throw new UsageError(`Unknown view mode: ${akmView}. Expected one of: full|toc|frontmatter|section|lines`);
910
- }
911
- }
912
- const cliShape = getOutputMode().shape;
913
- const explicitDetail = parseFlagValue(process.argv, "--detail");
914
- // `--shape summary` selects the compact metadata projection for show
915
- // (the legacy `--detail summary` spelling still maps here via the
916
- // back-compat path in resolveOutputMode). `--detail brief` forces the
917
- // brief response regardless of shape.
918
- const showDetail = explicitDetail === "brief" ? "brief" : cliShape === "summary" ? "summary" : undefined;
919
- // `--scope` is repeatable — citty only exposes the last value, so read
920
- // every occurrence directly from argv (same pattern as `--filter`).
921
- const scopeTokens = parseAllFlagValues("--scope");
922
- const scope = parseScopeFilterFlags(scopeTokens, "--scope");
923
- const result = await akmShowUnified({
924
- ref: args.ref,
925
- view,
926
- detail: showDetail,
927
- scope,
928
- eventSource: resolveEventSource(),
929
- });
930
- output("show", result);
931
- });
932
- },
933
- });
934
- const configCommand = defineCommand({
935
- meta: { name: "config", description: "Show and manage configuration" },
936
- args: {
937
- list: { type: "boolean", description: "List current configuration", default: false },
938
- },
939
- subCommands: {
940
- path: defineCommand({
941
- meta: { name: "path", description: "Show paths to config, stash, cache, and index" },
942
- args: {
943
- all: { type: "boolean", description: "Show all paths (config, stash, cache, index)", default: false },
944
- },
945
- run({ args }) {
946
- return runWithJsonErrors(() => {
947
- const configPath = getConfigPath();
948
- if (args.all) {
949
- let stashDir;
950
- try {
951
- stashDir = resolveStashDir({ readOnly: true });
952
- }
953
- catch {
954
- stashDir = `${getDefaultStashDir()} (not initialized)`;
955
- }
956
- const cacheDir = getCacheDir();
957
- const result = {
958
- config: configPath,
959
- stash: stashDir,
960
- cache: cacheDir,
961
- index: getDbPath(),
962
- };
963
- output("config", result);
964
- }
965
- else {
966
- console.log(configPath);
967
- }
968
- });
969
- },
970
- }),
971
- list: defineCommand({
972
- meta: { name: "list", description: "List current configuration" },
973
- run() {
974
- return runWithJsonErrors(() => {
975
- output("config", listConfig(loadConfig()));
976
- });
977
- },
978
- }),
979
- show: defineCommand({
980
- meta: { name: "show", description: "Alias for `akm config list` — list current configuration" },
981
- run() {
982
- return runWithJsonErrors(() => {
983
- output("config", listConfig(loadConfig()));
984
- });
985
- },
986
- }),
987
- get: defineCommand({
988
- meta: { name: "get", description: "Get a configuration value by key" },
989
- args: {
990
- key: { type: "positional", required: true, description: "Config key (for example: embedding, stashDir)" },
991
- },
992
- run({ args }) {
993
- return runWithJsonErrors(() => {
994
- output("config", getConfigValue(loadConfig(), args.key));
995
- });
996
- },
997
- }),
998
- set: defineCommand({
999
- meta: { name: "set", description: "Set a configuration value by key" },
1000
- args: {
1001
- key: { type: "positional", required: true, description: "Config key (for example: embedding, llm)" },
1002
- value: { type: "positional", required: true, description: "Config value" },
1003
- // #463: stable machine-friendly entry point for plugins / hooks.
1004
- // `--silent` suppresses the config dump on stdout so hook-driven
1005
- // writes don't pollute their host's output stream.
1006
- silent: {
1007
- type: "boolean",
1008
- description: "Suppress the post-write config dump on stdout. Use from hooks and CI scripts; the write still happens and errors still print.",
1009
- default: false,
1010
- },
1011
- // #463: explicit layer flag for forward-compat. User layer is the only
1012
- // settable layer today; the flag exists so plugin authors can encode
1013
- // intent and the surface stays stable if project-layer writes return.
1014
- layer: {
1015
- type: "string",
1016
- description: "Config layer to write to. Currently only `user` is supported.",
1017
- default: "user",
1018
- },
1019
- },
1020
- run({ args }) {
1021
- return runWithJsonErrors(() => {
1022
- if (args.layer && args.layer !== "user") {
1023
- throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1024
- }
1025
- // Use loadConfig (not loadUserConfig) so the project-config
1026
- // deprecation warning fires consistently with `akm config get`
1027
- // (#457). Effective merged shape is identical post-0.8.0.
1028
- const updated = setConfigValue(loadConfig(), args.key, args.value);
1029
- saveConfig(updated);
1030
- if (!args.silent) {
1031
- output("config", listConfig(updated));
1032
- }
1033
- });
1034
- },
1035
- }),
1036
- unset: defineCommand({
1037
- meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/llm section" },
1038
- args: {
1039
- key: { type: "positional", required: true, description: "Config key to unset" },
1040
- silent: {
1041
- type: "boolean",
1042
- description: "Suppress the post-write config dump on stdout.",
1043
- default: false,
1044
- },
1045
- layer: {
1046
- type: "string",
1047
- description: "Config layer to write to. Currently only `user` is supported.",
1048
- default: "user",
1049
- },
1050
- },
1051
- run({ args }) {
1052
- return runWithJsonErrors(() => {
1053
- if (args.layer && args.layer !== "user") {
1054
- throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1055
- }
1056
- const updated = unsetConfigValue(loadConfig(), args.key);
1057
- saveConfig(updated);
1058
- if (!args.silent) {
1059
- output("config", listConfig(updated));
1060
- }
1061
- });
1062
- },
1063
- }),
1064
- validate: defineCommand({
1065
- meta: {
1066
- name: "validate",
1067
- description: "Validate the on-disk config file against the schema. Exits non-zero on errors.",
1068
- },
1069
- async run() {
1070
- return runWithJsonErrors(async () => {
1071
- const { runConfigValidate } = await import("./cli/config-validate.js");
1072
- await runConfigValidate();
1073
- });
1074
- },
1075
- }),
1076
- migrate: defineCommand({
1077
- meta: {
1078
- name: "migrate",
1079
- description: "Migrate the config file to the current schema version. Use --dry-run to preview without writing.",
1080
- },
1081
- args: {
1082
- "dry-run": { type: "boolean", description: "Preview the migration result without writing.", default: false },
1083
- "print-diff": {
1084
- type: "boolean",
1085
- description: "Print a unified diff of old vs new config alongside the migration output.",
1086
- default: false,
1087
- },
1088
- },
1089
- async run({ args }) {
1090
- return runWithJsonErrors(async () => {
1091
- const { runConfigMigrate } = await import("./cli/config-migrate.js");
1092
- await runConfigMigrate({ dryRun: Boolean(args["dry-run"]), printDiff: Boolean(args["print-diff"]) });
1093
- });
1094
- },
1095
- }),
1096
- enable: defineCommand({
1097
- meta: { name: "enable", description: "Enable an optional component (skills.sh)" },
1098
- args: {
1099
- target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
1100
- },
1101
- run({ args }) {
1102
- return runWithJsonErrors(() => {
1103
- const result = toggleComponent(args.target, true);
1104
- output("enable", result);
1105
- });
1106
- },
1107
- }),
1108
- disable: defineCommand({
1109
- meta: { name: "disable", description: "Disable an optional component (skills.sh)" },
1110
- args: {
1111
- target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
1112
- },
1113
- run({ args }) {
1114
- return runWithJsonErrors(() => {
1115
- const result = toggleComponent(args.target, false);
1116
- output("disable", result);
1117
- });
1118
- },
1119
- }),
1120
- },
1121
- run({ args }) {
1122
- return runWithJsonErrors(() => {
1123
- if (hasSubcommand(args, CONFIG_SUBCOMMAND_SET))
1124
- return;
1125
- if (args.list) {
1126
- output("config", listConfig(loadConfig()));
1127
- return;
1128
- }
1129
- output("config", listConfig(loadConfig()));
1130
- });
1131
- },
1132
- });
1133
- // Shared `save`/`sync` body. `sync` is the canonical spelling in 0.8; `save`
1134
- // remains a deprecated alias (removed 0.9.0). Both share this implementation so
1135
- // the git-commit/push logic and the `--format`-as-name workaround stay in one place.
1136
- async function runSyncBody(args, verb) {
1137
- await runWithJsonErrors(async () => {
1138
- // Fix: citty can consume `--format json` (space-separated) as the
1139
- // positional `name` argument (e.g. `akm sync --format json` parses
1140
- // name="json"). Detect the mis-parse by checking argv order — only
1141
- // treat the positional as consumed by --format when --format appears
1142
- // before any standalone occurrence of the same value in the sync
1143
- // subcommand's argv slice. This preserves legitimate invocations
1144
- // like `akm sync json --format json`.
1145
- const parsedFormat = parseFlagValue(process.argv, "--format");
1146
- const effectiveName = args.name !== undefined &&
1147
- parsedFormat !== undefined &&
1148
- args.name === parsedFormat &&
1149
- wasFormatValueConsumedAsName(args.name, parsedFormat, verb)
1150
- ? undefined
1151
- : args.name;
1152
- let writable;
1153
- if (effectiveName === undefined) {
1154
- // Primary stash — honour the root-level writable flag from config.
1155
- writable = resolveWritableOverride(loadConfig());
1156
- }
1157
- const result = saveGitStash(effectiveName, args.message, writable, { push: args.push !== false });
1158
- appendEvent({
1159
- eventType: "save",
1160
- metadata: {
1161
- name: effectiveName ?? null,
1162
- message: args.message ?? null,
1163
- ok: result.ok !== false,
1164
- },
1165
- });
1166
- output("save", result);
1167
- });
1168
- }
1169
- const syncCommand = defineCommand({
1170
- meta: {
1171
- name: "sync",
1172
- description: "Sync changes in a git-backed stash: commits (and pushes when writable + remote is configured). No-op for non-git stashes.",
1173
- },
1174
- args: {
1175
- name: {
1176
- type: "positional",
1177
- description: "Name of the git stash to sync (default: primary stash directory)",
1178
- required: false,
1179
- },
1180
- message: {
1181
- type: "string",
1182
- alias: "m",
1183
- description: "Commit message (default: timestamp)",
1184
- },
1185
- push: {
1186
- type: "boolean",
1187
- description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
1188
- default: true,
1189
- },
1190
- },
1191
- async run({ args }) {
1192
- await runSyncBody(args, "sync");
1193
- },
1194
- });
1195
- // Deprecated alias (removed 0.9.0): `akm save` → `akm sync`.
1196
- const saveCommand = defineCommand({
1197
- meta: {
1198
- name: "save",
1199
- description: "DEPRECATED — use `akm sync`. Removed in 0.9.0.",
1200
- },
1201
- args: {
1202
- name: {
1203
- type: "positional",
1204
- description: "Name of the git stash to save (default: primary stash directory)",
1205
- required: false,
1206
- },
1207
- message: {
1208
- type: "string",
1209
- alias: "m",
1210
- description: "Commit message (default: timestamp)",
1211
- },
1212
- push: {
1213
- type: "boolean",
1214
- description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
1215
- default: true,
1216
- },
1217
- },
1218
- async run({ args }) {
1219
- emitCommandDeprecation("save", "sync");
1220
- await runSyncBody(args, "save");
1221
- },
1222
- });
1223
- /**
1224
- * Detect whether `--format <value>` was consumed by citty as the optional
1225
- * `name` positional of `akm save`. Returns true only when `--format` appears
1226
- * in the save subcommand's argv slice AND the candidate name does NOT
1227
- * appear as a standalone positional elsewhere (before or after the flag).
1228
- *
1229
- * This keeps `akm sync json --format json` routing `json` as the stash name,
1230
- * while `akm sync --format json` (no separate positional) is treated as a
1231
- * primary-stash sync. `verb` is the subcommand token to anchor on (`sync` or
1232
- * the deprecated `save`).
1233
- */
1234
- function wasFormatValueConsumedAsName(name, formatValue, verb) {
1235
- const argv = process.argv.slice(2);
1236
- const verbIndex = argv.indexOf(verb);
1237
- const tokens = verbIndex >= 0 ? argv.slice(verbIndex + 1) : argv;
1238
- let formatIndex = -1;
1239
- let formatConsumesNextToken = false;
1240
- for (let i = 0; i < tokens.length; i += 1) {
1241
- const token = tokens[i];
1242
- if (token === "--format") {
1243
- formatIndex = i;
1244
- formatConsumesNextToken = true;
1245
- break;
1246
- }
1247
- if (token === `--format=${formatValue}`) {
1248
- formatIndex = i;
1249
- break;
1250
- }
1251
- }
1252
- if (formatIndex === -1)
1253
- return false;
1254
- // If the name appears as a standalone token before --format, it's the
1255
- // real positional and --format did not consume it.
1256
- if (tokens.slice(0, formatIndex).includes(name))
1257
- return false;
1258
- // If --format has a space-separated value, skip past the value token
1259
- // when scanning after the flag; otherwise start right after the flag.
1260
- const firstTokenAfterFormat = formatIndex + (formatConsumesNextToken ? 2 : 1);
1261
- if (tokens.slice(firstTokenAfterFormat).includes(name))
1262
- return false;
1263
- return true;
1264
- }
1265
- const cloneCommand = defineCommand({
1266
- meta: {
1267
- name: "clone",
1268
- description: "Clone an asset from any source into the working stash or a custom destination",
1269
- },
1270
- args: {
1271
- ref: { type: "positional", description: "Asset ref (e.g. npm:@scope/pkg//script:deploy.sh)", required: true },
1272
- name: { type: "string", description: "New name for the cloned asset" },
1273
- force: { type: "boolean", description: "Overwrite if asset already exists in working stash", default: false },
1274
- dest: { type: "string", description: "Destination directory (default: working stash)" },
1275
- },
1276
- async run({ args }) {
1277
- await runWithJsonErrors(async () => {
1278
- const result = await akmClone({
1279
- sourceRef: args.ref,
1280
- newName: args.name,
1281
- force: args.force,
1282
- dest: args.dest,
1283
- });
1284
- output("clone", result);
1285
- });
1286
- },
1287
- });
1288
- const historyCommand = defineCommand({
1289
- meta: {
1290
- name: "history",
1291
- description: "Show mutation/usage history for a single asset (--ref) or stash-wide.\n\n" +
1292
- "Event sources:\n" +
1293
- " usage_events (default): search, show, and feedback events from the local index.\n" +
1294
- " state.db events (--include-proposals): proposal lifecycle events (promoted, rejected)\n" +
1295
- " emitted by `akm accept` / `akm reject`.\n\n" +
1296
- "Results from all active sources are merged and sorted chronologically.",
1297
- },
1298
- args: {
1299
- ref: { type: "string", description: "Asset ref (type:name). Omit for stash-wide history." },
1300
- since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
1301
- generator: {
1302
- type: "string",
1303
- description: 'Filter by event generator: "user" (default) or "improve" (akm improve operations).',
1304
- },
1305
- source: {
1306
- type: "string",
1307
- description: "DEPRECATED — use --generator. Removed in 0.9.0.",
1308
- },
1309
- "include-proposals": {
1310
- type: "boolean",
1311
- description: "Also include proposal lifecycle events (promoted, rejected) from state.db events. " +
1312
- "Default: false (usage_events only).",
1313
- default: false,
1314
- },
1315
- "accept-rate-by-source": {
1316
- type: "boolean",
1317
- description: "Compute accept-rate-per-source metrics from the proposal store and include them in the output (F-4 / #385). " +
1318
- "Useful for measuring which generators (reflect, distill, …) produce the most accepted proposals.",
1319
- default: false,
1320
- },
1321
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
1322
- },
1323
- run({ args }) {
1324
- return runWithJsonErrors(async () => {
1325
- if (args.generator === undefined && args.source !== undefined) {
1326
- emitFlagDeprecation("--source", "--generator", "history");
1327
- }
1328
- const generatorFlag = (args.generator ?? args.source);
1329
- if (generatorFlag !== undefined && generatorFlag !== "user" && generatorFlag !== "improve") {
1330
- // Name the flag the user actually typed so the diagnostic points at
1331
- // their command line, not the canonical flag they may not have used.
1332
- const usedFlag = args.generator !== undefined ? "--generator" : "--source";
1333
- throw new UsageError(`Invalid ${usedFlag} value: "${generatorFlag}". Must be "user" or "improve".`, "INVALID_FLAG_VALUE");
1334
- }
1335
- const sources = resolveSourceEntries();
1336
- const stashDir = sources[0]?.path;
1337
- const result = await akmHistory({
1338
- ref: args.ref,
1339
- since: args.since,
1340
- source: generatorFlag,
1341
- includeProposals: args["include-proposals"],
1342
- acceptRateBySource: args["accept-rate-by-source"],
1343
- stashDir,
1344
- });
1345
- output("history", result);
1346
- });
1347
- },
1348
- });
1349
- const workflowStartCommand = defineCommand({
1350
- meta: {
1351
- name: "start",
1352
- description: "Start a new workflow run in the current working scope",
1353
- },
1354
- args: {
1355
- ref: { type: "positional", description: "Workflow ref (workflow:<name>)", required: true },
1356
- params: { type: "string", description: "Workflow parameters as a JSON object" },
1357
- force: {
1358
- type: "boolean",
1359
- description: "Allow a parallel run when an active run already exists in this scope (#485)",
1360
- default: false,
1361
- },
1362
- },
1363
- async run({ args }) {
1364
- await runWithJsonErrors(async () => {
1365
- const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params"), {
1366
- force: args.force === true,
1367
- });
1368
- output("workflow-start", result);
1369
- });
1370
- },
1371
- });
1372
- const workflowNextCommand = defineCommand({
1373
- meta: {
1374
- name: "next",
1375
- description: "Show the next actionable workflow step in the current scope, auto-starting a run when passed a workflow ref",
1376
- },
1377
- args: {
1378
- target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
1379
- params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
1380
- },
1381
- async run({ args }) {
1382
- await runWithJsonErrors(async () => {
1383
- // `--dry-run` is intentionally NOT a declared arg (so it stays out of
1384
- // --help). The guard reads it straight from process.argv so existing
1385
- // callers still get a clear, actionable error instead of a generic
1386
- // "unknown flag" from citty.
1387
- if (hasBooleanFlag(process.argv, "--dry-run")) {
1388
- throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
1389
- }
1390
- const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
1391
- // If the target looks like a UUID-style run id (no `:` and matches the
1392
- // run-id shape), short-circuit with a structured WORKFLOW_NOT_FOUND
1393
- // error before parseAssetRef gets to throw an unhelpful ref-parse error.
1394
- if (looksLikeWorkflowRunId(args.target)) {
1395
- const { hasWorkflowRun } = await import("./workflows/runs.js");
1396
- if (!(await hasWorkflowRun(args.target))) {
1397
- throw new NotFoundError(`Workflow run "${args.target}" not found.`, "WORKFLOW_NOT_FOUND", "Run `akm workflow list --active` to see runs.");
1398
- }
1399
- }
1400
- const result = await getNextWorkflowStep(args.target, parsedParams);
1401
- output("workflow-next", result);
1402
- });
1403
- },
1404
- });
1405
- /**
1406
- * Heuristic: a workflow run id is a UUID-shaped or hex-id-shaped string with
1407
- * no `:` separator (refs always contain a colon: `workflow:<name>` or
1408
- * `<origin>//workflow:<name>`). When this matches we can give a much better
1409
- * error than parseAssetRef's "Invalid asset type" failure.
1410
- */
1411
- function looksLikeWorkflowRunId(target) {
1412
- if (target.includes(":"))
1413
- return false;
1414
- if (target.includes("/"))
1415
- return false;
1416
- // UUID v4-ish: 8-4-4-4-12 hex digits separated by dashes.
1417
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(target))
1418
- return true;
1419
- // Bare hex/alphanumeric run ids of >=8 chars (covers shortened ids).
1420
- if (/^[0-9a-z][0-9a-z_-]{7,}$/i.test(target) && /[0-9]/.test(target))
1421
- return true;
1422
- return false;
1423
- }
1424
- const workflowCompleteCommand = defineCommand({
1425
- meta: {
1426
- name: "complete",
1427
- description: "Update a workflow step state and persist notes/evidence",
1428
- },
1429
- args: {
1430
- runId: { type: "positional", description: "Workflow run id", required: true },
1431
- step: { type: "string", description: "Workflow step id", required: true },
1432
- state: {
1433
- type: "string",
1434
- description: `Step state (default: completed). One of: ${WORKFLOW_STEP_STATES.join(", ")}.`,
1435
- },
1436
- notes: { type: "string", description: "Notes for the completed step" },
1437
- evidence: { type: "string", description: "Evidence JSON object for the step" },
1438
- },
1439
- async run({ args }) {
1440
- await runWithJsonErrors(async () => {
1441
- const result = await completeWorkflowStep({
1442
- runId: args.runId,
1443
- stepId: args.step,
1444
- status: parseWorkflowStepState(args.state),
1445
- notes: args.notes,
1446
- evidence: args.evidence ? parseWorkflowJsonObject(args.evidence, "--evidence") : undefined,
1447
- });
1448
- output("workflow-complete", result);
1449
- });
1450
- },
1451
- });
1452
- const workflowStatusCommand = defineCommand({
1453
- meta: {
1454
- name: "status",
1455
- description: "Show full workflow run state for review or resume; workflow refs resolve within the current scope",
1456
- },
1457
- args: {
1458
- target: { type: "positional", description: "Workflow run id or workflow ref (workflow:<name>)", required: true },
1459
- },
1460
- run({ args }) {
1461
- return runWithJsonErrors(async () => {
1462
- const target = args.target;
1463
- // Check if target looks like a workflow ref
1464
- const parsed = (() => {
1465
- try {
1466
- return parseAssetRef(target);
1467
- }
1468
- catch {
1469
- return null;
1470
- }
1471
- })();
1472
- if (parsed?.type === "workflow") {
1473
- const ref = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
1474
- const { runs } = await listWorkflowRuns({ workflowRef: ref });
1475
- if (runs.length === 0) {
1476
- throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
1477
- }
1478
- const mostRecent = runs[0];
1479
- if (!mostRecent)
1480
- throw new NotFoundError(`No workflow runs found for ${ref}`, "WORKFLOW_NOT_FOUND");
1481
- const result = await getWorkflowStatus(mostRecent.id);
1482
- output("workflow-status", result);
1483
- }
1484
- else {
1485
- const result = await getWorkflowStatus(target);
1486
- output("workflow-status", result);
1487
- }
1488
- });
1489
- },
1490
- });
1491
- const workflowListCommand = defineCommand({
1492
- meta: {
1493
- name: "list",
1494
- description: "List workflow runs in the current working scope",
1495
- },
1496
- args: {
1497
- ref: { type: "string", description: "Filter to one workflow ref" },
1498
- active: { type: "boolean", description: "Only show active runs", default: false },
1499
- },
1500
- run({ args }) {
1501
- return runWithJsonErrors(async () => {
1502
- const result = await listWorkflowRuns({ workflowRef: args.ref, activeOnly: args.active });
1503
- output("workflow-list", result);
1504
- });
1505
- },
1506
- });
1507
- const workflowCreateCommand = defineCommand({
1508
- meta: {
1509
- name: "create",
1510
- description: "Create a workflow markdown document in the working stash",
1511
- },
1512
- args: {
1513
- name: { type: "positional", description: "Workflow name", required: true },
1514
- from: { type: "string", description: "Import and validate markdown from an existing file" },
1515
- force: {
1516
- type: "boolean",
1517
- description: "Overwrite an existing workflow (requires --from or --reset)",
1518
- default: false,
1519
- },
1520
- reset: {
1521
- type: "boolean",
1522
- description: "Explicitly replace an existing workflow with a fresh template (use with --force)",
1523
- default: false,
1524
- },
1525
- },
1526
- async run({ args }) {
1527
- return runWithJsonErrors(async () => {
1528
- const namePattern = /^[a-z0-9][a-z0-9._/-]*$/;
1529
- if (!namePattern.test(args.name)) {
1530
- throw new UsageError("Workflow name must start with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, dots, underscores, and slashes.");
1531
- }
1532
- if (args.force && !args.from && !args.reset) {
1533
- throw new UsageError("Refusing to overwrite with template: pass --from <file> to replace content, or --reset to explicitly replace with a fresh template.");
1534
- }
1535
- const result = createWorkflowAsset({
1536
- name: args.name,
1537
- from: args.from,
1538
- force: args.force,
1539
- });
1540
- // Index the newly-written workflow so `akm workflow start` can resolve
1541
- // a workflowEntryId without requiring an explicit `akm index` call
1542
- // first. Uses the same incremental index path that `akm add` uses.
1543
- await akmIndex({ stashDir: result.stashDir });
1544
- output("workflow-create", { ok: true, ...result });
1545
- });
1546
- },
1547
- });
1548
- const workflowTemplateCommand = defineCommand({
1549
- meta: {
1550
- name: "template",
1551
- description: "Print a valid workflow markdown template",
1552
- },
1553
- run() {
1554
- process.stdout.write(getWorkflowTemplate());
1555
- },
1556
- });
1557
- const workflowValidateCommand = defineCommand({
1558
- meta: {
1559
- name: "validate",
1560
- description: "Validate a workflow markdown file or ref and print any errors",
1561
- },
1562
- args: {
1563
- target: {
1564
- type: "positional",
1565
- description: "Workflow ref (workflow:<name>) or filesystem path to a workflow .md",
1566
- required: true,
1567
- },
1568
- },
1569
- async run({ args }) {
1570
- return runWithJsonErrors(async () => {
1571
- const filePath = await resolveWorkflowFilePath(args.target);
1572
- const { parse } = validateWorkflowSource(filePath);
1573
- if (parse.ok) {
1574
- output("workflow-validate", {
1575
- ok: true,
1576
- path: filePath,
1577
- title: parse.document.title,
1578
- stepCount: parse.document.steps.length,
1579
- });
1580
- return;
1581
- }
1582
- throw new UsageError(formatWorkflowErrors(filePath, parse.errors));
1583
- });
1584
- },
1585
- });
1586
- async function resolveWorkflowFilePath(target) {
1587
- if (!target.startsWith("workflow:"))
1588
- return target;
1589
- const parsed = parseAssetRef(target);
1590
- if (parsed.type !== "workflow") {
1591
- throw new UsageError(`Expected a workflow ref (workflow:<name>), got "${target}".`);
1592
- }
1593
- const config = loadConfig();
1594
- const allSources = resolveSourceEntries(undefined, config);
1595
- const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
1596
- for (const source of searchSources) {
1597
- try {
1598
- return await resolveAssetPath(source.path, "workflow", parsed.name);
1599
- }
1600
- catch {
1601
- /* try next source */
1602
- }
1603
- }
1604
- throw new UsageError(`Workflow not found for ref: workflow:${parsed.name}`);
1605
- }
1606
- const workflowResumeCommand = defineCommand({
1607
- meta: {
1608
- name: "resume",
1609
- description: "Resume a blocked or failed workflow run, flipping it back to active",
1610
- },
1611
- args: {
1612
- runId: { type: "positional", description: "Workflow run id", required: true },
1613
- },
1614
- run({ args }) {
1615
- return runWithJsonErrors(async () => {
1616
- const result = await resumeWorkflowRun(args.runId);
1617
- output("workflow-resume", result);
1618
- });
1619
- },
1620
- });
1621
- const workflowCommand = defineCommand({
1622
- meta: {
1623
- name: "workflow",
1624
- description: "Author, inspect, and execute step-by-step workflow assets",
1625
- },
1626
- subCommands: {
1627
- start: workflowStartCommand,
1628
- next: workflowNextCommand,
1629
- complete: workflowCompleteCommand,
1630
- status: workflowStatusCommand,
1631
- list: workflowListCommand,
1632
- create: workflowCreateCommand,
1633
- template: workflowTemplateCommand,
1634
- resume: workflowResumeCommand,
1635
- validate: workflowValidateCommand,
1636
- },
1637
- run({ args }) {
1638
- return runWithJsonErrors(async () => {
1639
- if (hasWorkflowSubcommand(args))
1640
- return;
1641
- output("workflow-list", await listWorkflowRuns({ activeOnly: true }));
1642
- });
1643
- },
1644
- });
1645
- const importKnowledgeCommand = defineCommand({
1646
- meta: {
1647
- name: "import",
1648
- description: "Import a knowledge document or URL into the default stash",
1649
- },
1650
- args: {
1651
- source: {
1652
- type: "positional",
1653
- description: 'Source file path, URL, or "-" to read from stdin',
1654
- required: true,
1655
- },
1656
- name: {
1657
- type: "string",
1658
- description: "Knowledge name (defaults to the source filename or content slug)",
1659
- },
1660
- force: {
1661
- type: "boolean",
1662
- description: "Overwrite an existing knowledge document with the same name",
1663
- default: false,
1664
- },
1665
- target: {
1666
- type: "string",
1667
- description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working stash.",
1668
- },
1669
- },
1670
- async run({ args }) {
1671
- return runWithJsonErrors(async () => {
1672
- const { content, preferredName } = await readKnowledgeInput(args.source);
1673
- const result = await writeMarkdownAsset({
1674
- type: "knowledge",
1675
- content,
1676
- name: args.name ?? (isHttpUrl(args.source) ? preferredName : undefined),
1677
- fallbackPrefix: "knowledge",
1678
- preferredName,
1679
- force: args.force,
1680
- target: args.target,
1681
- });
1682
- appendEvent({
1683
- eventType: "import",
1684
- ref: result.ref,
1685
- metadata: { source: args.source, path: result.path, force: args.force === true },
1686
- });
1687
- output("import", { ok: true, source: args.source, ...result });
1688
- });
1689
- },
1690
- });
1691
- const hintsCommand = defineCommand({
1692
- meta: {
1693
- name: "hints",
1694
- description: "Print agent instructions on how to use akm, use --detail full for a complete guide",
1695
- },
1696
- args: {
1697
- detail: {
1698
- type: "string",
1699
- description: "Hints detail level (brief|normal|full). `brief` prints the short guide; `normal`/`full` print the complete guide.",
1700
- default: "normal",
1701
- },
1702
- },
1703
- run({ args }) {
1704
- return runWithJsonErrors(() => {
1705
- // Let the global parser validate the value so an invalid `--detail`
1706
- // returns the standard JSON error envelope (exit 2) rather than a raw
1707
- // stack trace + exit 1. `brief` → short doc; `normal`/`full` → full doc.
1708
- const detail = parseDetailLevel(args.detail) ?? "normal";
1709
- process.stdout.write(loadHints(detail === "brief" ? "brief" : "full"));
1710
- });
1711
- },
1712
- });
1713
- const helpCommand = defineCommand({
1714
- meta: {
1715
- name: "help",
1716
- description: "Print focused help topics such as migration guidance for a release",
1717
- },
1718
- subCommands: {
1719
- migrate: defineCommand({
1720
- meta: {
1721
- name: "migrate",
1722
- description: "Print release notes and migration guidance for a version. Bundled notes live in docs/migration/release-notes/<version>.md; an unknown version lists what's available.",
1723
- },
1724
- args: {
1725
- // Optional in citty so run() is invoked even when omitted; we
1726
- // re-validate below to surface a structured UsageError (exit 2)
1727
- // instead of citty's default help-banner exit-0.
1728
- version: {
1729
- type: "positional",
1730
- description: "Version to review (for example 0.6.0, v0.6.0, 0.6.0-rc1, or latest)",
1731
- required: false,
1732
- },
1733
- },
1734
- run({ args }) {
1735
- return runWithJsonErrors(() => {
1736
- const version = resolveHelpMigrateVersionArg(typeof args.version === "string" ? args.version : undefined);
1737
- if (!version?.trim()) {
1738
- throw new UsageError("Usage: akm help migrate <version>.", "MISSING_REQUIRED_ARGUMENT", "Pass a version like `0.6.0`, `v0.6.0`, `0.6.0-rc1`, or `latest`.");
1739
- }
1740
- process.stdout.write(renderMigrationHelp(version));
1741
- });
1742
- },
1743
- }),
1744
- },
1745
- });
1746
- const completionsCommand = defineCommand({
1747
- meta: {
1748
- name: "completions",
1749
- description: "Generate or install shell completion script",
1750
- },
1751
- args: {
1752
- install: {
1753
- type: "boolean",
1754
- description: "Install completions to the appropriate directory",
1755
- default: false,
1756
- },
1757
- shell: {
1758
- type: "string",
1759
- description: "Shell type (bash)",
1760
- default: "bash",
1761
- },
1762
- },
1763
- run({ args }) {
1764
- if (args.shell !== "bash") {
1765
- throw new UsageError(`Unsupported shell: ${args.shell}. Only bash is supported.`);
1766
- }
1767
- const script = generateBashCompletions(main);
1768
- if (args.install) {
1769
- const dest = installBashCompletions(script);
1770
- info(`Completions installed to ${dest}`);
1771
- info(`Restart your shell or run: source ${dest}`);
1772
- }
1773
- else {
1774
- process.stdout.write(script);
1775
- }
1776
- },
1777
- });
1778
- function normalizeToggleTarget(target) {
1779
- const normalized = target.trim().toLowerCase();
1780
- if (normalized === "skills.sh" || normalized === "skills-sh")
1781
- return "skills.sh";
1782
- throw new UsageError(`Unsupported target "${target}". Supported targets: skills.sh`);
1783
- }
1784
- function toggleSkillsShRegistry(enabled) {
1785
- const config = loadUserConfig();
1786
- const registries = (config.registries ?? DEFAULT_CONFIG.registries ?? []).map((registry) => ({ ...registry }));
1787
- const idx = registries.findIndex((registry) => registry.provider === SKILLS_SH_PROVIDER || registry.name === SKILLS_SH_NAME || registry.url === SKILLS_SH_URL);
1788
- if (idx >= 0) {
1789
- const existing = registries[idx];
1790
- const wasEnabled = existing.enabled !== false;
1791
- existing.enabled = enabled;
1792
- saveConfig({ ...config, registries });
1793
- return { changed: wasEnabled !== enabled, component: SKILLS_SH_NAME, enabled };
1794
- }
1795
- if (!enabled) {
1796
- // Materialize the skills.sh registry explicitly if absent.
1797
- registries.push({ url: SKILLS_SH_URL, name: SKILLS_SH_NAME, provider: SKILLS_SH_PROVIDER, enabled: false });
1798
- saveConfig({ ...config, registries });
1799
- return { changed: true, component: SKILLS_SH_NAME, enabled: false };
1800
- }
1801
- registries.push({ url: SKILLS_SH_URL, name: SKILLS_SH_NAME, provider: SKILLS_SH_PROVIDER, enabled: true });
1802
- saveConfig({ ...config, registries });
1803
- return { changed: true, component: SKILLS_SH_NAME, enabled: true };
1804
- }
1805
- function toggleComponent(targetRaw, enabled) {
1806
- const target = normalizeToggleTarget(targetRaw);
1807
- if (target === "skills.sh")
1808
- return toggleSkillsShRegistry(enabled);
1809
- // normalizeToggleTarget throws for any unsupported target; this is unreachable.
1810
- throw new UsageError(`Unsupported target "${targetRaw}". Supported targets: skills.sh`);
1811
- }
1812
- // Deprecated top-level aliases (removed 0.9.0) — delegate to `config enable|disable`.
1813
- const enableCommand = defineCommand({
1814
- meta: { name: "enable", description: "DEPRECATED — use `akm config enable`. Removed in 0.9.0." },
1815
- args: {
1816
- target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
1817
- },
1818
- run({ args }) {
1819
- return runWithJsonErrors(() => {
1820
- emitCommandDeprecation("enable", "config enable");
1821
- const result = toggleComponent(args.target, true);
1822
- output("enable", result);
1823
- });
1824
- },
1825
- });
1826
- const disableCommand = defineCommand({
1827
- meta: { name: "disable", description: "DEPRECATED — use `akm config disable`. Removed in 0.9.0." },
1828
- args: {
1829
- target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
1830
- },
1831
- run({ args }) {
1832
- return runWithJsonErrors(() => {
1833
- emitCommandDeprecation("disable", "config disable");
1834
- const result = toggleComponent(args.target, false);
1835
- output("disable", result);
1836
- });
1837
- },
1838
- });
1839
- // ── env ───────────────────────────────────────────────────────────────────
1840
- //
1841
- // `akm env` manages whole `.env` files under each stash's env/ directory.
1842
- // Values are NEVER written to stdout or structured output — only key NAMES and
1843
- // start-of-line comments are surfaced. akm does not manage individual entries;
1844
- // you edit the `.env` file yourself and akm loads it. Replaces the deprecated
1845
- // `vault` type (see the shim further below; removed in 0.9.0).
1846
- function parseEnvRef(ref) {
1847
- return parseAssetRef(ref.includes(":") ? ref : `env:${ref}`);
1848
- }
1849
- function findEnvSource(origin) {
1850
- const sources = resolveSourceEntries(undefined, loadConfig());
1851
- if (sources.length === 0) {
1852
- throw new UsageError("No stashes configured. Run `akm init` to create your working stash.");
1853
- }
1854
- if (!origin || origin === "local")
1855
- return sources[0];
1856
- const named = sources.find((source) => source.registryId === origin);
1857
- if (!named) {
1858
- throw new NotFoundError(`Source not found for origin: ${origin}`);
1859
- }
1860
- return named;
1861
- }
1862
- function makeEnvRef(name, source) {
1863
- return source?.registryId ? `${source.registryId}//env:${name}` : `env:${name}`;
1864
- }
1865
- /**
1866
- * Resolve an env ref to an absolute `.env` path. Accepts `env:`, `environment:`
1867
- * (alias), and `vault:` (deprecated) refs as well as bare names. Prefers the
1868
- * `env/` directory; falls back to the legacy `vaults/` directory when the env
1869
- * file is absent there (handles an upgraded-but-not-yet-migrated stash). When
1870
- * neither exists the env path is returned (so `create` writes under `env/`).
1871
- */
1872
- function resolveEnvPath(ref) {
1873
- const parsed = parseEnvRef(ref);
1874
- if (parsed.type !== "env" && parsed.type !== "vault") {
1875
- throw new UsageError(`Expected an env ref (env:<name>); got "${ref}".`);
1876
- }
1877
- const source = findEnvSource(parsed.origin);
1878
- const envRoot = path.join(source.path, "env");
1879
- const envPath = resolveAssetPathFromName("env", envRoot, parsed.name);
1880
- // Defense-in-depth: ensure the resolved path stays inside the env directory.
1881
- // validateName already rejects traversal patterns like "../../foo", but an
1882
- // absolute-path override or symlink-based attack could still escape without
1883
- // this second check.
1884
- if (!isWithin(envPath, envRoot)) {
1885
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
1886
- }
1887
- const vaultRoot = path.join(source.path, "vaults");
1888
- const vaultPath = resolveAssetPathFromName("vault", vaultRoot, parsed.name);
1889
- if (!isWithin(vaultPath, vaultRoot)) {
1890
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
1891
- }
1892
- // Prefer env/; fall back to the frozen vaults/ copy only when the env file
1893
- // is absent and the legacy vault file is present.
1894
- if (!fs.existsSync(envPath) && fs.existsSync(vaultPath)) {
1895
- return { name: parsed.name, absPath: vaultPath, source, parsedRef: parsed, dir: "vaults" };
1896
- }
1897
- return { name: parsed.name, absPath: envPath, source, parsedRef: parsed, dir: "env" };
1898
- }
1899
- /**
1900
- * Walk each stash's env files and return one entry per `.env` file, using the
1901
- * env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
1902
- * `env:team/prod`, `env/team/.env` → `env:team/default`). When a stash has not
1903
- * yet migrated (no `env/` dir) the legacy `vaults/` dir is listed instead, so
1904
- * `env list` stays continuous across the upgrade.
1905
- */
1906
- function listEnvsRecursive(listKeysFn) {
1907
- const result = [];
1908
- for (const source of resolveSourceEntries(undefined, loadConfig())) {
1909
- const envDir = path.join(source.path, "env");
1910
- const legacyDir = path.join(source.path, "vaults");
1911
- // Prefer env/; only fall back to the frozen vaults/ copy when env/ is absent.
1912
- const scanType = fs.existsSync(envDir) ? "env" : "vault";
1913
- const root = scanType === "env" ? envDir : legacyDir;
1914
- if (!fs.existsSync(root))
1915
- continue;
1916
- const walk = (dir) => {
1917
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1918
- const full = path.join(dir, entry.name);
1919
- if (entry.isDirectory()) {
1920
- walk(full);
1921
- continue;
1922
- }
1923
- if (!entry.isFile())
1924
- continue;
1925
- if (entry.name !== ".env" && !entry.name.endsWith(".env"))
1926
- continue;
1927
- const canonical = deriveCanonicalAssetName(scanType, root, full);
1928
- if (!canonical)
1929
- continue;
1930
- // Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
1931
- const markerPath = full.replace(/\.env$/, ".sensitive");
1932
- if (fs.existsSync(markerPath))
1933
- continue;
1934
- const { keys } = listKeysFn(full);
1935
- result.push({ ref: makeEnvRef(canonical, source), path: full, keys });
1936
- }
1937
- };
1938
- walk(root);
1939
- }
1940
- return result;
1941
- }
1942
- const envListCommand = defineCommand({
1943
- meta: { name: "list", description: "List all env files across all stashes with their key names (no values)" },
1944
- run() {
1945
- return runWithJsonErrors(async () => {
1946
- const { listKeys } = await import("./commands/env.js");
1947
- output("env-list", { envs: listEnvsRecursive(listKeys) });
1948
- });
1949
- },
1950
- });
1951
- const envCreateCommand = defineCommand({
1952
- meta: {
1953
- name: "create",
1954
- description: "Create an env file (empty by default; seed an existing `.env` with --from-file or --from-stdin). No-op if it already exists and no source is given.",
1955
- },
1956
- args: {
1957
- name: { type: "positional", description: "Env name (e.g. prod) — file becomes <name>.env", required: true },
1958
- "from-file": { type: "string", description: "Seed the env file from an existing .env at this path" },
1959
- "from-stdin": { type: "boolean", description: "Seed the env file from stdin", default: false },
1960
- sensitive: {
1961
- type: "boolean",
1962
- description: "Exclude this env file from env list output and the search index",
1963
- default: false,
1964
- },
1965
- },
1966
- run({ args }) {
1967
- return runWithJsonErrors(async () => {
1968
- const { createEnv, writeEnv } = await import("./commands/env.js");
1969
- // `create` always targets env/, never the frozen vaults/ copy.
1970
- const parsed = parseEnvRef(args.name);
1971
- const source = findEnvSource(parsed.origin);
1972
- const envRoot = path.join(source.path, "env");
1973
- const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
1974
- if (!isWithin(absPath, envRoot)) {
1975
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
1976
- }
1977
- const fromFile = getHyphenatedArg(args, "from-file");
1978
- const fromStdin = getHyphenatedArg(args, "from-stdin") === true;
1979
- if (fromFile !== undefined && fromStdin) {
1980
- throw new UsageError("Pass only one of --from-file or --from-stdin.", "INVALID_FLAG_VALUE");
1981
- }
1982
- if (fromFile !== undefined || fromStdin) {
1983
- // Ingest path: never silently clobber an existing env file.
1984
- if (fs.existsSync(absPath)) {
1985
- throw new UsageError(`Env "${makeEnvRef(parsed.name, source)}" already exists. Remove it first (\`akm env remove\`) or edit the file directly.`, "RESOURCE_ALREADY_EXISTS");
1986
- }
1987
- let content;
1988
- if (fromFile !== undefined) {
1989
- if (!fs.existsSync(fromFile)) {
1990
- throw new NotFoundError(`Source file not found: ${fromFile}`, "FILE_NOT_FOUND");
1991
- }
1992
- content = fs.readFileSync(fromFile, "utf8");
1993
- }
1994
- else {
1995
- const MAX_ENV_BYTES = 1024 * 1024; // 1 MB
1996
- let total = 0;
1997
- const chunks = [];
1998
- for await (const chunk of Bun.stdin.stream()) {
1999
- total += chunk.byteLength;
2000
- if (total > MAX_ENV_BYTES) {
2001
- throw new UsageError("Env file exceeds 1 MB limit.", "INVALID_FLAG_VALUE");
2002
- }
2003
- chunks.push(chunk);
2004
- }
2005
- content = Buffer.concat(chunks).toString("utf8");
2006
- }
2007
- writeEnv(absPath, content);
2008
- }
2009
- else {
2010
- createEnv(absPath);
2011
- }
2012
- if (args.sensitive) {
2013
- const markerPath = absPath.replace(/\.env$/, ".sensitive");
2014
- if (!fs.existsSync(markerPath)) {
2015
- fs.writeFileSync(markerPath, "", { mode: 0o600 });
2016
- }
2017
- }
2018
- output("env-create", { ref: makeEnvRef(parsed.name, source) });
2019
- });
2020
- },
2021
- });
2022
- const envPathCommand = defineCommand({
2023
- meta: {
2024
- name: "path",
2025
- description: "Print the absolute env file path (Docker `_FILE` convention / `--env-file`). To inject values, use `akm env run <ref> -- <cmd>` — do NOT `source` the raw file.",
2026
- },
2027
- args: {
2028
- ref: { type: "positional", description: "Env ref", required: true },
2029
- quiet: { type: "boolean", alias: "q", description: "Suppress the unsafe-source warning", default: false },
2030
- },
2031
- run({ args }) {
2032
- return runWithJsonErrors(async () => {
2033
- const { name, absPath, source } = resolveEnvPath(args.ref);
2034
- if (!fs.existsSync(absPath)) {
2035
- throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2036
- }
2037
- // The raw `.env` may contain `X=$(cmd)`, which executes if `source`d.
2038
- // Warning goes to stderr (never contaminates the path on stdout) and is
2039
- // suppressed with --quiet for the legitimate `_FILE` / `--env-file` use.
2040
- if (args.quiet !== true) {
2041
- process.stderr.write(`warning: this is the raw file path. Do NOT \`source\` it (shell substitutions in the file would execute).\n` +
2042
- ` To inject values run: akm env run ${args.ref} -- <command>\n`);
2043
- }
2044
- process.stdout.write(`${absPath}\n`);
2045
- });
2046
- },
2047
- });
2048
- const envExportCommand = defineCommand({
2049
- meta: {
2050
- name: "export",
2051
- description: "Write safe `export KEY='value'` lines to a file (mode 0600) for `source`-ing — requires --out <path>. Values are re-serialised single-quoted so a raw `.env` cannot execute on load, and are NEVER printed to stdout. To use values directly, prefer `akm env run <ref> -- <command>`.",
2052
- },
2053
- args: {
2054
- ref: { type: "positional", description: "Env ref", required: true },
2055
- out: { type: "string", alias: "o", description: "Destination file (required). Written at mode 0600." },
2056
- },
2057
- run({ args }) {
2058
- return runWithJsonErrors(async () => {
2059
- const outPath = getHyphenatedArg(args, "out");
2060
- if (!outPath) {
2061
- throw new UsageError("`akm env export` writes to a file — pass --out <path>.\n" +
2062
- " To use values directly, run `akm env run <ref> -- <command>` (or `-- $SHELL` for an interactive\n" +
2063
- " session). export never prints values to stdout, to avoid leaking them into a captured context.", "MISSING_REQUIRED_ARGUMENT");
2064
- }
2065
- const { name, absPath, source } = resolveEnvPath(args.ref);
2066
- if (!fs.existsSync(absPath)) {
2067
- throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2068
- }
2069
- const { buildShellExportScript } = await import("./commands/env.js");
2070
- const resolvedOut = path.resolve(outPath);
2071
- writeFileAtomic(resolvedOut, buildShellExportScript(absPath), 0o600);
2072
- output("env-export", { ref: makeEnvRef(name, source), out: resolvedOut });
2073
- });
2074
- },
2075
- });
2076
- /**
2077
- * Shared implementation for `env run` (and the deprecated `vault run` shim).
2078
- * Injects an entire env file's values into the child process env — never via a
2079
- * shell — after scanning the injected keys for process-hijacking variables.
2080
- */
2081
- async function runEnvInjected(target, opts) {
2082
- const dashIndex = process.argv.indexOf("--");
2083
- if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2084
- throw new UsageError("Missing command. Usage: akm env run <ref> -- <command>");
2085
- }
2086
- const command = process.argv.slice(dashIndex + 1);
2087
- const { name, absPath, source } = resolveEnvPath(target);
2088
- if (!fs.existsSync(absPath)) {
2089
- // Help users who reach for the removed single-key `ref/KEY` form.
2090
- const slash = target.lastIndexOf("/");
2091
- if (slash > 0) {
2092
- const maybeKey = target.slice(slash + 1);
2093
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(maybeKey)) {
2094
- let baseExists = false;
2095
- try {
2096
- baseExists = fs.existsSync(resolveEnvPath(target.slice(0, slash)).absPath);
2097
- }
2098
- catch {
2099
- baseExists = false;
2100
- }
2101
- if (baseExists) {
2102
- throw new UsageError(`'akm env run' injects the whole file; the single-key '<ref>/${maybeKey}' form was removed.\n` +
2103
- ` For one value use a secret: \`akm secret run secret:${maybeKey} ${maybeKey} -- <command>\`.`, "INVALID_FLAG_VALUE");
2104
- }
2105
- }
2106
- }
2107
- throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2108
- }
2109
- const { loadEnv } = await import("./commands/env.js");
2110
- const allValues = loadEnv(absPath);
2111
- // Value-safe key filtering (--only / --except operate on key NAMES only).
2112
- let envValues = allValues;
2113
- if (opts.only && opts.except) {
2114
- throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
2115
- }
2116
- if (opts.only) {
2117
- const wanted = new Set(opts.only);
2118
- const missing = opts.only.filter((k) => !(k in allValues));
2119
- if (missing.length > 0) {
2120
- process.stderr.write(`warning: --only key(s) not present in ${makeEnvRef(name, source)}: ${missing.join(", ")}\n`);
2121
- }
2122
- envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
2123
- }
2124
- else if (opts.except) {
2125
- const excluded = new Set(opts.except);
2126
- envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
2127
- }
2128
- const keys = Object.keys(envValues);
2129
- // Scan injected keys for known process-hijacking variables (LD_PRELOAD,
2130
- // PATH, ...). Block for third-party-sourced stashes (origin has a registryId);
2131
- // warn for the operator's own first-party stash, where they own the file.
2132
- const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
2133
- const dangerous = keys.filter(isDangerousEnvKey);
2134
- if (dangerous.length > 0) {
2135
- const detail = `Env "${makeEnvRef(name, source)}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
2136
- if (source.registryId) {
2137
- throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
2138
- ` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
2139
- }
2140
- process.stderr.write(`warning: ${detail} Injecting anyway (first-party stash).\n`);
2141
- }
2142
- const mergedEnv = { ...process.env };
2143
- for (const [envKey, envValue] of Object.entries(envValues)) {
2144
- mergedEnv[envKey] = envValue;
2145
- }
2146
- // Audit trail: keys only, never values. A single `env_access` event carries a
2147
- // `deprecatedAlias` marker when reached via the `vault run` shim, so log
2148
- // consumers see one stable event type without a doubled physical record.
2149
- appendEvent({
2150
- eventType: "env_access",
2151
- ref: makeEnvRef(name, source),
2152
- metadata: opts.viaVault ? { keys, deprecatedAlias: "vault_access" } : { keys },
2153
- });
2154
- const result = spawnSync(command[0], command.slice(1), {
2155
- stdio: "inherit",
2156
- env: mergedEnv,
2157
- });
2158
- if (result.error) {
2159
- // Classify spawn failures (#483). Raw ErrnoException leaks a bare
2160
- // "spawn ENOENT" with no hint — wrap it so consumers get a usable
2161
- // code + hint in the standard JSON envelope.
2162
- const err = result.error;
2163
- if (err.code === "ENOENT") {
2164
- throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm env run'.`);
2165
- }
2166
- if (err.code === "EACCES") {
2167
- throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2168
- }
2169
- throw err;
2170
- }
2171
- process.exit(result.status ?? 0);
2172
- }
2173
- /** Parse a comma/space-separated key list flag into a trimmed, non-empty array. */
2174
- function parseKeyListFlag(raw) {
2175
- if (raw === undefined)
2176
- return undefined;
2177
- const keys = raw
2178
- .split(/[,\s]+/)
2179
- .map((k) => k.trim())
2180
- .filter(Boolean);
2181
- return keys.length > 0 ? keys : undefined;
2182
- }
2183
- const envRunCommand = defineCommand({
2184
- meta: {
2185
- name: "run",
2186
- description: "Run a command with the env file injected into its environment: `akm env run <ref> -- <command>`. Use `-- $SHELL` for an interactive session. Restrict which variables are injected with --only / --except.",
2187
- },
2188
- args: {
2189
- target: { type: "positional", description: "Env ref", required: true },
2190
- only: {
2191
- type: "string",
2192
- description: "Inject ONLY these keys (comma-separated). Mutually exclusive with --except.",
2193
- },
2194
- except: { type: "string", description: "Inject all keys EXCEPT these (comma-separated)." },
2195
- },
2196
- run({ args }) {
2197
- return runWithJsonErrors(() => runEnvInjected(args.target, {
2198
- viaVault: false,
2199
- only: parseKeyListFlag(getHyphenatedArg(args, "only")),
2200
- except: parseKeyListFlag(getHyphenatedArg(args, "except")),
2201
- }));
2202
- },
2203
- });
2204
- const envRemoveCommand = defineCommand({
2205
- meta: { name: "remove", description: "Remove an env file (and its .sensitive marker, if any)" },
2206
- args: {
2207
- ref: { type: "positional", description: "Env ref", required: true },
2208
- yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2209
- },
2210
- run({ args }) {
2211
- return runWithJsonErrors(async () => {
2212
- // Resolve against env/ specifically — never delete the frozen vaults/ copy.
2213
- const parsed = parseEnvRef(args.ref);
2214
- const source = findEnvSource(parsed.origin);
2215
- const envRoot = path.join(source.path, "env");
2216
- const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
2217
- if (!isWithin(absPath, envRoot)) {
2218
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
2219
- }
2220
- const { confirmDestructive } = await import("./cli/confirm.js");
2221
- const confirmed = await confirmDestructive(`Remove env "${args.ref}"? This cannot be undone.`, {
2222
- yes: args.yes === true,
2223
- });
2224
- if (!confirmed) {
2225
- process.stderr.write("Aborted.\n");
2226
- return;
2227
- }
2228
- if (!fs.existsSync(absPath)) {
2229
- throw new NotFoundError(`Env not found: ${makeEnvRef(parsed.name, source)}`);
2230
- }
2231
- const { removeEnv } = await import("./commands/env.js");
2232
- const removed = removeEnv(absPath);
2233
- output("env-remove", { ref: makeEnvRef(parsed.name, source), removed });
2234
- });
2235
- },
2236
- });
2237
- const envCommand = defineCommand({
2238
- meta: {
2239
- name: "env",
2240
- description: "Manage `.env` files — a group of related CONFIGURATION values for an app or service (URLs, flags, plus any credentials it needs), loaded together. Values may or may not be sensitive; akm protects them all the same (key names visible, values never in structured output). For a single sensitive value used on its own (an auth token, key, or cert), use `akm secret`.",
2241
- },
2242
- subCommands: {
2243
- list: envListCommand,
2244
- path: envPathCommand,
2245
- export: envExportCommand,
2246
- run: envRunCommand,
2247
- create: envCreateCommand,
2248
- remove: envRemoveCommand,
2249
- },
2250
- run({ args }) {
2251
- return runWithJsonErrors(async () => {
2252
- if (hasSubcommand(args, ENV_SUBCOMMAND_SET))
2253
- return;
2254
- const { listKeys } = await import("./commands/env.js");
2255
- output("env-list", { envs: listEnvsRecursive(listKeys) });
2256
- });
2257
- },
2258
- });
2259
- // ── vault (DEPRECATED) ────────────────────────────────────────────────────────
2260
- //
2261
- // `akm vault` is deprecated in 0.8.0 and removed in 0.9.0. The verb now warns
2262
- // to stderr and delegates to the `env` handlers. Entry management (`set` /
2263
- // `unset`) and the single-key `run <ref>/KEY` form are hard-errors with a
2264
- // signpost to `akm secret` — silent behaviour changes around secret material
2265
- // are unacceptable.
2266
- function emitVaultDeprecation(sub) {
2267
- process.stderr.write(`warning: 'akm vault ${sub}' is deprecated and will be removed in 0.9.0. Use 'akm env ${sub}'.\n` +
2268
- " For single-value injection use 'akm secret'.\n");
2269
- }
2270
- function emitFlagDeprecation(oldFlag, newFlag, cmd) {
2271
- if (isQuiet())
2272
- return;
2273
- process.stderr.write(`warning: '${oldFlag}' is deprecated for 'akm ${cmd}'; use '${newFlag}'. Removed in 0.9.0.\n`);
2274
- }
2275
- /**
2276
- * Emit a stderr deprecation warning for a renamed top-level command. The old
2277
- * spelling keeps working in 0.8 (wrap-and-delegate) and is removed in 0.9.0.
2278
- * Suppressed under --quiet; never written to stdout so JSON consumers are
2279
- * unaffected.
2280
- */
2281
- function emitCommandDeprecation(oldCmd, newCmd) {
2282
- if (isQuiet())
2283
- return;
2284
- process.stderr.write(`warning: 'akm ${oldCmd}' is deprecated and will be removed in 0.9.0. Use 'akm ${newCmd}'.\n`);
2285
- }
2286
- const vaultSetCommand = defineCommand({
2287
- meta: { name: "set", description: "DEPRECATED — removed. Edit the .env file directly, or use `akm secret set`." },
2288
- args: {
2289
- ref: { type: "positional", description: "(deprecated)", required: false },
2290
- key: { type: "positional", description: "(deprecated)", required: false },
2291
- },
2292
- run() {
2293
- return runWithJsonErrors(async () => {
2294
- throw new UsageError("'akm vault set' was removed: akm no longer manages individual env entries.\n" +
2295
- " Edit the .env file directly (then run with `akm env run <ref> -- <cmd>`),\n" +
2296
- " or store a single value as a secret: `akm secret set secret:<name>`.", "INVALID_FLAG_VALUE");
2297
- });
2298
- },
2299
- });
2300
- const vaultUnsetCommand = defineCommand({
2301
- meta: { name: "unset", description: "DEPRECATED — removed. Edit the .env file directly." },
2302
- args: {
2303
- ref: { type: "positional", description: "(deprecated)", required: false },
2304
- key: { type: "positional", description: "(deprecated)", required: false },
2305
- },
2306
- run() {
2307
- return runWithJsonErrors(async () => {
2308
- throw new UsageError("'akm vault unset' was removed: akm no longer manages individual env entries.\n" +
2309
- " Edit the .env file directly, or remove a secret with `akm secret remove secret:<name>`.", "INVALID_FLAG_VALUE");
2310
- });
2311
- },
2312
- });
2313
- const vaultListCommand = defineCommand({
2314
- meta: { name: "list", description: "DEPRECATED — use `akm env list`." },
2315
- run() {
2316
- return runWithJsonErrors(async () => {
2317
- emitVaultDeprecation("list");
2318
- const { listKeys } = await import("./commands/env.js");
2319
- output("env-list", { envs: listEnvsRecursive(listKeys) });
2320
- });
2321
- },
2322
- });
2323
- const vaultCreateCommand = defineCommand({
2324
- meta: { name: "create", description: "DEPRECATED — use `akm env create`." },
2325
- args: {
2326
- name: { type: "positional", description: "Env name", required: true },
2327
- sensitive: { type: "boolean", description: "Exclude from list output and the search index", default: false },
2328
- },
2329
- run({ args }) {
2330
- return runWithJsonErrors(async () => {
2331
- emitVaultDeprecation("create");
2332
- const { createEnv } = await import("./commands/env.js");
2333
- const parsed = parseEnvRef(args.name);
2334
- const source = findEnvSource(parsed.origin);
2335
- const envRoot = path.join(source.path, "env");
2336
- const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
2337
- if (!isWithin(absPath, envRoot)) {
2338
- throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
2339
- }
2340
- createEnv(absPath);
2341
- if (args.sensitive) {
2342
- const markerPath = absPath.replace(/\.env$/, ".sensitive");
2343
- if (!fs.existsSync(markerPath))
2344
- fs.writeFileSync(markerPath, "", { mode: 0o600 });
2345
- }
2346
- output("env-create", { ref: makeEnvRef(parsed.name, source) });
2347
- });
2348
- },
2349
- });
2350
- const vaultPathCommand = defineCommand({
2351
- meta: { name: "path", description: "DEPRECATED — use `akm env path`." },
2352
- args: {
2353
- ref: { type: "positional", description: "Env ref", required: true },
2354
- },
2355
- run({ args }) {
2356
- return runWithJsonErrors(async () => {
2357
- emitVaultDeprecation("path");
2358
- const { name, absPath, source } = resolveEnvPath(args.ref);
2359
- if (!fs.existsSync(absPath)) {
2360
- throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2361
- }
2362
- process.stderr.write(`warning: sourcing the raw file executes shell substitutions it contains. Use: akm env run ${args.ref} -- <command>\n`);
2363
- process.stdout.write(`${absPath}\n`);
2364
- });
2365
- },
2366
- });
2367
- const vaultRunCommand = defineCommand({
2368
- meta: { name: "run", description: "DEPRECATED — use `akm env run`. The single-key `<ref>/KEY` form was removed." },
2369
- args: {
2370
- target: { type: "positional", description: "Env ref", required: true },
2371
- },
2372
- run({ args }) {
2373
- return runWithJsonErrors(async () => {
2374
- emitVaultDeprecation("run");
2375
- await runEnvInjected(args.target, { viaVault: true });
2376
- });
2377
- },
2378
- });
2379
- const vaultCommand = defineCommand({
2380
- meta: {
2381
- name: "vault",
2382
- description: "DEPRECATED (use `akm env`) — removed in 0.9.0. Manages whole `.env` files; values never printed.",
2383
- },
2384
- subCommands: {
2385
- list: vaultListCommand,
2386
- path: vaultPathCommand,
2387
- run: vaultRunCommand,
2388
- create: vaultCreateCommand,
2389
- set: vaultSetCommand,
2390
- unset: vaultUnsetCommand,
2391
- },
2392
- run({ args }) {
2393
- return runWithJsonErrors(async () => {
2394
- if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
2395
- return;
2396
- emitVaultDeprecation("list");
2397
- const { listKeys } = await import("./commands/env.js");
2398
- output("env-list", { envs: listEnvsRecursive(listKeys) });
2399
- });
2400
- },
2401
- });
2402
- // ── secret ──────────────────────────────────────────────────────────────────
2403
- //
2404
- // `akm secret` manages whole-file secrets under each stash's secrets/ directory.
2405
- // Unlike vaults (.env key/value), the ENTIRE file is the secret value. The bytes
2406
- // are NEVER written to stdout or structured output. Values reach a command only
2407
- // via `akm secret run` (injected into a child env var) or `akm secret path`
2408
- // (the Docker /run/secrets + `_FILE` convention).
2409
- function parseSecretRef(ref) {
2410
- return parseAssetRef(ref.includes(":") ? ref : `secret:${ref}`);
2411
- }
2412
- function makeSecretRef(name, source) {
2413
- return source?.registryId ? `${source.registryId}//secret:${name}` : `secret:${name}`;
2414
- }
2415
- function resolveSecretPath(ref) {
2416
- const parsed = parseSecretRef(ref);
2417
- if (parsed.type !== "secret") {
2418
- throw new UsageError(`Expected a secret ref (secret:<name>); got "${ref}".`);
2419
- }
2420
- // Source resolution is identical for every asset type; reuse the env helper.
2421
- const source = findEnvSource(parsed.origin);
2422
- const typeRoot = path.join(source.path, "secrets");
2423
- const absPath = resolveAssetPathFromName("secret", typeRoot, parsed.name);
2424
- // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
2425
- if (!isWithin(absPath, typeRoot)) {
2426
- throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
2427
- }
2428
- return { name: parsed.name, absPath, source };
2429
- }
2430
- /** Walk `secrets/` across all stashes, returning one entry per secret file. */
2431
- function listSecretsRecursive() {
2432
- const result = [];
2433
- for (const source of resolveSourceEntries(undefined, loadConfig())) {
2434
- const secretsDir = path.join(source.path, "secrets");
2435
- if (!fs.existsSync(secretsDir))
2436
- continue;
2437
- const walk = (dir) => {
2438
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2439
- const full = path.join(dir, entry.name);
2440
- if (entry.isDirectory()) {
2441
- walk(full);
2442
- continue;
2443
- }
2444
- if (!entry.isFile())
2445
- continue;
2446
- if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
2447
- continue;
2448
- // A sibling `<name>.sensitive` marker suppresses listing.
2449
- if (fs.existsSync(`${full}.sensitive`))
2450
- continue;
2451
- const canonical = deriveCanonicalAssetName("secret", secretsDir, full);
2452
- if (!canonical)
2453
- continue;
2454
- result.push({ ref: makeSecretRef(canonical, source), path: full });
2455
- }
2456
- };
2457
- walk(secretsDir);
2458
- }
2459
- return result;
2460
- }
2461
- const secretListCommand = defineCommand({
2462
- meta: {
2463
- name: "list",
2464
- description: "List all secrets across all stashes by name (the file contents are never shown)",
2465
- },
2466
- run() {
2467
- return runWithJsonErrors(async () => {
2468
- output("secret-list", { secrets: listSecretsRecursive() });
2469
- });
2470
- },
2471
- });
2472
- const secretSetCommand = defineCommand({
2473
- meta: {
2474
- name: "set",
2475
- description: "Create or overwrite a secret. The value is read from stdin by default (never via argv). Use --from-file <path> to import an existing file byte-exact, or --from-env <VAR> to read from an environment variable. Multi-line values are allowed.",
2476
- },
2477
- args: {
2478
- ref: { type: "positional", description: "Secret ref (e.g. secret:deploy-key or just deploy-key)", required: true },
2479
- "from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" },
2480
- "from-env": { type: "string", description: "Read the value from the named environment variable" },
2481
- },
2482
- run({ args }) {
2483
- return runWithJsonErrors(async () => {
2484
- const { setSecret } = await import("./commands/secret.js");
2485
- const { name, absPath, source } = resolveSecretPath(args.ref);
2486
- const fromEnv = getHyphenatedArg(args, "from-env");
2487
- const fromFile = getHyphenatedArg(args, "from-file");
2488
- if (fromEnv !== undefined && fromFile !== undefined) {
2489
- throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
2490
- }
2491
- const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
2492
- let value;
2493
- if (fromFile !== undefined) {
2494
- if (!fs.existsSync(fromFile)) {
2495
- throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
2496
- }
2497
- value = fs.readFileSync(fromFile);
2498
- if (value.byteLength > MAX_SECRET_BYTES) {
2499
- throw new UsageError("Secret exceeds the 5 MB limit.");
2500
- }
2501
- }
2502
- else if (fromEnv !== undefined) {
2503
- const envVal = process.env[fromEnv];
2504
- if (envVal === undefined) {
2505
- throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
2506
- }
2507
- value = Buffer.from(envVal, "utf8");
2508
- }
2509
- else {
2510
- if (process.stdin.isTTY) {
2511
- process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
2512
- }
2513
- let totalBytes = 0;
2514
- const chunks = [];
2515
- for await (const chunk of Bun.stdin.stream()) {
2516
- totalBytes += chunk.byteLength;
2517
- if (totalBytes > MAX_SECRET_BYTES) {
2518
- throw new UsageError("Secret exceeds the 5 MB limit.");
2519
- }
2520
- chunks.push(chunk);
2521
- }
2522
- // Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
2523
- // stores the token without the shell-added newline. Use --from-file for
2524
- // byte-exact storage of multi-line material (PEM keys, certs).
2525
- value = Buffer.from(Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""), "utf8");
2526
- }
2527
- setSecret(absPath, value);
2528
- output("secret-set", { ref: makeSecretRef(name, source) });
2529
- });
2530
- },
2531
- });
2532
- const secretPathCommand = defineCommand({
2533
- meta: {
2534
- name: "path",
2535
- description: "Print the absolute secret file path for the Docker `_FILE` convention, e.g. `MY_SECRET_FILE=$(akm secret path secret:deploy-key)`.",
2536
- },
2537
- args: {
2538
- ref: { type: "positional", description: "Secret ref", required: true },
2539
- },
2540
- run({ args }) {
2541
- return runWithJsonErrors(async () => {
2542
- const { name, absPath, source } = resolveSecretPath(args.ref);
2543
- if (!fs.existsSync(absPath)) {
2544
- throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2545
- }
2546
- process.stdout.write(`${absPath}\n`);
2547
- });
2548
- },
2549
- });
2550
- const secretRunCommand = defineCommand({
2551
- meta: {
2552
- name: "run",
2553
- description: "Run a command with a secret's value injected into an env var: `akm secret run <ref> <VAR> -- <command>`. The value is set as $VAR in the child process only.",
2554
- },
2555
- args: {
2556
- ref: { type: "positional", description: "Secret ref", required: true },
2557
- var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
2558
- },
2559
- run({ args }) {
2560
- return runWithJsonErrors(async () => {
2561
- // Validate the target env var name FIRST (before the command split) so a
2562
- // dangerous/invalid name is rejected regardless of how the command is
2563
- // supplied — and so the failure does not depend on argv parsing.
2564
- const varName = args.var;
2565
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
2566
- throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
2567
- }
2568
- const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
2569
- if (isDangerousEnvKey(varName)) {
2570
- throw new UsageError(`Refusing to inject a secret into "${varName}": it is a known process-hijacking variable (e.g. LD_PRELOAD, PATH).`, "INVALID_FLAG_VALUE");
2571
- }
2572
- const dashIndex = process.argv.indexOf("--");
2573
- if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2574
- throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
2575
- }
2576
- const command = process.argv.slice(dashIndex + 1);
2577
- const { name, absPath, source } = resolveSecretPath(args.ref);
2578
- if (!fs.existsSync(absPath)) {
2579
- throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2580
- }
2581
- const { readValue } = await import("./commands/secret.js");
2582
- const mergedEnv = { ...process.env };
2583
- mergedEnv[varName] = readValue(absPath).toString("utf8");
2584
- // Audit trail: record access by ref + var name only — never the value.
2585
- appendEvent({
2586
- eventType: "secret_access",
2587
- ref: makeSecretRef(name, source),
2588
- metadata: { var: varName },
2589
- });
2590
- const result = spawnSync(command[0], command.slice(1), {
2591
- stdio: "inherit",
2592
- env: mergedEnv,
2593
- });
2594
- if (result.error) {
2595
- const err = result.error;
2596
- if (err.code === "ENOENT") {
2597
- throw new NotFoundError(`Command not found: ${command[0]}`, "FILE_NOT_FOUND", `Install '${command[0]}' or add its directory to PATH before invoking 'akm secret run'.`);
2598
- }
2599
- if (err.code === "EACCES") {
2600
- throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2601
- }
2602
- throw err;
2603
- }
2604
- process.exit(result.status ?? 0);
2605
- });
2606
- },
2607
- });
2608
- const secretRemoveCommand = defineCommand({
2609
- meta: { name: "remove", description: "Remove a secret (and its .sensitive marker, if any)" },
2610
- args: {
2611
- ref: { type: "positional", description: "Secret ref", required: true },
2612
- yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2613
- },
2614
- run({ args }) {
2615
- return runWithJsonErrors(async () => {
2616
- const { name, absPath, source } = resolveSecretPath(args.ref);
2617
- const { confirmDestructive } = await import("./cli/confirm.js");
2618
- const confirmed = await confirmDestructive(`Remove secret "${args.ref}"? This cannot be undone.`, {
2619
- yes: args.yes === true,
2620
- });
2621
- if (!confirmed) {
2622
- process.stderr.write("Aborted.\n");
2623
- return;
2624
- }
2625
- const { removeSecret } = await import("./commands/secret.js");
2626
- if (!fs.existsSync(absPath)) {
2627
- throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2628
- }
2629
- const removed = removeSecret(absPath);
2630
- output("secret-remove", { ref: makeSecretRef(name, source), removed });
2631
- });
2632
- },
2633
- });
2634
- const secretCommand = defineCommand({
2635
- meta: {
2636
- name: "secret",
2637
- description: "Manage secrets — a single sensitive value used on its own for authentication (an API token, a PEM private key, a TLS cert), one value per file. Names are visible; the file contents are the value and never appear in structured output. For a group of related configuration loaded together, use `akm env`.",
2638
- },
2639
- subCommands: {
2640
- list: secretListCommand,
2641
- path: secretPathCommand,
2642
- run: secretRunCommand,
2643
- set: secretSetCommand,
2644
- remove: secretRemoveCommand,
2645
- },
2646
- run({ args }) {
2647
- return runWithJsonErrors(async () => {
2648
- if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
2649
- return;
2650
- output("secret-list", { secrets: listSecretsRecursive() });
2651
- });
2652
- },
2653
- });
2654
- // ── Wiki subcommands ─────────────────────────────────────────────────────────
2655
- const wikiCreateCommand = defineCommand({
2656
- meta: { name: "create", description: "Scaffold a new wiki under <stashDir>/wikis/<name>/" },
2657
- args: {
2658
- name: { type: "positional", description: "Wiki name (lowercase, digits, hyphens)", required: true },
2659
- },
2660
- run({ args }) {
2661
- return runWithJsonErrors(async () => {
2662
- const { createWiki } = await import("./wiki/wiki.js");
2663
- const stashDir = resolveStashDir();
2664
- const result = createWiki(stashDir, args.name);
2665
- output("wiki-create", result);
2666
- });
2667
- },
2668
- });
2669
- const wikiRegisterCommand = defineCommand({
2670
- meta: {
2671
- name: "register",
2672
- description: "Register an existing directory or repo as a first-class wiki without copying or mutating it; refreshes source and wiki search state immediately",
2673
- },
2674
- args: {
2675
- name: { type: "positional", description: "Wiki name (lowercase, digits, hyphens)", required: true },
2676
- ref: { type: "positional", description: "Path or repo ref for the external wiki source", required: true },
2677
- writable: {
2678
- type: "boolean",
2679
- description: "Mark a git-backed source as writable so changes can be pushed back",
2680
- default: false,
2681
- },
2682
- "max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
2683
- "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
2684
- },
2685
- run({ args }) {
2686
- return runWithJsonErrors(async () => {
2687
- const { registerWikiSource } = await import("./commands/source-add");
2688
- const result = await registerWikiSource({
2689
- ref: args.ref.trim(),
2690
- name: args.name,
2691
- options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
2692
- writable: args.writable,
2693
- });
2694
- output("wiki-register", result);
2695
- });
2696
- },
2697
- });
2698
- const wikiListCommand = defineCommand({
2699
- meta: { name: "list", description: "List wikis with page/raw counts and last-modified timestamps" },
2700
- run() {
2701
- return runWithJsonErrors(async () => {
2702
- const { listWikis } = await import("./wiki/wiki.js");
2703
- const stashDir = resolveStashDir();
2704
- const wikis = listWikis(stashDir);
2705
- output("wiki-list", { wikis });
2706
- });
2707
- },
2708
- });
2709
- const wikiShowCommand = defineCommand({
2710
- meta: { name: "show", description: "Show a wiki's path, description, counts, and last 3 log entries" },
2711
- args: {
2712
- name: { type: "positional", description: "Wiki name", required: true },
2713
- },
2714
- run({ args }) {
2715
- return runWithJsonErrors(async () => {
2716
- const { showWiki } = await import("./wiki/wiki.js");
2717
- const stashDir = resolveStashDir();
2718
- const result = showWiki(stashDir, args.name);
2719
- output("wiki-show", result);
2720
- });
2721
- },
2722
- });
2723
- const wikiRemoveCommand = defineCommand({
2724
- meta: {
2725
- name: "remove",
2726
- description: "Remove a wiki and refresh the index. Preserves raw/ by default; pass --with-sources to also delete raw/",
2727
- },
2728
- args: {
2729
- name: { type: "positional", description: "Wiki name", required: true },
2730
- yes: {
2731
- type: "boolean",
2732
- alias: "y",
2733
- description: "Skip confirmation prompt (required in non-interactive shells)",
2734
- default: false,
2735
- },
2736
- force: {
2737
- type: "boolean",
2738
- description: "DEPRECATED — use -y/--yes. Removed in 0.9.0.",
2739
- default: false,
2740
- },
2741
- "with-sources": {
2742
- type: "boolean",
2743
- description: "Also delete the raw/ directory (immutable ingested sources)",
2744
- default: false,
2745
- },
2746
- },
2747
- run({ args }) {
2748
- return runWithJsonErrors(async () => {
2749
- if (args.yes !== true && args.force === true) {
2750
- emitFlagDeprecation("--force", "-y/--yes", "wiki remove");
2751
- }
2752
- const { confirmDestructive } = await import("./cli/confirm.js");
2753
- const confirmed = await confirmDestructive(`Remove wiki "${args.name}"? This cannot be undone.`, {
2754
- yes: args.yes === true || args.force === true,
2755
- });
2756
- if (!confirmed) {
2757
- process.stderr.write("Aborted.\n");
2758
- return;
2759
- }
2760
- const withSources = getHyphenatedBoolean(args, "with-sources");
2761
- const { removeWiki } = await import("./wiki/wiki.js");
2762
- const { akmIndex } = await import("./indexer/indexer");
2763
- const stashDir = resolveStashDir();
2764
- const result = removeWiki(stashDir, args.name, { withSources });
2765
- await akmIndex({ stashDir });
2766
- output("wiki-remove", result);
2767
- });
2768
- },
2769
- });
2770
- const wikiPagesCommand = defineCommand({
2771
- meta: {
2772
- name: "pages",
2773
- description: "List wiki pages (ref + frontmatter description), excluding schema/index/log/raw",
2774
- },
2775
- args: {
2776
- name: { type: "positional", description: "Wiki name", required: true },
2777
- },
2778
- run({ args }) {
2779
- return runWithJsonErrors(async () => {
2780
- const { listPages } = await import("./wiki/wiki.js");
2781
- const stashDir = resolveStashDir();
2782
- const pages = listPages(stashDir, args.name);
2783
- output("wiki-pages", { wiki: args.name, pages });
2784
- });
2785
- },
2786
- });
2787
- const wikiSearchCommand = defineCommand({
2788
- meta: {
2789
- name: "search",
2790
- description: "Search wiki pages within a single wiki (scoped wrapper over `akm search --type wiki`; excludes raw/schema/index/log and returns canonical wiki refs)",
2791
- },
2792
- args: {
2793
- name: { type: "positional", description: "Wiki name", required: true },
2794
- query: { type: "positional", description: "Search query", required: true },
2795
- limit: { type: "string", description: "Max hits (default 20)", required: false },
2796
- },
2797
- run({ args }) {
2798
- return runWithJsonErrors(async () => {
2799
- const { resolveWikiSource, searchInWiki } = await import("./wiki/wiki.js");
2800
- const stashDir = resolveStashDir();
2801
- resolveWikiSource(stashDir, args.name);
2802
- const parsedLimit = args.limit ? Number(args.limit) : undefined;
2803
- const limit = typeof parsedLimit === "number" && Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined;
2804
- const response = await searchInWiki({ stashDir, wikiName: args.name, query: args.query, limit });
2805
- output("search", response);
2806
- });
2807
- },
2808
- });
2809
- const wikiStashCommand = defineCommand({
2810
- meta: {
2811
- name: "stash",
2812
- description: "Copy a source into wikis/<name>/raw/<slug>.md with frontmatter. Source may be a file path, URL, or '-' for stdin.",
2813
- },
2814
- args: {
2815
- name: { type: "positional", description: "Wiki name", required: true },
2816
- source: { type: "positional", description: "Source file path, URL, or '-' to read from stdin", required: true },
2817
- as: { type: "string", description: "Preferred slug base (defaults to source filename or first-line slug)" },
2818
- target: {
2819
- type: "string",
2820
- description: "Name of a writable stash source to write into instead of the default stash. Must match a configured source name (run `akm list` to see sources).",
2821
- },
2822
- },
2823
- run({ args }) {
2824
- return runWithJsonErrors(async () => {
2825
- const { stashRaw } = await import("./wiki/wiki.js");
2826
- const { content, preferredName } = await (async () => {
2827
- if (!isHttpUrl(args.source))
2828
- return readKnowledgeInput(args.source);
2829
- const { fetchWebsiteMarkdownSnapshot } = await import("./sources/website-ingest");
2830
- const snapshot = await fetchWebsiteMarkdownSnapshot(args.source);
2831
- return { content: snapshot.content, preferredName: args.as ?? snapshot.preferredName };
2832
- })();
2833
- let stashDir;
2834
- if (args.target) {
2835
- // Resolve the named source to its filesystem path.
2836
- const cfg = loadConfig();
2837
- const sources = resolveConfiguredSources(cfg);
2838
- const match = sources.find((s) => s.name === args.target);
2839
- if (!match) {
2840
- throw new UsageError(`--target must reference a configured source name. No source named "${args.target}" found. Run \`akm list\` to see available sources.`, "INVALID_FLAG_VALUE");
2841
- }
2842
- const spec = match.source;
2843
- if (spec.type !== "filesystem" && spec.type !== "local") {
2844
- throw new ConfigError(`Source "${args.target}" is not a filesystem source and cannot be used as a wiki stash target.`, "INVALID_CONFIG_FILE", `Use a source with type "filesystem" or "local", or omit --target to use the default stash.`);
2845
- }
2846
- stashDir = spec.path;
2847
- }
2848
- else {
2849
- stashDir = resolveStashDir();
2850
- }
2851
- const result = stashRaw({
2852
- stashDir,
2853
- wikiName: args.name,
2854
- content,
2855
- preferredName: args.as ?? preferredName,
2856
- explicitSlug: args.as !== undefined,
2857
- });
2858
- output("wiki-stash", { ok: true, wiki: args.name, source: args.source, ...result });
2859
- });
2860
- },
2861
- });
2862
- const wikiLintCommand = defineCommand({
2863
- meta: {
2864
- name: "lint",
2865
- description: "Structural lint for a wiki: orphans, broken xrefs, missing descriptions, uncited raws, stale index",
2866
- },
2867
- args: {
2868
- name: { type: "positional", description: "Wiki name", required: true },
2869
- },
2870
- async run({ args }) {
2871
- let findingCount = 0;
2872
- await runWithJsonErrors(async () => {
2873
- const { lintWiki } = await import("./wiki/wiki.js");
2874
- const stashDir = resolveStashDir();
2875
- const report = lintWiki(stashDir, args.name);
2876
- output("wiki-lint", report);
2877
- findingCount = report.findings.length;
2878
- });
2879
- if (findingCount > 0)
2880
- process.exit(1); // EXIT_GENERAL
2881
- },
2882
- });
2883
- const wikiIngestCommand = defineCommand({
2884
- meta: {
2885
- name: "ingest",
2886
- description: "Dispatch an agent to execute the ingest workflow for this wiki. Uses --profile or config.defaults.agent.",
2887
- },
2888
- args: {
2889
- name: { type: "positional", description: "Wiki name", required: true },
2890
- profile: {
2891
- type: "string",
2892
- description: "Agent profile to use (default: config.defaults.agent).",
2893
- },
2894
- model: {
2895
- type: "string",
2896
- description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs.",
2897
- },
2898
- "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds." },
2899
- },
2900
- run({ args }) {
2901
- return runWithJsonErrors(async () => {
2902
- const { buildIngestWorkflow } = await import("./wiki/wiki.js");
2903
- const stashDir = resolveStashDir();
2904
- const built = buildIngestWorkflow(stashDir, args.name);
2905
- const config = loadConfig();
2906
- const profileName = getStringArg(args, "profile") ?? config.defaults?.agent;
2907
- if (!profileName) {
2908
- throw new UsageError("akm wiki ingest requires an agent profile. Pass --profile <name> or set defaults.agent in config.", "MISSING_REQUIRED_ARGUMENT", "Available profiles are listed under profiles.agent in your config. Run `akm config get profiles.agent` to inspect.");
2909
- }
2910
- const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
2911
- const model = getStringArg(args, "model");
2912
- const { getDefaultLlmConfig } = await import("./core/config.js");
2913
- const dispatchResult = await akmAgentDispatch({
2914
- profileName,
2915
- agentConfig: config,
2916
- llmConfig: getDefaultLlmConfig(config),
2917
- prompt: built.workflow,
2918
- dispatch: {
2919
- prompt: built.workflow,
2920
- ...(model !== undefined ? { model } : {}),
2921
- },
2922
- ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
2923
- });
2924
- output("wiki-ingest", {
2925
- wiki: built.wiki,
2926
- path: built.path,
2927
- schemaPath: built.schemaPath,
2928
- dispatched: true,
2929
- profile: profileName,
2930
- agentResult: dispatchResult,
2931
- });
2932
- });
2933
- },
2934
- });
2935
- const wikiCommand = defineCommand({
2936
- meta: {
2937
- name: "wiki",
2938
- description: "Manage multiple markdown wikis (Karpathy-style). akm surfaces (lifecycle, raw/, lint, index); the agent writes pages.",
2939
- },
2940
- subCommands: {
2941
- create: wikiCreateCommand,
2942
- register: wikiRegisterCommand,
2943
- list: wikiListCommand,
2944
- show: wikiShowCommand,
2945
- remove: wikiRemoveCommand,
2946
- pages: wikiPagesCommand,
2947
- search: wikiSearchCommand,
2948
- stash: wikiStashCommand,
2949
- lint: wikiLintCommand,
2950
- ingest: wikiIngestCommand,
2951
- },
2952
- run({ args }) {
2953
- return runWithJsonErrors(async () => {
2954
- if (hasSubcommand(args, WIKI_SUBCOMMAND_SET))
2955
- return;
2956
- // Default action: list wikis
2957
- const { listWikis } = await import("./wiki/wiki.js");
2958
- output("wiki-list", { wikis: listWikis(resolveStashDir()) });
2959
- });
2960
- },
2961
- });
2962
- // ── `akm events` ────────────────────────────────────────────────────────────
2963
- // Append-only events stream surface (#204). `list` reads state.db events
2964
- // with optional --since/--type/--ref filters; `tail` follows the table via
2965
- // a polling loop and prints each event as a single JSONL line.
2966
- const eventsListCommand = defineCommand({
2967
- meta: { name: "list", description: "List events from the append-only state.db events stream" },
2968
- args: {
2969
- since: {
2970
- type: "string",
2971
- description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
2972
- },
2973
- type: { type: "string", description: "Filter by event type (add, remove, remember, feedback, ...)" },
2974
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
2975
- "exclude-tags": {
2976
- type: "string",
2977
- description: "Exclude events matching these tags (repeatable)",
2978
- },
2979
- "include-tags": {
2980
- type: "string",
2981
- description: "Only include events with ALL these tags (repeatable)",
2982
- },
2983
- },
2984
- run({ args }) {
2985
- return runWithJsonErrors(() => {
2986
- const excludeTags = parseAllFlagValues("--exclude-tags");
2987
- const includeTags = parseAllFlagValues("--include-tags");
2988
- const result = akmEventsList({
2989
- since: args.since,
2990
- type: args.type,
2991
- ref: args.ref,
2992
- ...(excludeTags.length > 0 ? { excludeTags } : {}),
2993
- ...(includeTags.length > 0 ? { includeTags } : {}),
2994
- });
2995
- output("events-list", result);
2996
- });
2997
- },
2998
- });
2999
- const eventsTailCommand = defineCommand({
3000
- meta: { name: "tail", description: "Follow the append-only state.db events stream (polling)" },
3001
- args: {
3002
- since: {
3003
- type: "string",
3004
- description: "ISO timestamp / epoch ms, OR `@offset:<id>` for a durable row-id cursor (resume across processes)",
3005
- },
3006
- type: { type: "string", description: "Filter by event type" },
3007
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
3008
- "interval-ms": { type: "string", description: "Polling interval in ms (default: 75)" },
3009
- "max-duration-ms": { type: "string", description: "Stop after this many ms (default: never)" },
3010
- "max-events": { type: "string", description: "Stop after observing this many events" },
3011
- "exclude-tags": {
3012
- type: "string",
3013
- description: "Exclude events matching these tags (repeatable)",
3014
- },
3015
- "include-tags": {
3016
- type: "string",
3017
- description: "Only include events with ALL these tags (repeatable)",
3018
- },
3019
- },
3020
- async run({ args }) {
3021
- await runWithJsonErrors(async () => {
3022
- const intervalMs = parsePositiveIntFlag(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
3023
- const maxDurationMs = parsePositiveIntFlag(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
3024
- const maxEvents = parsePositiveIntFlag(getHyphenatedArg(args, "max-events"), "--max-events");
3025
- const mode = getOutputMode();
3026
- // In streaming text mode we want each event to print as soon as it
3027
- // arrives. The polling loop emits via `onEvent`; the final result is
3028
- // also rendered through the standard output() pipeline so JSON
3029
- // consumers always get the canonical envelope.
3030
- const stream = mode.format === "text" || mode.format === "jsonl";
3031
- const excludeTags = parseAllFlagValues("--exclude-tags");
3032
- const includeTags = parseAllFlagValues("--include-tags");
3033
- const result = await akmEventsTail({
3034
- since: args.since,
3035
- type: args.type,
3036
- ref: args.ref,
3037
- intervalMs,
3038
- maxDurationMs,
3039
- maxEvents,
3040
- ...(excludeTags.length > 0 ? { excludeTags } : {}),
3041
- ...(includeTags.length > 0 ? { includeTags } : {}),
3042
- onEvent: stream
3043
- ? (event) => {
3044
- if (mode.format === "jsonl") {
3045
- console.log(JSON.stringify(event));
3046
- }
3047
- else {
3048
- console.log(formatEventLine(event));
3049
- }
3050
- }
3051
- : undefined,
3052
- });
3053
- // Emit the canonical envelope last (JSON/YAML modes rely on this;
3054
- // streaming modes already printed each event but we still emit a
3055
- // trailer so callers can persist the resumable cursor).
3056
- if (!stream) {
3057
- output("events-tail", result);
3058
- }
3059
- else if (mode.format === "jsonl") {
3060
- // Final discriminated trailer row so jsonl consumers can resume.
3061
- const trailer = {
3062
- _kind: "trailer",
3063
- schemaVersion: 1,
3064
- nextOffset: result.nextOffset,
3065
- totalCount: result.totalCount,
3066
- reason: result.reason,
3067
- };
3068
- console.log(JSON.stringify(trailer));
3069
- }
3070
- else {
3071
- // text mode: keep stdout pristine for line-oriented parsers and
3072
- // emit the trailer on stderr.
3073
- process.stderr.write(`[events-tail] reason=${result.reason} nextOffset=${result.nextOffset} total=${result.totalCount}\n`);
3074
- }
3075
- });
3076
- },
3077
- });
3078
- const eventsCommand = defineCommand({
3079
- meta: {
3080
- name: "events",
3081
- alias: "log",
3082
- description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
3083
- },
3084
- subCommands: {
3085
- list: eventsListCommand,
3086
- tail: eventsTailCommand,
3087
- },
3088
- });
3089
- // ── lessons subcommands (Phase 7A / Advantage D4c) ──────────────────────────
3090
- const lessonsCoverageCommand = defineCommand({
3091
- meta: {
3092
- name: "coverage",
3093
- description: "Report tags that exist on indexed assets but are NOT yet covered by any lesson.\n\n" +
3094
- "Useful for spotting topics where the stash has skills/commands/scripts but no\n" +
3095
- "crystallized lesson — a signal that the team has tacit knowledge worth distilling.\n\n" +
3096
- "Default output is JSON: { uncoveredTags: string[], lessonTagCount: number, totalTagCount: number }.\n" +
3097
- "Pass --format text for a plain-text bulleted list.",
3098
- },
3099
- args: {},
3100
- run() {
3101
- return runWithJsonErrors(() => {
3102
- const db = openExistingDatabase();
3103
- try {
3104
- const allTagSet = collectTagSetFromEntries(db, undefined);
3105
- const lessonTagSet = collectTagSetFromEntries(db, "lesson");
3106
- const uncovered = [];
3107
- for (const tag of allTagSet) {
3108
- if (!lessonTagSet.has(tag))
3109
- uncovered.push(tag);
3110
- }
3111
- uncovered.sort((a, b) => a.localeCompare(b));
3112
- output("lessons-coverage", {
3113
- ok: true,
3114
- uncoveredTags: uncovered,
3115
- lessonTagCount: lessonTagSet.size,
3116
- totalTagCount: allTagSet.size,
3117
- });
3118
- }
3119
- finally {
3120
- closeDatabase(db);
3121
- }
3122
- });
3123
- },
3124
- });
3125
- /**
3126
- * Walk indexed entries and collect a deduplicated set of tags. When
3127
- * `entryType` is provided, only entries of that type contribute tags.
3128
- *
3129
- * Pure read; never mutates the DB. Used by `akm lessons coverage` (Phase 7A)
3130
- * to compute the diff between all-asset tags and lesson tags.
3131
- */
3132
- function collectTagSetFromEntries(db, entryType) {
3133
- const tags = new Set();
3134
- const stmt = entryType
3135
- ? db.prepare("SELECT entry_json FROM entries WHERE entry_type = ?")
3136
- : db.prepare("SELECT entry_json FROM entries");
3137
- const rows = (entryType ? stmt.all(entryType) : stmt.all());
3138
- for (const row of rows) {
3139
- let parsed;
3140
- try {
3141
- parsed = JSON.parse(row.entry_json);
3142
- }
3143
- catch {
3144
- continue;
3145
- }
3146
- if (!Array.isArray(parsed.tags))
3147
- continue;
3148
- for (const tag of parsed.tags) {
3149
- if (typeof tag === "string" && tag.trim().length > 0) {
3150
- tags.add(tag.trim().toLowerCase());
3151
- }
3152
- }
3153
- }
3154
- return tags;
3155
- }
3156
- const lessonsCommand = defineCommand({
3157
- meta: {
3158
- name: "lessons",
3159
- alias: "lesson",
3160
- description: "Lesson-asset tooling: tag-coverage gaps, strength queries.",
3161
- },
3162
- subCommands: {
3163
- coverage: lessonsCoverageCommand,
3164
- },
3165
- });
3166
- // ── proposal substrate (#225) ────────────────────────────────────────────────
3167
- const proposalListCommand = defineCommand({
3168
- meta: { name: "list", description: "List proposal queue entries" },
3169
- args: {
3170
- status: {
3171
- type: "string",
3172
- description: "Filter by status (pending|accepted|rejected|reverted)",
3173
- },
3174
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
3175
- type: { type: "string", description: "Filter by asset type" },
3176
- },
3177
- run({ args }) {
3178
- return runWithJsonErrors(() => {
3179
- const status = parseProposalStatus(args.status);
3180
- const result = akmProposalList({
3181
- status,
3182
- ref: args.ref,
3183
- type: args.type,
3184
- includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
3185
- });
3186
- output("proposal-list", result);
3187
- });
3188
- },
3189
- });
3190
- const proposalAcceptCommand = defineCommand({
3191
- meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
3192
- args: {
3193
- id: {
3194
- type: "positional",
3195
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
3196
- required: false,
3197
- },
3198
- target: { type: "string", description: "Override the write target by source name" },
3199
- // F-6 / #393: Batch accept by generator, diff size, or age.
3200
- generator: {
3201
- type: "string",
3202
- description: "F-6: Bulk-accept all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
3203
- },
3204
- source: {
3205
- type: "string",
3206
- description: "DEPRECATED — use --generator. Removed in 0.9.0.",
3207
- },
3208
- "max-diff-lines": {
3209
- type: "string",
3210
- description: "F-6: When bulk-accepting, only accept proposals whose content is <= this many lines. Skips larger proposals.",
3211
- },
3212
- "older-than": {
3213
- type: "string",
3214
- description: "F-6: When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
3215
- },
3216
- "dry-run": {
3217
- type: "boolean",
3218
- description: "F-6: List proposals that would be bulk-accepted without accepting them.",
3219
- default: false,
3220
- },
3221
- yes: {
3222
- type: "boolean",
3223
- alias: "y",
3224
- description: "Skip confirmation prompt (required in non-interactive mode for bulk accept)",
3225
- default: false,
3226
- },
3227
- },
3228
- async run({ args }) {
3229
- await runWithJsonErrors(async () => {
3230
- if (args.generator === undefined && args.source !== undefined) {
3231
- emitFlagDeprecation("--source", "--generator", "proposal accept");
3232
- }
3233
- const generator = (args.generator ?? args.source);
3234
- // F-6 / #393: Bulk-accept when --generator is provided without a positional id.
3235
- if (generator && !args.id) {
3236
- const { confirmDestructive } = await import("./cli/confirm.js");
3237
- const confirmed = await confirmDestructive(`Bulk-accept all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
3238
- if (!confirmed) {
3239
- process.stderr.write("Aborted.\n");
3240
- return;
3241
- }
3242
- const { listProposals } = await import("./core/proposals");
3243
- const stashDir = resolveStashDir();
3244
- const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3245
- if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3246
- throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3247
- }
3248
- const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3249
- if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3250
- throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3251
- }
3252
- const maxDiffLines = rawMaxDiff;
3253
- const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3254
- const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3255
- if (p.source !== generator)
3256
- return false;
3257
- if (maxDiffLines !== undefined) {
3258
- const lines = (p.payload.content ?? "").split("\n").length;
3259
- if (lines > maxDiffLines)
3260
- return false;
3261
- }
3262
- if (olderThanMs !== undefined) {
3263
- const age = Date.now() - new Date(p.createdAt).getTime();
3264
- if (age < olderThanMs)
3265
- return false;
3266
- }
3267
- return true;
3268
- });
3269
- const results = [];
3270
- for (const proposal of pending) {
3271
- if (args["dry-run"]) {
3272
- results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3273
- }
3274
- else {
3275
- const result = await akmProposalAccept({ id: proposal.id, target: args.target });
3276
- results.push(result);
3277
- }
3278
- }
3279
- output("proposal-accept-batch", { accepted: results.length, results, dryRun: args["dry-run"] });
3280
- return;
3281
- }
3282
- if (!args.id) {
3283
- throw new UsageError("Usage: akm proposal accept <id> OR akm proposal accept --generator <generator>", "MISSING_REQUIRED_ARGUMENT");
3284
- }
3285
- const result = await akmProposalAccept({ id: args.id, target: args.target });
3286
- output("proposal-accept", result);
3287
- });
3288
- },
3289
- });
3290
- const proposalRejectCommand = defineCommand({
3291
- meta: { name: "reject", description: "Reject a proposal and record the reason" },
3292
- args: {
3293
- id: {
3294
- type: "positional",
3295
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
3296
- required: false,
3297
- },
3298
- reason: { type: "string", description: "Reason for rejection (required)" },
3299
- // F-6 / #393: Batch reject by generator, diff size, or age.
3300
- generator: {
3301
- type: "string",
3302
- description: "F-6: Bulk-reject all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
3303
- },
3304
- source: {
3305
- type: "string",
3306
- description: "DEPRECATED — use --generator. Removed in 0.9.0.",
3307
- },
3308
- "max-diff-lines": {
3309
- type: "string",
3310
- description: "F-6: When bulk-rejecting, only reject proposals whose content is <= this many lines. Skips larger proposals.",
3311
- },
3312
- "older-than": {
3313
- type: "string",
3314
- description: "F-6: When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
3315
- },
3316
- "dry-run": {
3317
- type: "boolean",
3318
- description: "F-6: List proposals that would be bulk-rejected without rejecting them.",
3319
- default: false,
3320
- },
3321
- yes: {
3322
- type: "boolean",
3323
- alias: "y",
3324
- description: "Skip confirmation prompt (required in non-interactive mode)",
3325
- default: false,
3326
- },
3327
- },
3328
- run({ args }) {
3329
- return runWithJsonErrors(async () => {
3330
- if (args.generator === undefined && args.source !== undefined) {
3331
- emitFlagDeprecation("--source", "--generator", "proposal reject");
3332
- }
3333
- const generator = (args.generator ?? args.source);
3334
- if (!args.reason || !String(args.reason).trim()) {
3335
- throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3336
- }
3337
- // F-6 / #393: Bulk-reject when --generator is provided without a positional id.
3338
- if (generator && !args.id) {
3339
- const { confirmDestructive } = await import("./cli/confirm.js");
3340
- const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
3341
- if (!confirmed) {
3342
- process.stderr.write("Aborted.\n");
3343
- return;
3344
- }
3345
- const { listProposals } = await import("./core/proposals");
3346
- const stashDir = resolveStashDir();
3347
- const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3348
- if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3349
- throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3350
- }
3351
- const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3352
- if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3353
- throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3354
- }
3355
- const maxDiffLines = rawMaxDiff;
3356
- const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3357
- const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3358
- if (p.source !== generator)
3359
- return false;
3360
- if (maxDiffLines !== undefined) {
3361
- const lines = (p.payload.content ?? "").split("\n").length;
3362
- if (lines > maxDiffLines)
3363
- return false;
3364
- }
3365
- if (olderThanMs !== undefined) {
3366
- const age = Date.now() - new Date(p.createdAt).getTime();
3367
- if (age < olderThanMs)
3368
- return false;
3369
- }
3370
- return true;
3371
- });
3372
- const results = [];
3373
- for (const proposal of pending) {
3374
- if (args["dry-run"]) {
3375
- results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3376
- }
3377
- else {
3378
- const result = akmProposalReject({ id: proposal.id, reason: String(args.reason) });
3379
- results.push(result);
3380
- }
3381
- }
3382
- output("proposal-reject-batch", { rejected: results.length, results, dryRun: args["dry-run"] });
3383
- return;
3384
- }
3385
- if (!args.id) {
3386
- throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3387
- }
3388
- const { confirmDestructive } = await import("./cli/confirm.js");
3389
- const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? This cannot be undone.`, {
3390
- yes: args.yes === true,
3391
- });
3392
- if (!confirmed) {
3393
- process.stderr.write("Aborted.\n");
3394
- return;
3395
- }
3396
- const result = akmProposalReject({ id: args.id, reason: String(args.reason) });
3397
- output("proposal-reject", result);
3398
- });
3399
- },
3400
- });
3401
- const proposalDiffCommand = defineCommand({
3402
- meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
3403
- args: {
3404
- id: {
3405
- type: "positional",
3406
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
3407
- required: true,
3408
- },
3409
- target: { type: "string", description: "Override the write target by source name" },
3410
- },
3411
- run({ args }) {
3412
- return runWithJsonErrors(() => {
3413
- const result = akmProposalDiff({ id: args.id, target: args.target });
3414
- output("proposal-diff", result);
3415
- });
3416
- },
3417
- });
3418
- // Phase 6C (Advantage D6c): revert an accepted proposal.
3419
- //
3420
- // Exit codes (mapped by `runWithJsonErrors` from the typed errors thrown by
3421
- // `akmProposalRevert` / `revertProposal`):
3422
- // 0 — success; prior content restored.
3423
- // 1 — generic error (also used by `UsageError("INVALID_FLAG_VALUE")` and
3424
- // `UsageError("MISSING_REQUIRED_ARGUMENT")` when the proposal is not
3425
- // accepted, or no backup is available).
3426
- // 1 — `NotFoundError("FILE_NOT_FOUND")` when the proposal id does not resolve.
3427
- const proposalRevertCommand = defineCommand({
3428
- meta: {
3429
- name: "revert",
3430
- description: "Revert an accepted proposal: restore the prior asset content from the backup captured at promotion time. " +
3431
- "Errors if the proposal is not accepted or has no backup (new-asset proposals leave no backup). " +
3432
- "Accepts the full proposal UUID or the asset ref. UUID prefixes are not supported for archived proposals — use the full UUID.",
3433
- },
3434
- args: {
3435
- id: {
3436
- type: "positional",
3437
- description: "Proposal id (full uuid) or asset ref (e.g. skill:akm-dream). UUID prefixes are not supported for archived proposals — use the full UUID.",
3438
- required: true,
3439
- },
3440
- target: { type: "string", description: "Override the write target by source name" },
3441
- },
3442
- async run({ args }) {
3443
- await runWithJsonErrors(async () => {
3444
- const result = await akmProposalRevert({
3445
- id: args.id,
3446
- target: args.target,
3447
- });
3448
- output("proposal-revert", result);
3449
- });
3450
- },
3451
- });
3452
- // `proposal show` (#225): show a single proposal with its validation findings.
3453
- // `akmProposalShow` already backs `akm show proposal <id>` (now deprecated); this
3454
- // is the canonical noun-group entry point.
3455
- const proposalShowCommand = defineCommand({
3456
- meta: { name: "show", description: "Show a single proposal and its validation findings" },
3457
- args: {
3458
- id: {
3459
- type: "positional",
3460
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
3461
- required: true,
3462
- },
3463
- },
3464
- run({ args }) {
3465
- return runWithJsonErrors(() => {
3466
- const result = akmProposalShow({ id: args.id });
3467
- output("proposal-show", result);
3468
- });
3469
- },
3470
- });
3471
- const proposalDrainCommand = defineCommand({
3472
- meta: {
3473
- name: "drain",
3474
- description: "Drain the standing pending proposal backlog using a deterministic triage policy",
3475
- },
3476
- args: {
3477
- policy: {
3478
- type: "string",
3479
- description: "Built-in preset (personal-stash|conservative|manual) or path to a policy file",
3480
- },
3481
- "dry-run": {
3482
- type: "boolean",
3483
- description: "List what would be accepted/rejected/deferred without writing.",
3484
- default: false,
3485
- },
3486
- yes: {
3487
- type: "boolean",
3488
- alias: "y",
3489
- description: "Skip confirmation prompt (required in non-interactive mode for promotion).",
3490
- default: false,
3491
- },
3492
- "max-accepts": {
3493
- type: "string",
3494
- description: "Hard per-run accept ceiling. Accepts beyond this are reported as skippedByCap.",
3495
- },
3496
- "max-diff-lines": {
3497
- type: "string",
3498
- description: "Defer (never promote) accepts whose proposed content exceeds this many lines.",
3499
- },
3500
- "older-than": {
3501
- type: "string",
3502
- description: "Only consider proposals created more than this many days ago.",
3503
- },
3504
- promote: {
3505
- type: "boolean",
3506
- description: "Promote (accept) matching proposals. Default is queue mode (stage only, no writes to assets).",
3507
- default: false,
3508
- },
3509
- judgment: {
3510
- type: "boolean",
3511
- description: "Opt into the judgment tier (llm by default; agent/sdk per config) for deferred items. No-op with a logged triage_deferred summary when no runner is configured.",
3512
- default: false,
3513
- },
3514
- profile: {
3515
- type: "string",
3516
- description: "Read the triage block (policy, applyMode, ceilings, judgment) from this improve profile.",
3517
- },
3518
- },
3519
- async run({ args }) {
3520
- await runWithJsonErrors(async () => {
3521
- const stashDir = resolveStashDir();
3522
- const cfg = loadConfig();
3523
- // Phase 2: read the triage block from the named improve profile. CLI flags
3524
- // always override config; config supplies defaults for any flag omitted.
3525
- const triageConfig = args.profile !== undefined ? resolveImproveProfile(args.profile, cfg).processes?.triage : undefined;
3526
- const policy = resolveDrainPolicy(args.policy ?? triageConfig?.policy);
3527
- const dryRun = args["dry-run"] === true;
3528
- const applyMode = args.promote === true ? "promote" : (triageConfig?.applyMode ?? "queue");
3529
- const maxAccepts = parsePositiveIntFlag(args["max-accepts"], "--max-accepts") ??
3530
- triageConfig?.maxAcceptsPerRun ??
3531
- 25;
3532
- const maxDiffLines = parsePositiveIntFlag(args["max-diff-lines"], "--max-diff-lines") ??
3533
- triageConfig?.maxDiffLines;
3534
- const rawOlderThan = parsePositiveIntFlag(args["older-than"], "--older-than");
3535
- const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3536
- // Promotion in promote mode is destructive (commits to git, no batch revert).
3537
- if (applyMode === "promote" && !dryRun) {
3538
- const { confirmDestructive } = await import("./cli/confirm.js");
3539
- const confirmed = await confirmDestructive(`Drain and promote matching pending proposals under policy "${policy.name}"? Promotions commit to git and cannot be batch-reverted.`, { yes: args.yes === true });
3540
- if (!confirmed) {
3541
- process.stderr.write("Aborted.\n");
3542
- return;
3543
- }
3544
- }
3545
- // `--older-than` is applied here as a pre-filter on excludeIds: ids that
3546
- // are too fresh are excluded so the engine never touches them. This reads
3547
- // the pending set once here; drainProposals reads the pending set again
3548
- // internally, so a future engine-level olderThan option could remove this
3549
- // second read (engine API owned by another agent — not changed here).
3550
- let excludeIds;
3551
- if (olderThanMs !== undefined) {
3552
- const { listProposals } = await import("./core/proposals");
3553
- const now = Date.now();
3554
- excludeIds = new Set(listProposals(stashDir, { status: "pending" })
3555
- // Fail SAFE: exclude a proposal when its age cannot be computed
3556
- // (NaN createdAt) OR it is too fresh. An unparseable createdAt must
3557
- // never be treated as old enough to drain/promote.
3558
- .filter((proposal) => {
3559
- const age = now - new Date(proposal.createdAt).getTime();
3560
- return Number.isNaN(age) || age < olderThanMs;
3561
- })
3562
- .map((proposal) => proposal.id));
3563
- }
3564
- // Phase 3: resolve the judgment runner when --judgment is set. Default
3565
- // mode is llm; falls back to defaults.llm when the triage block sets
3566
- // neither mode nor profile (mirrors resolveValidationRunner). null when
3567
- // nothing is configured → the engine leaves deferred items unresolved and
3568
- // emits triage_deferred.
3569
- const judgment = args.judgment === true ? resolveTriageJudgmentRunner(triageConfig?.judgment, cfg) : null;
3570
- const result = await drainProposals({
3571
- stashDir,
3572
- policy,
3573
- applyMode,
3574
- maxAccepts,
3575
- dryRun,
3576
- ...(maxDiffLines !== undefined ? { maxDiffLines } : {}),
3577
- ...(excludeIds ? { excludeIds } : {}),
3578
- judgment,
3579
- });
3580
- output("proposal-drain", {
3581
- schemaVersion: 1,
3582
- ok: true,
3583
- policy: policy.name,
3584
- applyMode,
3585
- dryRun,
3586
- promoted: result.promoted,
3587
- rejected: result.rejected,
3588
- deferred: result.deferred,
3589
- skippedByCap: result.skippedByCap,
3590
- });
3591
- });
3592
- },
3593
- });
3594
- // ── proposal noun group (#225 / 0.8 CLI stabilization) ────────────────────────
3595
- //
3596
- // `akm proposal <verb>` is the canonical grammar in 0.8. The flat verbs
3597
- // (`proposals`/`accept`/`reject`/`diff`/`revert`) remain as deprecated aliases
3598
- // that warn to stderr and delegate to the same command bodies; they are removed
3599
- // in 0.9.0. Bare `akm proposal` behaves as `proposal list` (mirrors `akm env`).
3600
- const PROPOSAL_SUBCOMMAND_SET = new Set(["list", "show", "diff", "accept", "reject", "revert", "drain"]);
3601
- function emitProposalVerbDeprecation(oldVerb, canonical) {
3602
- if (isQuiet())
3603
- return;
3604
- process.stderr.write(`warning: 'akm ${oldVerb}' is deprecated and will be removed in 0.9.0. Use 'akm ${canonical}'.\n`);
3605
- }
3606
- const proposalCommand = defineCommand({
3607
- meta: { name: "proposal", description: "Manage the proposal queue: list, show, diff, accept, reject, revert" },
3608
- args: {
3609
- status: {
3610
- type: "string",
3611
- description: "Filter by status (pending|accepted|rejected|reverted)",
3612
- },
3613
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
3614
- type: { type: "string", description: "Filter by asset type" },
3615
- },
3616
- subCommands: {
3617
- list: proposalListCommand,
3618
- show: proposalShowCommand,
3619
- diff: proposalDiffCommand,
3620
- accept: proposalAcceptCommand,
3621
- reject: proposalRejectCommand,
3622
- revert: proposalRevertCommand,
3623
- drain: proposalDrainCommand,
3624
- },
3625
- run({ args }) {
3626
- return runWithJsonErrors(() => {
3627
- // citty runs the group body even after a subcommand; short-circuit so the
3628
- // default-to-list body only fires for bare `akm proposal [--status …]`.
3629
- if (hasSubcommand(args, PROPOSAL_SUBCOMMAND_SET))
3630
- return;
3631
- const status = parseProposalStatus(args.status);
3632
- const result = akmProposalList({
3633
- status,
3634
- ref: args.ref,
3635
- type: args.type,
3636
- includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
3637
- });
3638
- output("proposal-list", result);
3639
- });
3640
- },
3641
- });
3642
- // Deprecated flat-verb aliases (removed 0.9.0). Each wraps the canonical command
3643
- // body so bulk/guard logic is not duplicated.
3644
- const proposalsCommand = defineCommand({
3645
- meta: { name: "proposals", description: "DEPRECATED — use `akm proposal list`. Removed in 0.9.0." },
3646
- args: proposalListCommand.args,
3647
- run(ctx) {
3648
- emitProposalVerbDeprecation("proposals", "proposal list");
3649
- return proposalListCommand.run?.(ctx);
3650
- },
3651
- });
3652
- const acceptCommand = defineCommand({
3653
- meta: { name: "accept", description: "DEPRECATED — use `akm proposal accept`. Removed in 0.9.0." },
3654
- args: proposalAcceptCommand.args,
3655
- run(ctx) {
3656
- emitProposalVerbDeprecation("accept", "proposal accept");
3657
- return proposalAcceptCommand.run?.(ctx);
3658
- },
3659
- });
3660
- const rejectCommand = defineCommand({
3661
- meta: { name: "reject", description: "DEPRECATED — use `akm proposal reject`. Removed in 0.9.0." },
3662
- args: proposalRejectCommand.args,
3663
- run(ctx) {
3664
- emitProposalVerbDeprecation("reject", "proposal reject");
3665
- return proposalRejectCommand.run?.(ctx);
3666
- },
3667
- });
3668
- const diffCommand = defineCommand({
3669
- meta: { name: "diff", description: "DEPRECATED — use `akm proposal diff`. Removed in 0.9.0." },
3670
- args: proposalDiffCommand.args,
3671
- run(ctx) {
3672
- emitProposalVerbDeprecation("diff", "proposal diff");
3673
- return proposalDiffCommand.run?.(ctx);
3674
- },
3675
- });
3676
- const revertCommand = defineCommand({
3677
- meta: { name: "revert", description: "DEPRECATED — use `akm proposal revert`. Removed in 0.9.0." },
3678
- args: proposalRevertCommand.args,
3679
- run(ctx) {
3680
- emitProposalVerbDeprecation("revert", "proposal revert");
3681
- return proposalRevertCommand.run?.(ctx);
3682
- },
3683
- });
3684
- // ── distill (#228) ──────────────────────────────────────────────────────────
3685
- function parseProposalStatus(raw) {
3686
- if (raw === undefined)
3687
- return undefined;
3688
- const trimmed = raw.trim();
3689
- if (!trimmed)
3690
- return undefined;
3691
- if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected" || trimmed === "reverted") {
3692
- return trimmed;
3693
- }
3694
- throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected, reverted.`, "INVALID_FLAG_VALUE");
3695
- }
3696
- const agentCommand = defineCommand({
3697
- meta: {
3698
- name: "agent",
3699
- description: "Dispatch an agent CLI (opencode, claude, …) with an optional agent asset that provides the system prompt, model, and tool policy. Use <agent-ref> to embody a stash agent, --model to override the model, and --prompt/--command/--workflow to provide the task.",
3700
- },
3701
- args: {
3702
- profile: {
3703
- type: "positional",
3704
- description: "Agent profile / platform to use (opencode, claude, …)",
3705
- required: false,
3706
- },
3707
- "agent-ref": {
3708
- type: "positional",
3709
- description: "Optional agent asset ref (e.g. agent:code-reviewer). Loads system prompt, model, and tool policy from the stash asset.",
3710
- required: false,
3711
- },
3712
- prompt: { type: "string", description: "Task prompt to pass to the agent" },
3713
- command: { type: "string", description: "Load prompt from a command: asset" },
3714
- workflow: { type: "string", description: "Load prompt from a workflow: asset" },
3715
- model: {
3716
- type: "string",
3717
- description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
3718
- },
3719
- "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
3720
- },
3721
- async run({ args }) {
3722
- await runWithJsonErrors(async () => {
3723
- if (!args.profile) {
3724
- throw new UsageError("Usage: akm agent <profile> [<agent-ref>] [--prompt <text>] [--model <model>]", "MISSING_REQUIRED_ARGUMENT", "Provide the agent profile name. Available profiles are listed in profiles.agent.");
3725
- }
3726
- const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
3727
- const config = loadConfig();
3728
- const { getDefaultLlmConfig } = await import("./core/config.js");
3729
- // After 0.8.0 the agent block IS the loaded AkmConfig.
3730
- const agentConfig = config;
3731
- // Resolve agent asset ref → extract system prompt, model, and tool policy.
3732
- const agentRef = getStringArg(args, "agent-ref");
3733
- let systemPrompt;
3734
- let assetModel;
3735
- let assetTools;
3736
- if (agentRef) {
3737
- const { akmShowUnified } = await import("./commands/show.js");
3738
- const asset = await akmShowUnified({ ref: agentRef, detail: "full" });
3739
- systemPrompt = typeof asset.content === "string" ? asset.content : undefined;
3740
- assetModel = typeof asset.modelHint === "string" ? asset.modelHint : undefined;
3741
- assetTools = asset.toolPolicy;
3742
- }
3743
- // --model flag wins over the asset's modelHint.
3744
- const model = getStringArg(args, "model") ?? assetModel;
3745
- const promptText = getStringArg(args, "prompt");
3746
- const commandRef = getStringArg(args, "command");
3747
- const workflowRef = getStringArg(args, "workflow");
3748
- // Only build a dispatch request when there is something to dispatch — a
3749
- // prompt, an agent asset, or a model override. When none of these are
3750
- // present the agent is launched interactively (no injected prompt, no
3751
- // platform-specific flags beyond the profile's base args).
3752
- const hasDispatchContent = !!(promptText ?? commandRef ?? workflowRef ?? systemPrompt ?? model ?? assetTools);
3753
- const result = await akmAgentDispatch({
3754
- profileName: String(args.profile),
3755
- prompt: promptText,
3756
- commandRef,
3757
- workflowRef,
3758
- agentConfig,
3759
- llmConfig: getDefaultLlmConfig(config),
3760
- ...(hasDispatchContent
3761
- ? {
3762
- dispatch: {
3763
- prompt: promptText ?? "",
3764
- systemPrompt,
3765
- model,
3766
- tools: assetTools,
3767
- },
3768
- }
3769
- : {}),
3770
- ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
3771
- });
3772
- output("agent-result", result);
3773
- if (!result.ok) {
3774
- process.exit(EXIT_GENERAL);
3775
- }
3776
- });
3777
- },
3778
- });
3779
- const lintCommand = defineCommand({
3780
- meta: {
3781
- name: "lint",
3782
- description: "Scan stash .md files for structural issues (unquoted colons, missing updated field, orphaned stubs, placeholder stubs, missing name/type, stale paths). Use --fix to auto-fix Tier 1 issues. Exits 0 on success regardless of findings; use --fail-on-flagged for CI fail-on-finding behavior.",
3783
- },
3784
- args: {
3785
- fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
3786
- dir: { type: "string", description: "Override stash root directory (default: from config)" },
3787
- "fail-on-flagged": {
3788
- type: "boolean",
3789
- description: "Exit non-zero when summary.flagged > 0 (CI-friendly). Default: exit 0 regardless of findings.",
3790
- default: false,
3791
- },
3792
- },
3793
- async run({ args }) {
3794
- await runWithJsonErrors(async () => {
3795
- const result = akmLint({
3796
- fix: args.fix ?? false,
3797
- dir: getStringArg(args, "dir"),
3798
- });
3799
- output("lint", result);
3800
- if (args["fail-on-flagged"] && result.summary.flagged > 0)
3801
- process.exit(EXIT_GENERAL);
3802
- });
3803
- },
3804
- });
3805
- const proposeCommand = defineCommand({
3806
- meta: {
3807
- name: "propose",
3808
- description: "Ask the configured agent CLI to author a brand-new asset and queue it as a proposal",
3809
- },
3810
- args: {
3811
- // Optional in citty so run() is invoked when omitted; we re-validate
3812
- // below to surface a structured UsageError (exit 2) instead of citty's
3813
- // default help-banner exit-0.
3814
- type: { type: "positional", description: "Asset type (skill, command, knowledge, lesson, ...)", required: false },
3815
- name: { type: "positional", description: "Asset name (slug or path under the type dir)", required: false },
3816
- task: { type: "string", description: "Task description for the agent (what should the asset do?)" },
3817
- file: { type: "string", description: "Read the task or prompt text from a UTF-8 file" },
3818
- profile: { type: "string", description: "Override the agent profile (defaults to agent.default)" },
3819
- "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
3820
- },
3821
- async run({ args }) {
3822
- await runWithJsonErrors(async () => {
3823
- // citty silently shows help and exits 0 when required positionals are
3824
- // omitted. Re-validate explicitly so the exit code is 2 (USAGE) and a
3825
- // structured JSON error reaches scripted callers.
3826
- const taskFromFlag = typeof args.task === "string" ? args.task : undefined;
3827
- const fileFromFlag = typeof args.file === "string" ? args.file : undefined;
3828
- if (!args.type || !args.name || (!taskFromFlag && !fileFromFlag)) {
3829
- throw new UsageError("Usage: akm propose <type> <name> (--task '<task>' | --file <path>).", "MISSING_REQUIRED_ARGUMENT", "Provide the asset type, name, and exactly one of --task or --file.");
3830
- }
3831
- if (taskFromFlag && fileFromFlag) {
3832
- throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
3833
- }
3834
- const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
3835
- const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
3836
- const result = await akmPropose({
3837
- type: String(args.type),
3838
- name: String(args.name),
3839
- task: taskText,
3840
- profile: getStringArg(args, "profile"),
3841
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3842
- });
3843
- output("propose", result);
3844
- if (result.ok === false) {
3845
- process.exit(EXIT_GENERAL);
3846
- }
3847
- });
3848
- },
3849
- });
3850
- const TASKS_SUBCOMMAND_SET = new Set([
3851
- "add",
3852
- "list",
3853
- "show",
3854
- "remove",
3855
- "enable",
3856
- "disable",
3857
- "run",
3858
- "history",
3859
- "sync",
3860
- "doctor",
3861
- ]);
3862
- const GRAPH_SUBCOMMAND_SET = new Set([
3863
- "summary",
3864
- "entities",
3865
- "entity",
3866
- "relations",
3867
- "related",
3868
- "orphans",
3869
- "export",
3870
- "update",
3871
- ]);
3872
- const tasksAddCommand = defineCommand({
3873
- meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
3874
- args: {
3875
- id: { type: "positional", description: "Task id (used as filename and scheduler entry)", required: true },
3876
- schedule: { type: "string", description: 'Cron-style schedule, e.g. "0 9 * * *" or "@daily"', required: true },
3877
- workflow: { type: "string", description: "Workflow ref to invoke (e.g. workflow:my-flow)" },
3878
- prompt: {
3879
- type: "string",
3880
- description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
3881
- },
3882
- command: {
3883
- type: "string",
3884
- description: 'Shell command to run on the schedule (no AI agent), e.g. "akm improve --auto-accept safe". Split on whitespace; quote the whole flag value.',
3885
- },
3886
- profile: { type: "string", description: "Agent profile to use for prompt targets (default: defaults.agent)" },
3887
- params: { type: "string", description: "Workflow params as a JSON object" },
3888
- name: { type: "string", description: "Human-readable name for the task" },
3889
- "when-to-use": { type: "string", description: "Guidance on when this task runs or should be used" },
3890
- description: { type: "string", description: "Human-readable description" },
3891
- tags: { type: "string", description: "Comma-separated tags" },
3892
- disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
3893
- force: { type: "boolean", description: "Overwrite an existing task with the same id", default: false },
3894
- },
3895
- async run({ args }) {
3896
- await runWithJsonErrors(async () => {
3897
- const result = await akmTasksAdd({
3898
- id: args.id,
3899
- schedule: args.schedule,
3900
- workflow: args.workflow,
3901
- prompt: args.prompt,
3902
- command: args.command,
3903
- profile: args.profile,
3904
- params: args.params,
3905
- name: args.name,
3906
- when_to_use: getHyphenatedArg(args, "when-to-use"),
3907
- description: args.description,
3908
- tags: args.tags
3909
- ? args.tags
3910
- .split(/[\s,]+/)
3911
- .map((s) => s.trim())
3912
- .filter(Boolean)
3913
- : undefined,
3914
- disabled: args.disabled === true,
3915
- force: args.force === true,
3916
- });
3917
- output("tasks-add", result);
3918
- });
3919
- },
3920
- });
3921
- const tasksListCommand = defineCommand({
3922
- meta: { name: "list", description: "List scheduled tasks in the stash" },
3923
- async run() {
3924
- await runWithJsonErrors(async () => {
3925
- const result = await akmTasksList();
3926
- output("tasks-list", result);
3927
- });
3928
- },
3929
- });
3930
- const tasksShowCommand = defineCommand({
3931
- meta: { name: "show", description: "Show a parsed task definition" },
3932
- args: { id: { type: "positional", description: "Task id or task:<id>", required: true } },
3933
- async run({ args }) {
3934
- await runWithJsonErrors(async () => {
3935
- const { id } = parseTaskRef(args.id);
3936
- const result = await akmTasksShow(id);
3937
- output("tasks-show", result);
3938
- });
3939
- },
3940
- });
3941
- const tasksRemoveCommand = defineCommand({
3942
- meta: { name: "remove", description: "Delete a task file and uninstall it from the OS scheduler" },
3943
- args: { id: { type: "positional", description: "Task id", required: true } },
3944
- async run({ args }) {
3945
- await runWithJsonErrors(async () => {
3946
- const { id } = parseTaskRef(args.id);
3947
- const result = await akmTasksRemove(id);
3948
- output("tasks-remove", result);
3949
- });
3950
- },
3951
- });
3952
- function makeTasksToggleCommand(enabled) {
3953
- const verb = enabled ? "enable" : "disable";
3954
- const description = enabled
3955
- ? "Enable a previously-disabled task"
3956
- : "Disable a task in the OS scheduler without removing the file";
3957
- return defineCommand({
3958
- meta: { name: verb, description },
3959
- args: { id: { type: "positional", description: "Task id", required: true } },
3960
- async run({ args }) {
3961
- await runWithJsonErrors(async () => {
3962
- const { id } = parseTaskRef(args.id);
3963
- const result = await akmTasksSetEnabled(id, enabled);
3964
- output(`tasks-${verb}`, result);
3965
- });
3966
- },
3967
- });
3968
- }
3969
- const tasksEnableCommand = makeTasksToggleCommand(true);
3970
- const tasksDisableCommand = makeTasksToggleCommand(false);
3971
- const tasksRunCommand = defineCommand({
3972
- meta: {
3973
- name: "run",
3974
- description: "Execute a task now (this is what cron / launchd / schtasks invoke at the scheduled time)",
3975
- },
3976
- args: { id: { type: "positional", description: "Task id", required: true } },
3977
- async run({ args }) {
3978
- await runWithJsonErrors(async () => {
3979
- const { id } = parseTaskRef(args.id);
3980
- const envelope = await akmTasksRun(id);
3981
- output("tasks-run", envelope);
3982
- if (envelope.exitCode !== 0)
3983
- process.exit(envelope.exitCode);
3984
- });
3985
- },
3986
- });
3987
- const tasksHistoryCommand = defineCommand({
3988
- meta: { name: "history", description: "Show recent task run history" },
3989
- args: {
3990
- id: { type: "string", description: "Filter to one task id" },
3991
- limit: { type: "string", description: "Maximum rows to return (default 50)" },
3992
- },
3993
- async run({ args }) {
3994
- await runWithJsonErrors(async () => {
3995
- const limit = parsePositiveIntFlag(args.limit ?? undefined);
3996
- const result = await akmTasksHistory({ id: args.id, limit });
3997
- output("tasks-history", result);
3998
- });
3999
- },
4000
- });
4001
- const tasksSyncCommand = defineCommand({
4002
- meta: {
4003
- name: "sync",
4004
- description: "Reconcile the on-disk task files with the OS scheduler",
4005
- },
4006
- async run() {
4007
- await runWithJsonErrors(async () => {
4008
- const result = await akmTasksSync();
4009
- output("tasks-sync", result);
4010
- });
4011
- },
4012
- });
4013
- const tasksDoctorCommand = defineCommand({
4014
- meta: {
4015
- name: "doctor",
4016
- description: "Report the active scheduler backend, akm bin path, log dir, and supported schedule subset",
4017
- },
4018
- async run() {
4019
- await runWithJsonErrors(async () => {
4020
- const result = await akmTasksDoctor();
4021
- output("tasks-doctor", result);
4022
- });
4023
- },
4024
- });
4025
- const tasksCommand = defineCommand({
4026
- meta: {
4027
- name: "tasks",
4028
- alias: "task",
4029
- description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
4030
- },
4031
- subCommands: {
4032
- add: tasksAddCommand,
4033
- list: tasksListCommand,
4034
- show: tasksShowCommand,
4035
- remove: tasksRemoveCommand,
4036
- enable: tasksEnableCommand,
4037
- disable: tasksDisableCommand,
4038
- run: tasksRunCommand,
4039
- history: tasksHistoryCommand,
4040
- sync: tasksSyncCommand,
4041
- doctor: tasksDoctorCommand,
4042
- },
4043
- run({ args }) {
4044
- return runWithJsonErrors(async () => {
4045
- if (hasSubcommand(args, TASKS_SUBCOMMAND_SET))
4046
- return;
4047
- const result = await akmTasksList();
4048
- output("tasks-list", result);
4049
- });
4050
- },
4051
- });
4052
- export const main = defineCommand({
4053
- meta: {
4054
- name: "akm",
4055
- version: pkgVersion,
4056
- description: "Agent Knowledge Management — search, show, and manage assets from your stash.\n\n" +
4057
- "Exit codes:\n" +
4058
- " 0 success\n" +
4059
- " 1 general error / not found\n" +
4060
- " 2 usage error\n" +
4061
- " 4 health warn (akm health only)\n" +
4062
- " 78 config error",
4063
- },
4064
- args: {
4065
- format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
4066
- detail: {
4067
- type: "string",
4068
- description: "Detail level (verbosity): brief|normal|full. Default: brief.",
4069
- default: "brief",
4070
- },
4071
- shape: {
4072
- type: "string",
4073
- description: "Output projection: human|agent|summary. 'agent' trims to agent-essential fields; " +
4074
- "'summary' is only valid on 'akm show'. Default: human.",
4075
- },
4076
- "for-agent": {
4077
- type: "boolean",
4078
- description: "DEPRECATED alias for '--shape agent' (removed 0.9.0).",
4079
- default: false,
4080
- },
4081
- quiet: {
4082
- type: "boolean",
4083
- alias: "q",
4084
- description: "Suppress non-essential stderr output (banners, spinners, progress info). " +
4085
- "Safety-critical output is never suppressed: errors, destructive-action confirmation prompts, " +
4086
- "and auto-migration banners always appear regardless of --quiet.",
4087
- default: false,
4088
- },
4089
- verbose: {
4090
- type: "boolean",
4091
- description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
474
+ description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
4092
475
  default: false,
4093
476
  },
4094
477
  },
@@ -4112,16 +495,12 @@ export const main = defineCommand({
4112
495
  remember: rememberCommand,
4113
496
  import: importKnowledgeCommand,
4114
497
  sync: syncCommand,
4115
- // Deprecated alias (removed 0.9.0) — delegates to `sync`.
4116
- save: saveCommand,
4117
498
  clone: cloneCommand,
4118
499
  registry: registryCommand,
4119
500
  config: configCommand,
4120
- enable: enableCommand,
4121
- disable: disableCommand,
4122
501
  feedback: feedbackCommand,
4123
502
  history: historyCommand,
4124
- events: eventsCommand,
503
+ log: logCommand,
4125
504
  lessons: lessonsCommand,
4126
505
  agent: agentCommand,
4127
506
  lint: lintCommand,
@@ -4129,38 +508,15 @@ export const main = defineCommand({
4129
508
  extract: extractCommand,
4130
509
  propose: proposeCommand,
4131
510
  proposal: proposalCommand,
4132
- // Deprecated flat verbs (removed 0.9.0) — delegate to `proposal <verb>`.
4133
- proposals: proposalsCommand,
4134
- accept: acceptCommand,
4135
- reject: rejectCommand,
4136
- diff: diffCommand,
4137
- revert: revertCommand,
4138
511
  help: helpCommand,
4139
512
  hints: hintsCommand,
4140
513
  completions: completionsCommand,
4141
514
  env: envCommand,
4142
- vault: vaultCommand,
4143
515
  secret: secretCommand,
4144
516
  wiki: wikiCommand,
4145
517
  tasks: tasksCommand,
4146
518
  },
4147
519
  });
4148
- const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset", "enable", "disable"]);
4149
- const ENV_SUBCOMMAND_SET = new Set(["list", "path", "export", "run", "create", "remove"]);
4150
- const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
4151
- const SECRET_SUBCOMMAND_SET = new Set(["list", "path", "run", "set", "remove"]);
4152
- const WIKI_SUBCOMMAND_SET = new Set([
4153
- "create",
4154
- "register",
4155
- "list",
4156
- "show",
4157
- "remove",
4158
- "pages",
4159
- "search",
4160
- "stash",
4161
- "lint",
4162
- "ingest",
4163
- ]);
4164
520
  // ── Exit codes ──────────────────────────────────────────────────────────────
4165
521
  // Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
4166
522
  // the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
@@ -4173,7 +529,13 @@ const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
4173
529
  // `import.meta.main` is false and we skip all startup side effects (argv
4174
530
  // mutation, output-mode init, index cleanup, banner, runMain) so importers
4175
531
  // can drive the `main` command themselves without the process exiting.
4176
- if (import.meta.main) {
532
+ //
533
+ // Node path: this module carries a `#!/usr/bin/env bun` shebang and is launched
534
+ // under Node via the `dist/cli-node.mjs` wrapper, which `import()`s this file
535
+ // (so `import.meta.main` is false here even though the CLI is the real entry).
536
+ // The wrapper sets `AKM_NODE_ENTRY=1` to opt into the startup block. The test
537
+ // harness never sets it, so importing cli.ts under Bun stays inert as before.
538
+ if (import.meta.main || process.env.AKM_NODE_ENTRY === "1") {
4177
539
  // citty reads process.argv directly and does not accept a custom argv array,
4178
540
  // so we must replace process.argv with the normalized version before runMain.
4179
541
  process.argv = normalizeShowArgv(process.argv);
@@ -4202,7 +564,7 @@ if (import.meta.main) {
4202
564
  // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
4203
565
  // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
4204
566
  // user isn't confused by a phantom DB. Best-effort; never fatal.
4205
- try {
567
+ bestEffort(() => {
4206
568
  const oldIndexPath = path.join(getCacheDir(), "index.db");
4207
569
  if (fs.existsSync(oldIndexPath)) {
4208
570
  fs.rmSync(oldIndexPath, { force: true });
@@ -4210,10 +572,7 @@ if (import.meta.main) {
4210
572
  fs.rmSync(`${oldIndexPath}-wal`, { force: true });
4211
573
  warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
4212
574
  }
4213
- }
4214
- catch {
4215
- // Non-fatal; one-time warning only.
4216
- }
575
+ }, "stale 0.7.x index cleanup is non-fatal");
4217
576
  // First-time-user breadcrumb: when run with no subcommand AND no config
4218
577
  // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
4219
578
  // above citty's auto-generated usage block. Triggers only when stdin/stderr
@@ -4240,22 +599,3 @@ if (import.meta.main) {
4240
599
  })();
4241
600
  runMain(main);
4242
601
  }
4243
- // ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
4244
- function loadHints(detail = "normal") {
4245
- // `brief` → the short AGENTS.md guide; `normal`/`full` → the complete guide.
4246
- const wantFull = detail !== "brief";
4247
- const filename = wantFull ? "AGENTS.full.md" : "AGENTS.md";
4248
- const fallback = wantFull ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
4249
- // Try reading from the docs/ directory (works in dev and when installed via npm)
4250
- try {
4251
- const docsPath = path.resolve(import.meta.dir ?? __dirname, `../docs/agents/${filename}`);
4252
- if (fs.existsSync(docsPath)) {
4253
- return fs.readFileSync(docsPath, "utf8");
4254
- }
4255
- }
4256
- catch {
4257
- // fall through
4258
- }
4259
- // Fallback for compiled binary — inline content
4260
- return fallback;
4261
- }