akm-cli 0.8.0-rc1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +93 -3
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2162 -1258
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +20 -12
  12. package/dist/commands/agent-support.js +11 -5
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +129 -517
  15. package/dist/commands/consolidate.js +1533 -144
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +5 -3
  19. package/dist/commands/distill.js +906 -100
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +3 -0
  22. package/dist/commands/events.js +3 -0
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +260 -5
  28. package/dist/commands/health.js +977 -51
  29. package/dist/commands/help/help-accept.md +6 -3
  30. package/dist/commands/help/help-improve.md +36 -8
  31. package/dist/commands/help/help-proposals.md +7 -4
  32. package/dist/commands/help/help-reject.md +5 -2
  33. package/dist/commands/history.js +51 -16
  34. package/dist/commands/improve-auto-accept.js +97 -0
  35. package/dist/commands/improve-cli.js +236 -0
  36. package/dist/commands/improve-profiles.js +184 -0
  37. package/dist/commands/improve-result-file.js +167 -0
  38. package/dist/commands/improve.js +1725 -332
  39. package/dist/commands/info.js +3 -0
  40. package/dist/commands/init.js +49 -1
  41. package/dist/commands/installed-stashes.js +6 -23
  42. package/dist/commands/knowledge.js +3 -0
  43. package/dist/commands/lint/agent-linter.js +3 -0
  44. package/dist/commands/lint/base-linter.js +233 -5
  45. package/dist/commands/lint/command-linter.js +3 -0
  46. package/dist/commands/lint/default-linter.js +3 -0
  47. package/dist/commands/lint/env-key-rules.js +154 -0
  48. package/dist/commands/lint/index.js +92 -3
  49. package/dist/commands/lint/knowledge-linter.js +3 -0
  50. package/dist/commands/lint/markdown-insertion.js +343 -0
  51. package/dist/commands/lint/memory-linter.js +3 -0
  52. package/dist/commands/lint/registry.js +3 -0
  53. package/dist/commands/lint/skill-linter.js +3 -0
  54. package/dist/commands/lint/task-linter.js +15 -12
  55. package/dist/commands/lint/types.js +3 -0
  56. package/dist/commands/lint/workflow-linter.js +3 -0
  57. package/dist/commands/lint.js +3 -0
  58. package/dist/commands/migration-help.js +5 -2
  59. package/dist/commands/proposal-drain-policies.js +128 -0
  60. package/dist/commands/proposal-drain.js +477 -0
  61. package/dist/commands/proposal.js +60 -6
  62. package/dist/commands/propose.js +24 -19
  63. package/dist/commands/reflect.js +1004 -94
  64. package/dist/commands/registry-cli.js +150 -0
  65. package/dist/commands/registry-search.js +3 -0
  66. package/dist/commands/remember-cli.js +257 -0
  67. package/dist/commands/remember.js +15 -6
  68. package/dist/commands/schema-repair.js +88 -15
  69. package/dist/commands/search.js +99 -14
  70. package/dist/commands/secret.js +173 -0
  71. package/dist/commands/self-update.js +3 -0
  72. package/dist/commands/show.js +32 -13
  73. package/dist/commands/source-add.js +7 -35
  74. package/dist/commands/source-clone.js +3 -0
  75. package/dist/commands/source-manage.js +3 -0
  76. package/dist/commands/tasks.js +161 -95
  77. package/dist/commands/url-checker.js +3 -0
  78. package/dist/core/action-contributors.js +3 -0
  79. package/dist/core/asset-ref.js +17 -2
  80. package/dist/core/asset-registry.js +9 -2
  81. package/dist/core/asset-serialize.js +88 -0
  82. package/dist/core/asset-spec.js +61 -5
  83. package/dist/core/common.js +93 -5
  84. package/dist/core/concurrent.js +3 -0
  85. package/dist/core/config-io.js +347 -0
  86. package/dist/core/config-migration.js +622 -0
  87. package/dist/core/config-schema.js +558 -0
  88. package/dist/core/config-sources.js +108 -0
  89. package/dist/core/config-types.js +4 -0
  90. package/dist/core/config-walker.js +337 -0
  91. package/dist/core/config.js +366 -1077
  92. package/dist/core/errors.js +42 -20
  93. package/dist/core/events.js +31 -25
  94. package/dist/core/file-lock.js +104 -0
  95. package/dist/core/frontmatter.js +75 -10
  96. package/dist/core/lesson-lint.js +3 -0
  97. package/dist/core/markdown.js +3 -0
  98. package/dist/core/memory-belief.js +62 -0
  99. package/dist/core/memory-contradiction-detect.js +274 -0
  100. package/dist/core/memory-improve.js +142 -14
  101. package/dist/core/parse.js +3 -0
  102. package/dist/core/paths.js +218 -50
  103. package/dist/core/proposal-quality-validators.js +380 -0
  104. package/dist/core/proposal-validators.js +11 -3
  105. package/dist/core/proposals.js +464 -5
  106. package/dist/core/state-db.js +349 -56
  107. package/dist/core/text-truncation.js +107 -0
  108. package/dist/core/time.js +3 -0
  109. package/dist/core/tty.js +59 -0
  110. package/dist/core/warn.js +7 -2
  111. package/dist/core/write-source.js +12 -0
  112. package/dist/indexer/db-backup.js +391 -0
  113. package/dist/indexer/db-search.js +136 -28
  114. package/dist/indexer/db.js +662 -166
  115. package/dist/indexer/ensure-index.js +3 -0
  116. package/dist/indexer/file-context.js +3 -0
  117. package/dist/indexer/graph-boost.js +162 -40
  118. package/dist/indexer/graph-db.js +241 -51
  119. package/dist/indexer/graph-dedup.js +3 -7
  120. package/dist/indexer/graph-extraction.js +242 -149
  121. package/dist/indexer/index-context.js +3 -9
  122. package/dist/indexer/indexer.js +84 -14
  123. package/dist/indexer/llm-cache.js +24 -19
  124. package/dist/indexer/manifest.js +3 -0
  125. package/dist/indexer/matchers.js +184 -11
  126. package/dist/indexer/memory-inference.js +94 -50
  127. package/dist/indexer/metadata-contributors.js +3 -0
  128. package/dist/indexer/metadata.js +114 -48
  129. package/dist/indexer/path-resolver.js +3 -0
  130. package/dist/indexer/project-context.js +192 -0
  131. package/dist/indexer/ranking-contributors.js +134 -7
  132. package/dist/indexer/ranking.js +8 -1
  133. package/dist/indexer/search-fields.js +5 -9
  134. package/dist/indexer/search-hit-enrichers.js +91 -2
  135. package/dist/indexer/search-source.js +20 -1
  136. package/dist/indexer/semantic-status.js +4 -1
  137. package/dist/indexer/staleness-detect.js +447 -0
  138. package/dist/indexer/usage-events.js +12 -9
  139. package/dist/indexer/walker.js +3 -0
  140. package/dist/integrations/agent/builders.js +135 -0
  141. package/dist/integrations/agent/config.js +121 -401
  142. package/dist/integrations/agent/detect.js +3 -0
  143. package/dist/integrations/agent/index.js +6 -14
  144. package/dist/integrations/agent/model-aliases.js +55 -0
  145. package/dist/integrations/agent/profiles.js +3 -0
  146. package/dist/integrations/agent/prompts.js +137 -8
  147. package/dist/integrations/agent/runner.js +208 -0
  148. package/dist/integrations/agent/sdk-runner.js +8 -2
  149. package/dist/integrations/agent/spawn.js +54 -14
  150. package/dist/integrations/github.js +3 -0
  151. package/dist/integrations/lockfile.js +22 -51
  152. package/dist/integrations/session-logs/index.js +4 -0
  153. package/dist/integrations/session-logs/inline-refs.js +35 -0
  154. package/dist/integrations/session-logs/pre-filter.js +152 -0
  155. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  156. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  157. package/dist/integrations/session-logs/types.js +3 -0
  158. package/dist/llm/call-ai.js +14 -26
  159. package/dist/llm/client.js +16 -2
  160. package/dist/llm/embedder.js +20 -29
  161. package/dist/llm/embedders/cache.js +3 -7
  162. package/dist/llm/embedders/local.js +42 -1
  163. package/dist/llm/embedders/remote.js +20 -8
  164. package/dist/llm/embedders/types.js +3 -7
  165. package/dist/llm/feature-gate.js +92 -56
  166. package/dist/llm/graph-extract.js +401 -30
  167. package/dist/llm/index-passes.js +44 -29
  168. package/dist/llm/memory-infer.js +30 -2
  169. package/dist/llm/metadata-enhance.js +3 -7
  170. package/dist/llm/prompts/extract-session.md +80 -0
  171. package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
  172. package/dist/output/cli-hints-full.md +60 -32
  173. package/dist/output/cli-hints-short.md +10 -7
  174. package/dist/output/cli-hints.js +5 -2
  175. package/dist/output/context.js +60 -8
  176. package/dist/output/renderers.js +170 -194
  177. package/dist/output/shapes/curate.js +56 -0
  178. package/dist/output/shapes/distill.js +10 -0
  179. package/dist/output/shapes/env-list.js +19 -0
  180. package/dist/output/shapes/events.js +11 -0
  181. package/dist/output/shapes/helpers.js +424 -0
  182. package/dist/output/shapes/history.js +7 -0
  183. package/dist/output/shapes/passthrough.js +105 -0
  184. package/dist/output/shapes/proposal-accept.js +7 -0
  185. package/dist/output/shapes/proposal-diff.js +7 -0
  186. package/dist/output/shapes/proposal-list.js +7 -0
  187. package/dist/output/shapes/proposal-producer.js +11 -0
  188. package/dist/output/shapes/proposal-reject.js +7 -0
  189. package/dist/output/shapes/proposal-show.js +7 -0
  190. package/dist/output/shapes/registry-search.js +6 -0
  191. package/dist/output/shapes/registry.js +30 -0
  192. package/dist/output/shapes/search.js +6 -0
  193. package/dist/output/shapes/secret-list.js +19 -0
  194. package/dist/output/shapes/show.js +6 -0
  195. package/dist/output/shapes/vault-list.js +19 -0
  196. package/dist/output/shapes.js +51 -549
  197. package/dist/output/text/add.js +6 -0
  198. package/dist/output/text/clone.js +6 -0
  199. package/dist/output/text/config.js +6 -0
  200. package/dist/output/text/curate.js +6 -0
  201. package/dist/output/text/distill.js +7 -0
  202. package/dist/output/text/enable-disable.js +7 -0
  203. package/dist/output/text/events.js +10 -0
  204. package/dist/output/text/feedback.js +6 -0
  205. package/dist/output/text/helpers.js +1059 -0
  206. package/dist/output/text/history.js +7 -0
  207. package/dist/output/text/import.js +6 -0
  208. package/dist/output/text/index.js +6 -0
  209. package/dist/output/text/info.js +6 -0
  210. package/dist/output/text/init.js +6 -0
  211. package/dist/output/text/list.js +6 -0
  212. package/dist/output/text/proposal-producer.js +8 -0
  213. package/dist/output/text/proposal.js +12 -0
  214. package/dist/output/text/registry-commands.js +11 -0
  215. package/dist/output/text/registry.js +30 -0
  216. package/dist/output/text/remember.js +6 -0
  217. package/dist/output/text/remove.js +6 -0
  218. package/dist/output/text/save.js +6 -0
  219. package/dist/output/text/search.js +6 -0
  220. package/dist/output/text/show.js +6 -0
  221. package/dist/output/text/update.js +6 -0
  222. package/dist/output/text/upgrade.js +6 -0
  223. package/dist/output/text/vault.js +16 -0
  224. package/dist/output/text/wiki.js +15 -0
  225. package/dist/output/text/workflow.js +14 -0
  226. package/dist/output/text.js +44 -1329
  227. package/dist/registry/build-index.js +3 -0
  228. package/dist/registry/create-provider-registry.js +3 -0
  229. package/dist/registry/factory.js +4 -1
  230. package/dist/registry/origin-resolve.js +3 -0
  231. package/dist/registry/providers/index.js +3 -0
  232. package/dist/registry/providers/skills-sh.js +11 -2
  233. package/dist/registry/providers/static-index.js +10 -1
  234. package/dist/registry/providers/types.js +3 -24
  235. package/dist/registry/resolve.js +11 -16
  236. package/dist/registry/types.js +3 -0
  237. package/dist/scripts/migrate-storage.js +17767 -0
  238. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  239. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  240. package/dist/setup/detect.js +3 -0
  241. package/dist/setup/ripgrep-install.js +3 -0
  242. package/dist/setup/ripgrep-resolve.js +3 -0
  243. package/dist/setup/setup.js +306 -67
  244. package/dist/setup/steps.js +3 -15
  245. package/dist/sources/include.js +3 -0
  246. package/dist/sources/provider-factory.js +3 -11
  247. package/dist/sources/provider.js +3 -20
  248. package/dist/sources/providers/filesystem.js +19 -23
  249. package/dist/sources/providers/git.js +171 -21
  250. package/dist/sources/providers/index.js +3 -0
  251. package/dist/sources/providers/install-types.js +3 -13
  252. package/dist/sources/providers/npm.js +3 -4
  253. package/dist/sources/providers/provider-utils.js +3 -0
  254. package/dist/sources/providers/sync-from-ref.js +3 -11
  255. package/dist/sources/providers/tar-utils.js +3 -0
  256. package/dist/sources/providers/website.js +18 -22
  257. package/dist/sources/resolve.js +3 -0
  258. package/dist/sources/types.js +3 -0
  259. package/dist/sources/website-ingest.js +3 -0
  260. package/dist/tasks/backends/cron.js +3 -0
  261. package/dist/tasks/backends/exec-utils.js +3 -0
  262. package/dist/tasks/backends/index.js +3 -11
  263. package/dist/tasks/backends/launchd.js +3 -0
  264. package/dist/tasks/backends/schtasks.js +3 -0
  265. package/dist/tasks/parser.js +51 -38
  266. package/dist/tasks/resolveAkmBin.js +3 -0
  267. package/dist/tasks/runner.js +35 -9
  268. package/dist/tasks/schedule.js +20 -1
  269. package/dist/tasks/schema.js +5 -3
  270. package/dist/tasks/validator.js +6 -3
  271. package/dist/version.js +3 -0
  272. package/dist/wiki/wiki-templates.js +3 -0
  273. package/dist/wiki/wiki.js +3 -0
  274. package/dist/workflows/authoring.js +3 -0
  275. package/dist/workflows/cli.js +3 -0
  276. package/dist/workflows/db.js +140 -10
  277. package/dist/workflows/document-cache.js +3 -10
  278. package/dist/workflows/parser.js +3 -0
  279. package/dist/workflows/renderer.js +3 -0
  280. package/dist/workflows/runs.js +18 -1
  281. package/dist/workflows/schema.js +3 -0
  282. package/dist/workflows/scope-key.js +3 -0
  283. package/dist/workflows/validator.js +5 -9
  284. package/docs/README.md +7 -2
  285. package/docs/data-and-telemetry.md +225 -0
  286. package/docs/migration/release-notes/0.7.5.md +2 -2
  287. package/docs/migration/release-notes/0.8.0.md +57 -5
  288. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  289. package/package.json +28 -11
  290. package/.github/LICENSE +0 -374
  291. package/dist/commands/install-audit.js +0 -385
  292. package/dist/commands/vault.js +0 -307
  293. package/dist/indexer/match-contributors.js +0 -141
  294. package/dist/integrations/agent/pipeline.js +0 -39
  295. package/dist/integrations/agent/runners.js +0 -31
package/dist/cli.js CHANGED
@@ -1,56 +1,123 @@
1
1
  #!/usr/bin/env bun
2
+ // This Source Code Form is subject to the terms of the Mozilla Public
3
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
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);
22
+ }
23
+ // Global error handlers (#478) — route any async work outside the
24
+ // `runWithJsonErrors` envelope through the same JSON shape so users never see
25
+ // a raw stack trace. Background timers, fire-and-forget appendEvent writes,
26
+ // and lazy `import()` failures are the typical sources. Registered before
27
+ // any other top-level work so the startup IIFE banner and the stale-DB
28
+ // cleanup are also covered.
29
+ process.on("unhandledRejection", (reason) => {
30
+ const err = reason instanceof Error ? reason : new Error(String(reason));
31
+ console.error(JSON.stringify({
32
+ ok: false,
33
+ error: `Unhandled rejection: ${err.message}`,
34
+ code: "UNHANDLED_REJECTION",
35
+ hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
36
+ }, null, 2));
37
+ if (process.env.AKM_DEBUG === "1" && err.stack)
38
+ console.error(err.stack);
39
+ process.exit(1);
40
+ });
41
+ process.on("uncaughtException", (err) => {
42
+ console.error(JSON.stringify({
43
+ ok: false,
44
+ error: `Uncaught exception: ${err.message}`,
45
+ code: "UNCAUGHT_EXCEPTION",
46
+ hint: "Re-run with AKM_DEBUG=1 for a stack trace, or report at https://github.com/itlackey/akm/issues with the failing command.",
47
+ }, null, 2));
48
+ if (process.env.AKM_DEBUG === "1" && err.stack)
49
+ console.error(err.stack);
50
+ process.exit(1);
51
+ });
2
52
  import { spawnSync } from "node:child_process";
3
53
  import fs from "node:fs";
4
54
  import path from "node:path";
5
55
  import * as p from "@clack/prompts";
6
56
  import { defineCommand, runMain } from "citty";
7
- import { hasSubcommand, parsePositiveIntFlag } from "./cli/parse-args";
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";
8
60
  import { akmAgentDispatch } from "./commands/agent-dispatch";
9
61
  import { generateBashCompletions, installBashCompletions } from "./commands/completions";
10
62
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue } from "./commands/config-cli";
11
63
  import { akmCurate } from "./commands/curate";
64
+ import { akmDbBackups } from "./commands/db-cli";
12
65
  import { akmEventsList, akmEventsTail } from "./commands/events";
13
- import { akmGraphEntities, akmGraphExport, akmGraphRelated, akmGraphRelations, akmGraphSummary, } from "./commands/graph";
14
- import { akmHealth } from "./commands/health";
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";
15
70
  import { akmHistory } from "./commands/history";
16
- import { akmImprove } from "./commands/improve";
71
+ import { improveCommand } from "./commands/improve-cli";
17
72
  import { assembleInfo } from "./commands/info";
18
73
  import { akmInit } from "./commands/init";
19
74
  import { akmListSources, akmRemove, akmUpdate } from "./commands/installed-stashes";
20
75
  import { readKnowledgeInput, writeMarkdownAsset } from "./commands/knowledge";
21
76
  import { akmLint } from "./commands/lint";
22
77
  import { renderMigrationHelp } from "./commands/migration-help";
23
- import { akmProposalAccept, akmProposalDiff, akmProposalList, akmProposalReject, akmProposalShow, } from "./commands/proposal";
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";
24
97
  import { akmPropose } from "./commands/propose";
25
- import { searchRegistry } from "./commands/registry-search";
26
- import { buildMemoryFrontmatter, parseDuration, readMemoryContent, resolveRememberContentArg, runAutoHeuristics, runLlmEnrich, } from "./commands/remember";
27
98
  import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./commands/search";
28
99
  import { checkForUpdate, performUpgrade } from "./commands/self-update";
29
100
  import { akmShowUnified, normalizeShowArgv } from "./commands/show";
30
- import { akmAdd } from "./commands/source-add";
31
101
  import { akmClone } from "./commands/source-clone";
32
- import { addStash } from "./commands/source-manage";
33
102
  import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksList, akmTasksRemove, akmTasksRun, akmTasksSetEnabled, akmTasksShow, akmTasksSync, parseTaskRef, } from "./commands/tasks";
34
103
  import { parseAssetRef } from "./core/asset-ref";
35
104
  import { deriveCanonicalAssetName, resolveAssetPathFromName } from "./core/asset-spec";
36
- import { isHttpUrl, resolveStashDir } from "./core/common";
105
+ import { isHttpUrl, isWithin, resolveStashDir, writeFileAtomic } from "./core/common";
37
106
  import { DEFAULT_CONFIG, loadConfig, loadUserConfig, resolveConfiguredSources, saveConfig } from "./core/config";
38
107
  import { ConfigError, NotFoundError, UsageError } from "./core/errors";
39
108
  import { appendEvent } from "./core/events";
40
109
  import { getCacheDir, getConfigPath, getDbPath, getDefaultStashDir } from "./core/paths";
41
- import { clearLogFile, info, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
42
- import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, openExistingDatabase } from "./indexer/db";
43
- import { ensureIndex } from "./indexer/ensure-index";
110
+ import { plainize } from "./core/tty";
111
+ import { clearLogFile, info, isQuiet, isVerbose, setLogFile, setQuiet, setVerbose, warn } from "./core/warn";
112
+ import { closeDatabase, openExistingDatabase } from "./indexer/db";
44
113
  import { akmIndex } from "./indexer/indexer";
45
114
  import { resolveSourceEntries } from "./indexer/search-source";
46
- import { insertUsageEvent } from "./indexer/usage-events";
115
+ import { resolveTriageJudgmentRunner } from "./integrations/agent/runner";
47
116
  import { EMBEDDED_HINTS, EMBEDDED_HINTS_FULL } from "./output/cli-hints";
48
- import { getHyphenatedArg, getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue, } from "./output/context";
49
- import { shapeForCommand } from "./output/shapes";
50
- import { formatEventLine, formatPlain, outputJsonl } from "./output/text";
51
- import { buildRegistryIndex, writeRegistryIndex } from "./registry/build-index";
117
+ import { getHyphenatedArg, getHyphenatedBoolean, getOutputMode, hasBooleanFlag, initOutputMode, parseDetailLevel, parseFlagValue, } from "./output/context";
118
+ import { formatEventLine } from "./output/text";
52
119
  import { resolveSourcesForOrigin } from "./registry/origin-resolve";
53
- import { saveGitStash } from "./sources/providers/git";
120
+ import { resolveWritableOverride, saveGitStash } from "./sources/providers/git";
54
121
  import { resolveAssetPath } from "./sources/resolve";
55
122
  import { pkgVersion } from "./version";
56
123
  import { createWorkflowAsset, formatWorkflowErrors, getWorkflowTemplate, validateWorkflowSource, } from "./workflows/authoring";
@@ -59,7 +126,6 @@ import { completeWorkflowStep, getNextWorkflowStep, getWorkflowStatus, listWorkf
59
126
  const SKILLS_SH_NAME = "skills.sh";
60
127
  const SKILLS_SH_URL = "https://skills.sh";
61
128
  const SKILLS_SH_PROVIDER = "skills-sh";
62
- import { stringify as yamlStringify } from "yaml";
63
129
  function applyEarlyStderrFlags(argv) {
64
130
  if (argv.includes("--quiet") || argv.includes("-q")) {
65
131
  setQuiet(true);
@@ -68,29 +134,6 @@ function applyEarlyStderrFlags(argv) {
68
134
  setVerbose(true);
69
135
  }
70
136
  }
71
- /**
72
- * Collect all occurrences of a repeatable flag from process.argv.
73
- * Citty's StringArgDef only exposes the last value when a flag is repeated,
74
- * so for repeatable CLI args (like `--tag foo --tag bar`) we read argv directly.
75
- * Supports both `--flag value` and `--flag=value` forms.
76
- */
77
- function parseAllFlagValues(flag) {
78
- const values = [];
79
- for (let i = 0; i < process.argv.length; i++) {
80
- const arg = process.argv[i];
81
- if (arg === flag && i + 1 < process.argv.length) {
82
- values.push(process.argv[i + 1]);
83
- // BUG-M4: skip the value index so `--tag --tag` (literal `--tag`
84
- // value) does not double-count the second `--tag` as a separate
85
- // flag occurrence.
86
- i++;
87
- }
88
- else if (arg.startsWith(`${flag}=`)) {
89
- values.push(arg.slice(flag.length + 1));
90
- }
91
- }
92
- return values;
93
- }
94
137
  function resolveHelpMigrateVersionArg(version) {
95
138
  if (version === undefined)
96
139
  return undefined;
@@ -128,26 +171,29 @@ function wasHelpMigrateFlagValueConsumedAsVersion(version, flagValue, flagName)
128
171
  return false;
129
172
  return relevant[flagIndex] === flagName ? relevant[flagIndex + 1] === version : true;
130
173
  }
131
- function output(command, result) {
174
+ /**
175
+ * Stderr-only human-friendly hint after a non-interactive `setup` invocation.
176
+ * Default --format is `json`, so a CI or piped consumer sees only the JSON on
177
+ * stdout. But an interactive user running `akm setup --yes` would otherwise
178
+ * see only the JSON blob with no obvious next step. When stderr is a TTY and
179
+ * the JSON went to stdout, print a two-line summary to stderr telling the
180
+ * user (a) where the stash landed and (b) what to run next.
181
+ *
182
+ * Silent when: stderr is not a TTY (CI, pipes), --format=text/yaml (the user
183
+ * already gets readable output), --quiet, or the result is missing fields.
184
+ */
185
+ function printSetupTtyHint(result) {
186
+ if (!process.stderr.isTTY)
187
+ return;
132
188
  const mode = getOutputMode();
133
- const shaped = shapeForCommand(command, result, mode.detail, mode.forAgent);
134
- if (mode.format === "jsonl") {
135
- outputJsonl(command, shaped);
189
+ if (mode.format !== "json" && mode.format !== "jsonl")
136
190
  return;
137
- }
138
- switch (mode.format) {
139
- case "json":
140
- console.log(JSON.stringify(shaped, null, 2));
141
- return;
142
- case "yaml":
143
- console.log(yamlStringify(shaped));
144
- return;
145
- case "text": {
146
- const plain = formatPlain(command, shaped, mode.detail);
147
- console.log(plain ?? JSON.stringify(shaped, null, 2));
148
- return;
149
- }
150
- }
191
+ if (isQuiet())
192
+ return;
193
+ if (!result?.stashDir)
194
+ return;
195
+ console.error(plainize(`\n✓ Stash created at ${result.stashDir}\n` +
196
+ ` Next: \`akm add github:itlackey/akm-stash\` then \`akm index\` to populate the stash.`));
151
197
  }
152
198
  /**
153
199
  * Module Naming:
@@ -166,6 +212,10 @@ const setupCommand = defineCommand({
166
212
  type: "string",
167
213
  description: 'Config JSON to apply non-interactively, e.g. \'{"llm":{"endpoint":"...","model":"..."}}\'',
168
214
  },
215
+ from: {
216
+ type: "string",
217
+ description: "Path to a config file (JSON or YAML) to bootstrap from. Skips prompts for keys present in the file.",
218
+ },
169
219
  yes: {
170
220
  type: "boolean",
171
221
  default: false,
@@ -184,7 +234,26 @@ const setupCommand = defineCommand({
184
234
  async run({ args }) {
185
235
  await runWithJsonErrors(async () => {
186
236
  const noInit = getHyphenatedBoolean(args, "no-init");
187
- if (args.config) {
237
+ if (args.from && args.config) {
238
+ throw new UsageError("Pass either --from <file> or --config <json>, not both.", "INVALID_FLAG_VALUE");
239
+ }
240
+ if (args.from) {
241
+ // File-based bootstrap. `loadSetupConfigFromFile` expands a leading
242
+ // `~`, resolves relative paths against cwd, picks the YAML or JSON
243
+ // parser based on the file extension, and surfaces any
244
+ // read/parse/shape errors as ConfigError("INVALID_CONFIG_FILE").
245
+ const { loadSetupConfigFromFile, runSetupFromConfig } = await import("./setup/setup");
246
+ const loaded = await loadSetupConfigFromFile(args.from);
247
+ const result = await runSetupFromConfig({
248
+ configJson: loaded.configJson,
249
+ dir: args.dir,
250
+ noInit,
251
+ probe: args.probe,
252
+ });
253
+ output("setup", result);
254
+ printSetupTtyHint(result);
255
+ }
256
+ else if (args.config) {
188
257
  // Non-interactive config mode
189
258
  const { runSetupFromConfig } = await import("./setup/setup");
190
259
  const result = await runSetupFromConfig({
@@ -194,6 +263,7 @@ const setupCommand = defineCommand({
194
263
  probe: args.probe,
195
264
  });
196
265
  output("setup", result);
266
+ printSetupTtyHint(result);
197
267
  }
198
268
  else if (args.yes) {
199
269
  // Defaults mode — no prompts
@@ -204,6 +274,7 @@ const setupCommand = defineCommand({
204
274
  probe: args.probe,
205
275
  });
206
276
  output("setup", result);
277
+ printSetupTtyHint(result);
207
278
  }
208
279
  else {
209
280
  // Interactive wizard
@@ -235,7 +306,16 @@ const indexCommand = defineCommand({
235
306
  meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
236
307
  args: {
237
308
  full: { type: "boolean", description: "Force full reindex", default: false },
238
- verbose: { type: "boolean", description: "Print phase-by-phase indexing progress to stderr", default: false },
309
+ clean: {
310
+ type: "boolean",
311
+ description: "After indexing, remove any entries whose source file no longer exists on disk.",
312
+ default: false,
313
+ },
314
+ "dry-run": {
315
+ type: "boolean",
316
+ description: "When combined with --clean, report stale entries without deleting them.",
317
+ default: false,
318
+ },
239
319
  },
240
320
  async run({ args }) {
241
321
  await runWithJsonErrors(async () => {
@@ -252,7 +332,8 @@ const indexCommand = defineCommand({
252
332
  process.once("SIGTERM", abort);
253
333
  const indexLogFile = path.join(getCacheDir(), "logs", "index", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
254
334
  setLogFile(indexLogFile);
255
- const spin = !args.verbose && outputMode.format === "text" ? p.spinner() : null;
335
+ const verbose = isVerbose();
336
+ const spin = !verbose && outputMode.format === "text" ? p.spinner() : null;
256
337
  if (spin) {
257
338
  spin.start(`Building search index${args.full ? " (full rebuild)" : ""}...`);
258
339
  }
@@ -260,10 +341,12 @@ const indexCommand = defineCommand({
260
341
  try {
261
342
  const result = await akmIndex({
262
343
  full: args.full,
344
+ clean: args.clean,
345
+ dryRun: args["dry-run"],
263
346
  onProgress: ({ phase, message, processed, total }) => {
264
347
  latestMessage = message;
265
348
  const progressPrefix = processed !== undefined && total !== undefined ? `[${processed}/${total}] ` : "";
266
- if (args.verbose) {
349
+ if (verbose) {
267
350
  info(`[index:${phase}] ${progressPrefix}${message}`);
268
351
  }
269
352
  else if (spin) {
@@ -293,7 +376,7 @@ const indexCommand = defineCommand({
293
376
  },
294
377
  });
295
378
  const infoCommand = defineCommand({
296
- meta: { name: "info", description: "Show system capabilities, configuration, and index stats as JSON" },
379
+ meta: { name: "info", description: "Show system capabilities, configuration, and index stats" },
297
380
  run() {
298
381
  return runWithJsonErrors(() => {
299
382
  const result = assembleInfo();
@@ -308,12 +391,83 @@ const healthCommand = defineCommand({
308
391
  type: "string",
309
392
  description: "Rolling window start (ISO timestamp, date, epoch ms, or shorthand like 24h / 7d)",
310
393
  },
394
+ "group-by": {
395
+ type: "string",
396
+ description: "Group rows by: run (one row per improve_runs entry). Omit for the default summary.",
397
+ },
398
+ detail: {
399
+ type: "string",
400
+ description: "DEPRECATED: use --group-by run instead of --detail per-run (removed 0.9.0).",
401
+ },
402
+ "window-compare": {
403
+ type: "string",
404
+ description: "Compare current window vs prior window of the same duration (e.g. 24h, 7d, 30m)",
405
+ },
406
+ windows: {
407
+ type: "string",
408
+ description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
409
+ },
311
410
  },
312
- run({ args }) {
313
- return runWithJsonErrors(() => {
314
- const result = akmHealth({ since: args.since });
315
- output("health", result);
411
+ async run({ args }) {
412
+ let resultStatus;
413
+ await runWithJsonErrors(() => {
414
+ // citty only surfaces the last value of a repeated flag, so read --windows
415
+ // directly from argv to support multi-window comparison.
416
+ const rawWindows = parseAllFlagValues("--windows");
417
+ const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
418
+ const groupByRaw = args["group-by"];
419
+ const detailRaw = args.detail;
420
+ // Back-compat: `--detail per-run` → `--group-by run` (warns; removed 0.9.0).
421
+ let groupBy = groupByRaw;
422
+ if (detailRaw !== undefined) {
423
+ if (detailRaw === "per-run") {
424
+ // Read --quiet from argv (not the warn-module singleton) so the
425
+ // warning fires correctly even when the early-stderr flags were not
426
+ // applied (e.g. the in-process test harness), matching the WS2
427
+ // output-flag deprecations in src/output/context.ts.
428
+ const quietRequested = process.argv.includes("--quiet") || process.argv.includes("-q");
429
+ if (!quietRequested) {
430
+ process.stderr.write("warning: '--detail per-run' is deprecated for 'akm health'; use '--group-by run'. Removed in 0.9.0.\n");
431
+ }
432
+ groupBy = groupBy ?? "run";
433
+ }
434
+ else {
435
+ throw new UsageError(`Invalid value for --detail: ${detailRaw}. 'akm health' uses --group-by run (not --detail).`, "INVALID_DETAIL_VALUE");
436
+ }
437
+ }
438
+ const windowCompareRaw = args["window-compare"];
439
+ const result = akmHealth({
440
+ since: args.since,
441
+ groupBy: groupBy,
442
+ windowCompare: windowCompareRaw,
443
+ windows,
444
+ });
445
+ resultStatus = result.status;
446
+ // `--format md` is health-specific: render a TSV-shaped per-run or
447
+ // window-compare table to stdout instead of going through the JSON
448
+ // envelope. Other modes fall through to the standard output() path.
449
+ const mode = getOutputMode();
450
+ if (mode.format === "md") {
451
+ if (result.windows && result.windows.length > 0) {
452
+ console.log(renderWindowCompareMd(result.windows, result.deltas));
453
+ }
454
+ else if (result.runs) {
455
+ console.log(renderRunsDetailMd(result.runs));
456
+ }
457
+ else {
458
+ output("health", result);
459
+ }
460
+ }
461
+ else {
462
+ output("health", result);
463
+ }
316
464
  });
465
+ if (resultStatus === "fail") {
466
+ process.exit(EXIT_GENERAL);
467
+ }
468
+ if (resultStatus === "warn") {
469
+ process.exit(EXIT_HEALTH_WARN);
470
+ }
317
471
  },
318
472
  });
319
473
  const graphCommand = defineCommand({
@@ -371,6 +525,35 @@ const graphCommand = defineCommand({
371
525
  });
372
526
  },
373
527
  }),
528
+ entity: defineCommand({
529
+ meta: { name: "entity", description: "List assets that contain the given entity" },
530
+ args: {
531
+ name: { type: "positional", description: "Entity name", required: true },
532
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
533
+ limit: { type: "string", description: "Maximum matches to return" },
534
+ },
535
+ run({ args }) {
536
+ return runWithJsonErrors(() => {
537
+ output("graph-entity", akmGraphEntity({
538
+ name: args.name ?? "",
539
+ source: args.source,
540
+ limit: parsePositiveIntFlag(args.limit ?? undefined),
541
+ }));
542
+ });
543
+ },
544
+ }),
545
+ orphans: defineCommand({
546
+ meta: { name: "orphans", description: "List assets with no extracted graph entities" },
547
+ args: {
548
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
549
+ limit: { type: "string", description: "Maximum orphans to return" },
550
+ },
551
+ run({ args }) {
552
+ return runWithJsonErrors(() => {
553
+ output("graph-orphans", akmGraphOrphans({ source: args.source, limit: parsePositiveIntFlag(args.limit ?? undefined) }));
554
+ });
555
+ },
556
+ }),
374
557
  export: defineCommand({
375
558
  meta: { name: "export", description: "Export graph artifact as JSON or JSONL" },
376
559
  args: {
@@ -388,6 +571,25 @@ const graphCommand = defineCommand({
388
571
  });
389
572
  },
390
573
  }),
574
+ update: defineCommand({
575
+ meta: { name: "update", description: "Re-run graph extraction, optionally scoped to specific asset refs" },
576
+ args: {
577
+ refs: {
578
+ type: "positional",
579
+ description: "Zero or more asset refs to scope extraction (omit for a full re-extract)",
580
+ required: false,
581
+ default: "",
582
+ },
583
+ source: { type: "string", description: "Source name/path (default: primary stash source)" },
584
+ },
585
+ async run({ args }) {
586
+ return runWithJsonErrors(async () => {
587
+ // `refs` is a single positional; collect remaining argv tokens as well.
588
+ const rawRefs = [args.refs, ...(Array.isArray(args._) ? args._ : [])].filter((r) => typeof r === "string" && r.trim().length > 0);
589
+ output("graph-update", await akmGraphUpdate({ refs: rawRefs.length > 0 ? rawRefs : undefined, source: args.source }));
590
+ });
591
+ },
592
+ }),
391
593
  },
392
594
  run({ args }) {
393
595
  return runWithJsonErrors(() => {
@@ -397,6 +599,36 @@ const graphCommand = defineCommand({
397
599
  });
398
600
  },
399
601
  });
602
+ // MVP DB administration. Currently only `akm db backups`; restore is manual —
603
+ // stop akm and run `scripts/migrations/restore-data-dir.sh <backup>`.
604
+ const DB_SUBCOMMAND_SET = new Set(["backups"]);
605
+ const dbCommand = defineCommand({
606
+ meta: {
607
+ name: "db",
608
+ 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.",
609
+ },
610
+ subCommands: {
611
+ backups: defineCommand({
612
+ meta: {
613
+ name: "backups",
614
+ 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.",
615
+ },
616
+ run() {
617
+ return runWithJsonErrors(() => {
618
+ output("db-backups", akmDbBackups());
619
+ });
620
+ },
621
+ }),
622
+ },
623
+ run({ args }) {
624
+ return runWithJsonErrors(() => {
625
+ if (hasSubcommand(args, DB_SUBCOMMAND_SET))
626
+ return;
627
+ // Default action: list backups.
628
+ output("db-backups", akmDbBackups());
629
+ });
630
+ },
631
+ });
400
632
  const searchCommand = defineCommand({
401
633
  meta: { name: "search", description: "Search the stash" },
402
634
  args: {
@@ -422,7 +654,12 @@ const searchCommand = defineCommand({
422
654
  default: "all",
423
655
  },
424
656
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
425
- detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)" },
657
+ detail: { type: "string", description: "Detail level (brief|normal|full)" },
658
+ "no-project-context": {
659
+ type: "boolean",
660
+ description: "Disable the automatic project-context ranking boost (also disabled by AKM_DISABLE_PROJECT_CONTEXT=1).",
661
+ default: false,
662
+ },
426
663
  },
427
664
  async run({ args }) {
428
665
  await runWithJsonErrors(async () => {
@@ -439,7 +676,21 @@ const searchCommand = defineCommand({
439
676
  const filters = parseScopeFilterFlags(filterTokens, "--filter");
440
677
  const includeProposed = args["include-proposed"] === true;
441
678
  const belief = parseBeliefFilterMode(typeof args.belief === "string" ? args.belief : undefined);
442
- const result = await akmSearch({ query, type, limit, source, filters, includeProposed, belief });
679
+ const noProjectContext = getHyphenatedBoolean(args, "no-project-context");
680
+ // --no-project-context sets env so searchDatabase picks it up without
681
+ // threading the flag through the entire call stack.
682
+ if (noProjectContext)
683
+ process.env.AKM_DISABLE_PROJECT_CONTEXT = "1";
684
+ const result = await akmSearch({
685
+ query,
686
+ type,
687
+ limit,
688
+ source,
689
+ filters,
690
+ includeProposed,
691
+ belief,
692
+ eventSource: resolveEventSource(),
693
+ });
443
694
  output("search", result);
444
695
  });
445
696
  },
@@ -457,6 +708,12 @@ const curateCommand = defineCommand({
457
708
  },
458
709
  limit: { type: "string", description: "Maximum number of curated results", default: "4" },
459
710
  source: { type: "string", description: "Search source (stash|registry|both)", default: "stash" },
711
+ // Output-contract flags. The active values are read from the process-level
712
+ // singleton (parsed from argv at startup); these declarations make them
713
+ // visible in `akm curate --help` and document the supported axes.
714
+ format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
715
+ detail: { type: "string", description: "Detail level (brief|normal|full)" },
716
+ shape: { type: "string", description: "Output projection (human|agent)" },
460
717
  },
461
718
  async run({ args }) {
462
719
  await runWithJsonErrors(async () => {
@@ -472,137 +729,6 @@ const curateCommand = defineCommand({
472
729
  });
473
730
  },
474
731
  });
475
- const addCommand = defineCommand({
476
- meta: {
477
- name: "add",
478
- description: "Add a source (local directory, website, npm package, GitHub repo, git URL, or remote provider)",
479
- },
480
- args: {
481
- ref: {
482
- type: "positional",
483
- description: "Path, URL, or registry ref (website URL, npm package, owner/repo, git URL, or local directory)",
484
- required: true,
485
- },
486
- provider: { type: "string", description: "Provider type (e.g. website, npm). Required for URL sources." },
487
- options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
488
- name: { type: "string", description: "Human-friendly name for the source" },
489
- writable: {
490
- type: "boolean",
491
- description: "Mark a git stash as writable so changes can be pushed back",
492
- default: false,
493
- },
494
- trust: {
495
- type: "boolean",
496
- description: "Bypass install-audit blocking for this add invocation only",
497
- default: false,
498
- },
499
- type: {
500
- type: "string",
501
- description: "Override asset type for all files in this stash (currently supports: wiki)",
502
- },
503
- "max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
504
- "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
505
- "allow-insecure": {
506
- type: "boolean",
507
- description: "Allow a plain HTTP source URL (otherwise rejected for non-localhost hosts)",
508
- default: false,
509
- },
510
- },
511
- async run({ args }) {
512
- await runWithJsonErrors(async () => {
513
- const ref = args.ref.trim();
514
- const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
515
- // URL with --provider → stash source (remote or git provider)
516
- if (args.provider) {
517
- if (shouldWarnOnPlainHttp(ref)) {
518
- if (!allowInsecure) {
519
- throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
520
- "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
521
- }
522
- warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
523
- }
524
- let parsedOptions;
525
- if (args.options) {
526
- try {
527
- const parsed = JSON.parse(args.options);
528
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
529
- throw new UsageError("--options must be a JSON object");
530
- }
531
- parsedOptions = parsed;
532
- }
533
- catch (err) {
534
- if (err instanceof UsageError)
535
- throw err;
536
- throw new UsageError("--options must be valid JSON");
537
- }
538
- }
539
- const result = addStash({
540
- target: ref,
541
- name: args.name,
542
- providerType: args.provider,
543
- options: parsedOptions,
544
- writable: args.writable,
545
- });
546
- appendEvent({
547
- eventType: "add",
548
- metadata: { target: ref, provider: args.provider, name: args.name ?? null, writable: args.writable === true },
549
- });
550
- output("add", result);
551
- return;
552
- }
553
- if (shouldWarnOnPlainHttp(ref)) {
554
- if (!allowInsecure) {
555
- throw new UsageError("Source URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious payload. " +
556
- "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.", "INVALID_FLAG_VALUE", "Re-run with `--allow-insecure` only after confirming the URL is trusted.");
557
- }
558
- warn("Warning: source URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious payload.");
559
- }
560
- const websiteOptions = buildWebsiteOptions(args);
561
- if (args.type === "wiki") {
562
- const { registerWikiSource } = await import("./commands/source-add");
563
- const result = await registerWikiSource({
564
- ref,
565
- name: args.name,
566
- options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
567
- trustThisInstall: args.trust,
568
- writable: args.writable,
569
- });
570
- appendEvent({
571
- eventType: "add",
572
- metadata: { target: ref, type: "wiki", name: args.name ?? null, writable: args.writable === true },
573
- });
574
- output("add", result);
575
- return;
576
- }
577
- const result = await akmAdd({
578
- ref,
579
- name: args.name,
580
- overrideType: args.type,
581
- options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
582
- trustThisInstall: args.trust,
583
- writable: args.writable,
584
- });
585
- appendEvent({
586
- eventType: "add",
587
- metadata: {
588
- target: ref,
589
- name: args.name ?? null,
590
- overrideType: args.type ?? null,
591
- writable: args.writable === true,
592
- },
593
- });
594
- output("add", result);
595
- });
596
- },
597
- });
598
- function buildWebsiteOptions(args) {
599
- const websiteOptions = {};
600
- if (typeof args["max-pages"] === "string" && args["max-pages"].length > 0)
601
- websiteOptions.maxPages = args["max-pages"];
602
- if (typeof args["max-depth"] === "string" && args["max-depth"].length > 0)
603
- websiteOptions.maxDepth = args["max-depth"];
604
- return websiteOptions;
605
- }
606
732
  const VALID_SOURCE_KINDS = new Set(["local", "managed", "remote"]);
607
733
  function parseKindFilter(raw) {
608
734
  if (!raw)
@@ -615,22 +741,6 @@ function parseKindFilter(raw) {
615
741
  }
616
742
  return kinds;
617
743
  }
618
- function shouldWarnOnPlainHttp(ref) {
619
- if (!ref.startsWith("http://"))
620
- return false;
621
- try {
622
- const hostname = new URL(ref).hostname.toLowerCase();
623
- return (hostname !== "localhost" &&
624
- hostname !== "127.0.0.1" &&
625
- hostname !== "0.0.0.0" &&
626
- hostname !== "::1" &&
627
- hostname !== "[::1]" &&
628
- !hostname.endsWith(".localhost"));
629
- }
630
- catch {
631
- return true;
632
- }
633
- }
634
744
  const listCommand = defineCommand({
635
745
  meta: { name: "list", description: "List all sources (local directories, managed packages, remote providers)" },
636
746
  args: {
@@ -648,9 +758,18 @@ const removeCommand = defineCommand({
648
758
  meta: { name: "remove", description: "Remove a source by id, ref, path, URL, or name" },
649
759
  args: {
650
760
  target: { type: "positional", description: "Source to remove (id, ref, path, URL, or name)", required: true },
761
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
651
762
  },
652
763
  async run({ args }) {
653
764
  await runWithJsonErrors(async () => {
765
+ const { confirmDestructive } = await import("./cli/confirm.js");
766
+ const confirmed = await confirmDestructive(`Remove source "${args.target}"? This cannot be undone.`, {
767
+ yes: args.yes === true,
768
+ });
769
+ if (!confirmed) {
770
+ process.stderr.write("Aborted.\n");
771
+ return;
772
+ }
654
773
  const result = await akmRemove({ target: args.target });
655
774
  appendEvent({
656
775
  eventType: "remove",
@@ -731,7 +850,8 @@ const showCommand = defineCommand({
731
850
  required: true,
732
851
  },
733
852
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
734
- detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)" },
853
+ detail: { type: "string", description: "Detail level (brief|normal|full)" },
854
+ shape: { type: "string", description: "Output projection (human|agent|summary)" },
735
855
  scope: {
736
856
  type: "string",
737
857
  description: "Scope filter (repeatable): --scope user=<id> --scope agent=<id> --scope run=<id> --scope channel=<name>. Narrows resolution to assets whose frontmatter scope matches.",
@@ -741,9 +861,12 @@ const showCommand = defineCommand({
741
861
  await runWithJsonErrors(async () => {
742
862
  const subcommand = Array.isArray(args._) ? args._[0] : undefined;
743
863
  if (subcommand === "proposal") {
864
+ if (!isQuiet()) {
865
+ process.stderr.write("warning: 'akm show proposal <id>' is deprecated and will be removed in 0.9.0. Use 'akm proposal show <id>'.\n");
866
+ }
744
867
  const proposalId = Array.isArray(args._) ? args._[1] : undefined;
745
868
  if (typeof proposalId !== "string" || !proposalId.trim()) {
746
- throw new UsageError("Usage: akm show proposal <id>", "MISSING_REQUIRED_ARGUMENT");
869
+ throw new UsageError("Usage: akm proposal show <id>", "MISSING_REQUIRED_ARGUMENT");
747
870
  }
748
871
  const result = akmProposalShow({ id: proposalId.trim() });
749
872
  output("proposal-show", result);
@@ -781,14 +904,24 @@ const showCommand = defineCommand({
781
904
  throw new UsageError(`Unknown view mode: ${akmView}. Expected one of: full|toc|frontmatter|section|lines`);
782
905
  }
783
906
  }
784
- const cliDetail = getOutputMode().detail;
907
+ const cliShape = getOutputMode().shape;
785
908
  const explicitDetail = parseFlagValue(process.argv, "--detail");
786
- const showDetail = explicitDetail === "brief" ? "brief" : cliDetail === "summary" ? "summary" : undefined;
909
+ // `--shape summary` selects the compact metadata projection for show
910
+ // (the legacy `--detail summary` spelling still maps here via the
911
+ // back-compat path in resolveOutputMode). `--detail brief` forces the
912
+ // brief response regardless of shape.
913
+ const showDetail = explicitDetail === "brief" ? "brief" : cliShape === "summary" ? "summary" : undefined;
787
914
  // `--scope` is repeatable — citty only exposes the last value, so read
788
915
  // every occurrence directly from argv (same pattern as `--filter`).
789
916
  const scopeTokens = parseAllFlagValues("--scope");
790
917
  const scope = parseScopeFilterFlags(scopeTokens, "--scope");
791
- const result = await akmShowUnified({ ref: args.ref, view, detail: showDetail, scope });
918
+ const result = await akmShowUnified({
919
+ ref: args.ref,
920
+ view,
921
+ detail: showDetail,
922
+ scope,
923
+ eventSource: resolveEventSource(),
924
+ });
792
925
  output("show", result);
793
926
  });
794
927
  },
@@ -862,12 +995,36 @@ const configCommand = defineCommand({
862
995
  args: {
863
996
  key: { type: "positional", required: true, description: "Config key (for example: embedding, llm)" },
864
997
  value: { type: "positional", required: true, description: "Config value" },
998
+ // #463: stable machine-friendly entry point for plugins / hooks.
999
+ // `--silent` suppresses the config dump on stdout so hook-driven
1000
+ // writes don't pollute their host's output stream.
1001
+ silent: {
1002
+ type: "boolean",
1003
+ description: "Suppress the post-write config dump on stdout. Use from hooks and CI scripts; the write still happens and errors still print.",
1004
+ default: false,
1005
+ },
1006
+ // #463: explicit layer flag for forward-compat. User layer is the only
1007
+ // settable layer today; the flag exists so plugin authors can encode
1008
+ // intent and the surface stays stable if project-layer writes return.
1009
+ layer: {
1010
+ type: "string",
1011
+ description: "Config layer to write to. Currently only `user` is supported.",
1012
+ default: "user",
1013
+ },
865
1014
  },
866
1015
  run({ args }) {
867
1016
  return runWithJsonErrors(() => {
868
- const updated = setConfigValue(loadUserConfig(), args.key, args.value);
1017
+ if (args.layer && args.layer !== "user") {
1018
+ throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1019
+ }
1020
+ // Use loadConfig (not loadUserConfig) so the project-config
1021
+ // deprecation warning fires consistently with `akm config get`
1022
+ // (#457). Effective merged shape is identical post-0.8.0.
1023
+ const updated = setConfigValue(loadConfig(), args.key, args.value);
869
1024
  saveConfig(updated);
870
- output("config", listConfig(updated));
1025
+ if (!args.silent) {
1026
+ output("config", listConfig(updated));
1027
+ }
871
1028
  });
872
1029
  },
873
1030
  }),
@@ -875,12 +1032,83 @@ const configCommand = defineCommand({
875
1032
  meta: { name: "unset", description: "Unset an optional configuration key or whole embedding/llm section" },
876
1033
  args: {
877
1034
  key: { type: "positional", required: true, description: "Config key to unset" },
1035
+ silent: {
1036
+ type: "boolean",
1037
+ description: "Suppress the post-write config dump on stdout.",
1038
+ default: false,
1039
+ },
1040
+ layer: {
1041
+ type: "string",
1042
+ description: "Config layer to write to. Currently only `user` is supported.",
1043
+ default: "user",
1044
+ },
878
1045
  },
879
1046
  run({ args }) {
880
1047
  return runWithJsonErrors(() => {
881
- const updated = unsetConfigValue(loadUserConfig(), args.key);
1048
+ if (args.layer && args.layer !== "user") {
1049
+ throw new UsageError(`Unsupported --layer "${args.layer}". Only "user" is settable in 0.8.0.`, "INVALID_FLAG_VALUE");
1050
+ }
1051
+ const updated = unsetConfigValue(loadConfig(), args.key);
882
1052
  saveConfig(updated);
883
- output("config", listConfig(updated));
1053
+ if (!args.silent) {
1054
+ output("config", listConfig(updated));
1055
+ }
1056
+ });
1057
+ },
1058
+ }),
1059
+ validate: defineCommand({
1060
+ meta: {
1061
+ name: "validate",
1062
+ description: "Validate the on-disk config file against the schema. Exits non-zero on errors.",
1063
+ },
1064
+ async run() {
1065
+ return runWithJsonErrors(async () => {
1066
+ const { runConfigValidate } = await import("./cli/config-validate.js");
1067
+ await runConfigValidate();
1068
+ });
1069
+ },
1070
+ }),
1071
+ migrate: defineCommand({
1072
+ meta: {
1073
+ name: "migrate",
1074
+ description: "Migrate the config file to the current schema version. Use --dry-run to preview without writing.",
1075
+ },
1076
+ args: {
1077
+ "dry-run": { type: "boolean", description: "Preview the migration result without writing.", default: false },
1078
+ "print-diff": {
1079
+ type: "boolean",
1080
+ description: "Print a unified diff of old vs new config alongside the migration output.",
1081
+ default: false,
1082
+ },
1083
+ },
1084
+ async run({ args }) {
1085
+ return runWithJsonErrors(async () => {
1086
+ const { runConfigMigrate } = await import("./cli/config-migrate.js");
1087
+ await runConfigMigrate({ dryRun: Boolean(args["dry-run"]), printDiff: Boolean(args["print-diff"]) });
1088
+ });
1089
+ },
1090
+ }),
1091
+ enable: defineCommand({
1092
+ meta: { name: "enable", description: "Enable an optional component (skills.sh)" },
1093
+ args: {
1094
+ target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
1095
+ },
1096
+ run({ args }) {
1097
+ return runWithJsonErrors(() => {
1098
+ const result = toggleComponent(args.target, true);
1099
+ output("enable", result);
1100
+ });
1101
+ },
1102
+ }),
1103
+ disable: defineCommand({
1104
+ meta: { name: "disable", description: "Disable an optional component (skills.sh)" },
1105
+ args: {
1106
+ target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
1107
+ },
1108
+ run({ args }) {
1109
+ return runWithJsonErrors(() => {
1110
+ const result = toggleComponent(args.target, false);
1111
+ output("disable", result);
884
1112
  });
885
1113
  },
886
1114
  }),
@@ -897,15 +1125,51 @@ const configCommand = defineCommand({
897
1125
  });
898
1126
  },
899
1127
  });
900
- const saveCommand = defineCommand({
1128
+ // Shared `save`/`sync` body. `sync` is the canonical spelling in 0.8; `save`
1129
+ // remains a deprecated alias (removed 0.9.0). Both share this implementation so
1130
+ // the git-commit/push logic and the `--format`-as-name workaround stay in one place.
1131
+ async function runSyncBody(args, verb) {
1132
+ await runWithJsonErrors(async () => {
1133
+ // Fix: citty can consume `--format json` (space-separated) as the
1134
+ // positional `name` argument (e.g. `akm sync --format json` parses
1135
+ // name="json"). Detect the mis-parse by checking argv order — only
1136
+ // treat the positional as consumed by --format when --format appears
1137
+ // before any standalone occurrence of the same value in the sync
1138
+ // subcommand's argv slice. This preserves legitimate invocations
1139
+ // like `akm sync json --format json`.
1140
+ const parsedFormat = parseFlagValue(process.argv, "--format");
1141
+ const effectiveName = args.name !== undefined &&
1142
+ parsedFormat !== undefined &&
1143
+ args.name === parsedFormat &&
1144
+ wasFormatValueConsumedAsName(args.name, parsedFormat, verb)
1145
+ ? undefined
1146
+ : args.name;
1147
+ let writable;
1148
+ if (effectiveName === undefined) {
1149
+ // Primary stash — honour the root-level writable flag from config.
1150
+ writable = resolveWritableOverride(loadConfig());
1151
+ }
1152
+ const result = saveGitStash(effectiveName, args.message, writable, { push: args.push !== false });
1153
+ appendEvent({
1154
+ eventType: "save",
1155
+ metadata: {
1156
+ name: effectiveName ?? null,
1157
+ message: args.message ?? null,
1158
+ ok: result.ok !== false,
1159
+ },
1160
+ });
1161
+ output("save", result);
1162
+ });
1163
+ }
1164
+ const syncCommand = defineCommand({
901
1165
  meta: {
902
- name: "save",
903
- description: "Save changes in a git-backed stash: commits (and pushes when writable + remote is configured). No-op for non-git stashes.",
1166
+ name: "sync",
1167
+ description: "Sync changes in a git-backed stash: commits (and pushes when writable + remote is configured). No-op for non-git stashes.",
904
1168
  },
905
1169
  args: {
906
1170
  name: {
907
1171
  type: "positional",
908
- description: "Name of the git stash to save (default: primary stash directory)",
1172
+ description: "Name of the git stash to sync (default: primary stash directory)",
909
1173
  required: false,
910
1174
  },
911
1175
  message: {
@@ -913,56 +1177,59 @@ const saveCommand = defineCommand({
913
1177
  alias: "m",
914
1178
  description: "Commit message (default: timestamp)",
915
1179
  },
1180
+ push: {
1181
+ type: "boolean",
1182
+ description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
1183
+ default: true,
1184
+ },
916
1185
  },
917
1186
  async run({ args }) {
918
- await runWithJsonErrors(async () => {
919
- // Fix: citty can consume `--format json` (space-separated) as the
920
- // positional `name` argument (e.g. `akm save --format json` parses
921
- // name="json"). Detect the mis-parse by checking argv order — only
922
- // treat the positional as consumed by --format when --format appears
923
- // before any standalone occurrence of the same value in the save
924
- // subcommand's argv slice. This preserves legitimate invocations
925
- // like `akm save json --format json`.
926
- const parsedFormat = parseFlagValue(process.argv, "--format");
927
- const effectiveName = args.name !== undefined &&
928
- parsedFormat !== undefined &&
929
- args.name === parsedFormat &&
930
- wasFormatValueConsumedAsName(args.name, parsedFormat)
931
- ? undefined
932
- : args.name;
933
- let writable;
934
- if (effectiveName === undefined) {
935
- // Primary stash — honour the root-level writable flag from config.
936
- const cfg = loadConfig();
937
- writable = cfg.writable === true ? true : undefined;
938
- }
939
- const result = saveGitStash(effectiveName, args.message, writable);
940
- appendEvent({
941
- eventType: "save",
942
- metadata: {
943
- name: effectiveName ?? null,
944
- message: args.message ?? null,
945
- ok: result.ok !== false,
946
- },
947
- });
948
- output("save", result);
949
- });
1187
+ await runSyncBody(args, "sync");
950
1188
  },
951
1189
  });
952
- /**
953
- * Detect whether `--format <value>` was consumed by citty as the optional
954
- * `name` positional of `akm save`. Returns true only when `--format` appears
955
- * in the save subcommand's argv slice AND the candidate name does NOT
956
- * appear as a standalone positional elsewhere (before or after the flag).
1190
+ // Deprecated alias (removed 0.9.0): `akm save` → `akm sync`.
1191
+ const saveCommand = defineCommand({
1192
+ meta: {
1193
+ name: "save",
1194
+ description: "DEPRECATED use `akm sync`. Removed in 0.9.0.",
1195
+ },
1196
+ args: {
1197
+ name: {
1198
+ type: "positional",
1199
+ description: "Name of the git stash to save (default: primary stash directory)",
1200
+ required: false,
1201
+ },
1202
+ message: {
1203
+ type: "string",
1204
+ alias: "m",
1205
+ description: "Commit message (default: timestamp)",
1206
+ },
1207
+ push: {
1208
+ type: "boolean",
1209
+ description: "Push after commit when writable + remote configured (use --no-push to commit only). Default: true.",
1210
+ default: true,
1211
+ },
1212
+ },
1213
+ async run({ args }) {
1214
+ emitCommandDeprecation("save", "sync");
1215
+ await runSyncBody(args, "save");
1216
+ },
1217
+ });
1218
+ /**
1219
+ * Detect whether `--format <value>` was consumed by citty as the optional
1220
+ * `name` positional of `akm save`. Returns true only when `--format` appears
1221
+ * in the save subcommand's argv slice AND the candidate name does NOT
1222
+ * appear as a standalone positional elsewhere (before or after the flag).
957
1223
  *
958
- * This keeps `akm save json --format json` routing `json` as the stash name,
959
- * while `akm save --format json` (no separate positional) is treated as a
960
- * primary-stash save.
1224
+ * This keeps `akm sync json --format json` routing `json` as the stash name,
1225
+ * while `akm sync --format json` (no separate positional) is treated as a
1226
+ * primary-stash sync. `verb` is the subcommand token to anchor on (`sync` or
1227
+ * the deprecated `save`).
961
1228
  */
962
- function wasFormatValueConsumedAsName(name, formatValue) {
1229
+ function wasFormatValueConsumedAsName(name, formatValue, verb) {
963
1230
  const argv = process.argv.slice(2);
964
- const saveIndex = argv.indexOf("save");
965
- const tokens = saveIndex >= 0 ? argv.slice(saveIndex + 1) : argv;
1231
+ const verbIndex = argv.indexOf(verb);
1232
+ const tokens = verbIndex >= 0 ? argv.slice(verbIndex + 1) : argv;
966
1233
  let formatIndex = -1;
967
1234
  let formatConsumesNextToken = false;
968
1235
  for (let i = 0; i < tokens.length; i += 1) {
@@ -1013,280 +1280,6 @@ const cloneCommand = defineCommand({
1013
1280
  });
1014
1281
  },
1015
1282
  });
1016
- const registryCommand = defineCommand({
1017
- meta: { name: "registry", description: "Manage stash registries" },
1018
- subCommands: {
1019
- list: defineCommand({
1020
- meta: { name: "list", description: "List configured registries" },
1021
- run() {
1022
- return runWithJsonErrors(() => {
1023
- const config = loadUserConfig();
1024
- const registries = config.registries ?? DEFAULT_CONFIG.registries;
1025
- output("registry-list", { registries });
1026
- });
1027
- },
1028
- }),
1029
- add: defineCommand({
1030
- meta: { name: "add", description: "Add a registry by URL" },
1031
- args: {
1032
- url: { type: "positional", description: "Registry index URL", required: true },
1033
- name: { type: "string", description: "Human-friendly name for the registry" },
1034
- provider: { type: "string", description: "Provider type (e.g. static-index, skills-sh)" },
1035
- options: { type: "string", description: 'Provider options as JSON (e.g. \'{"apiKey":"key"}\').' },
1036
- "allow-insecure": {
1037
- type: "boolean",
1038
- description: "Allow a plain HTTP registry URL (otherwise rejected)",
1039
- default: false,
1040
- },
1041
- },
1042
- run({ args }) {
1043
- return runWithJsonErrors(() => {
1044
- if (!args.url.startsWith("http")) {
1045
- throw new UsageError("Registry URL must start with http:// or https://");
1046
- }
1047
- if (args.url.startsWith("http://")) {
1048
- const allowInsecure = getHyphenatedBoolean(args, "allow-insecure");
1049
- if (!allowInsecure) {
1050
- throw new UsageError("Registry URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious index. " +
1051
- "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.");
1052
- }
1053
- warn("Warning: registry URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious index.");
1054
- }
1055
- const config = loadUserConfig();
1056
- const registries = [...(config.registries ?? [])];
1057
- // Deduplicate by URL
1058
- if (registries.some((r) => r.url === args.url)) {
1059
- output("registry-add", { registries, added: false, message: "Registry URL already configured" });
1060
- return;
1061
- }
1062
- const entry = { url: args.url };
1063
- if (args.name)
1064
- entry.name = args.name;
1065
- if (args.provider)
1066
- entry.provider = args.provider;
1067
- if (args.options) {
1068
- try {
1069
- entry.options = JSON.parse(args.options);
1070
- }
1071
- catch {
1072
- throw new UsageError("--options must be valid JSON");
1073
- }
1074
- }
1075
- registries.push(entry);
1076
- saveConfig({ ...config, registries });
1077
- output("registry-add", { registries, added: true });
1078
- });
1079
- },
1080
- }),
1081
- remove: defineCommand({
1082
- meta: { name: "remove", description: "Remove a registry by URL or name" },
1083
- args: {
1084
- target: { type: "positional", description: "Registry URL or name to remove", required: true },
1085
- },
1086
- run({ args }) {
1087
- return runWithJsonErrors(() => {
1088
- const config = loadUserConfig();
1089
- const registries = [...(config.registries ?? [])];
1090
- const idx = registries.findIndex((r) => r.url === args.target || r.name === args.target);
1091
- if (idx === -1) {
1092
- output("registry-remove", { registries, removed: false, message: "No matching registry found" });
1093
- return;
1094
- }
1095
- const removed = registries.splice(idx, 1)[0];
1096
- saveConfig({ ...config, registries });
1097
- output("registry-remove", { registries, removed: true, entry: removed });
1098
- });
1099
- },
1100
- }),
1101
- search: defineCommand({
1102
- meta: { name: "search", description: "Search enabled registries for stashes" },
1103
- args: {
1104
- query: { type: "positional", description: "Search query", required: true },
1105
- limit: { type: "string", description: "Maximum number of results" },
1106
- assets: { type: "boolean", description: "Include asset-level search results", default: false },
1107
- },
1108
- async run({ args }) {
1109
- await runWithJsonErrors(async () => {
1110
- const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
1111
- const result = await searchRegistry(args.query, { limit: limitRaw, includeAssets: args.assets });
1112
- output("registry-search", result);
1113
- });
1114
- },
1115
- }),
1116
- "build-index": defineCommand({
1117
- meta: { name: "build-index", description: "Build a v2 registry index from discovery and manual entries" },
1118
- args: {
1119
- out: { type: "string", description: "Output path for the generated index" },
1120
- manual: { type: "string", description: "Manual entries JSON file" },
1121
- "npm-registry": { type: "string", description: "Override npm registry base URL" },
1122
- "github-api": { type: "string", description: "Override GitHub API base URL" },
1123
- },
1124
- async run({ args }) {
1125
- await runWithJsonErrors(async () => {
1126
- const result = await buildRegistryIndex({
1127
- manualEntriesPath: args.manual,
1128
- npmRegistryBase: getHyphenatedArg(args, "npm-registry"),
1129
- githubApiBase: getHyphenatedArg(args, "github-api"),
1130
- });
1131
- const outPath = writeRegistryIndex(result.index, args.out);
1132
- output("registry-build-index", {
1133
- outPath,
1134
- version: result.index.version,
1135
- updatedAt: result.index.updatedAt,
1136
- totalKits: result.counts.total,
1137
- counts: result.counts,
1138
- manualEntriesPath: result.paths.manualEntriesPath,
1139
- });
1140
- });
1141
- },
1142
- }),
1143
- },
1144
- });
1145
- const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
1146
- const MAX_FEEDBACK_TAGS = 10;
1147
- function validateFeedbackTags(raw) {
1148
- const seen = new Set();
1149
- const out = [];
1150
- for (const tag of raw) {
1151
- const parts = tag.split(":");
1152
- if (parts.length < 2 || parts[0] === "" || parts.slice(1).join("") === "") {
1153
- throw new UsageError(`Invalid tag "${tag}". Tags must be in key:value format where key matches [a-z_][a-z0-9_]* and value is non-empty.`, "INVALID_FLAG_VALUE");
1154
- }
1155
- const key = parts[0];
1156
- if (!TAG_KEY_RE.test(key)) {
1157
- throw new UsageError(`Invalid tag key "${key}" in "${tag}". Key must match [a-z_][a-z0-9_]*.`, "INVALID_FLAG_VALUE");
1158
- }
1159
- if (seen.has(tag))
1160
- continue;
1161
- seen.add(tag);
1162
- out.push(tag);
1163
- }
1164
- if (out.length > MAX_FEEDBACK_TAGS) {
1165
- throw new UsageError(`Too many tags: ${out.length}. Maximum is ${MAX_FEEDBACK_TAGS}.`, "INVALID_FLAG_VALUE");
1166
- }
1167
- return out;
1168
- }
1169
- const feedbackCommand = defineCommand({
1170
- meta: {
1171
- name: "feedback",
1172
- description: "Record positive or negative feedback for any indexed stash asset.\n\n" +
1173
- "Positive feedback boosts an asset's EMA utility score, making it rank higher\n" +
1174
- "in future searches without requiring a full reindex.\n\n" +
1175
- "Negative feedback records a negative signal in usage_events and state.db events.\n" +
1176
- "It does NOT immediately lower the asset's ranking — the EMA utility score is\n" +
1177
- "updated the next time `akm index` runs (incremental or full). Run `akm index`\n" +
1178
- "after recording negative feedback to have it reflected in search results.",
1179
- },
1180
- args: {
1181
- // Optional in citty so run() is invoked even when omitted; we re-validate
1182
- // and throw a structured UsageError below so exit code is 2 (USAGE) rather
1183
- // than citty's default 0 (help banner).
1184
- ref: { type: "positional", description: "Asset ref (type:name)", required: false },
1185
- positive: { type: "boolean", description: "Record positive feedback (boosts ranking immediately)", default: false },
1186
- negative: {
1187
- type: "boolean",
1188
- description: "Record negative feedback (suppresses ranking after next `akm index`). " +
1189
- "Reindexing is required for the signal to affect search results.",
1190
- default: false,
1191
- },
1192
- reason: {
1193
- type: "string",
1194
- description: "Reason for the feedback (recommended for negative feedback, used by distillation)",
1195
- },
1196
- note: { type: "string", description: "Alias for --reason (backward-compatible, prefer --reason)" },
1197
- tag: {
1198
- type: "string",
1199
- description: "Tag to attach to the feedback (repeatable, e.g. --tag slice:train --tag team:platform)",
1200
- },
1201
- },
1202
- run({ args }) {
1203
- return runWithJsonErrors(async () => {
1204
- const ref = (args.ref ?? "").trim();
1205
- if (!ref) {
1206
- throw new UsageError("Asset ref is required. Usage: akm feedback <ref> --positive|--negative", "MISSING_REQUIRED_ARGUMENT", "Pass a ref like `skill:deploy` and either --positive or --negative.");
1207
- }
1208
- parseAssetRef(ref);
1209
- if (args.positive && args.negative) {
1210
- throw new UsageError("Specify either --positive or --negative, not both.");
1211
- }
1212
- if (!args.positive && !args.negative) {
1213
- throw new UsageError("Specify --positive or --negative.");
1214
- }
1215
- const signal = args.positive ? "positive" : "negative";
1216
- const reason = args.reason ?? args.note;
1217
- if (args.negative === true && !reason?.trim()) {
1218
- const cfg = loadConfig();
1219
- if (cfg.feedback?.requireReason === true) {
1220
- throw new UsageError("Negative feedback requires --reason (feedback.requireReason is enabled).", "MISSING_REQUIRED_ARGUMENT");
1221
- }
1222
- else {
1223
- warn("Warning: negative feedback without --reason provides less distillation signal.");
1224
- }
1225
- }
1226
- const rawTags = parseAllFlagValues("--tag");
1227
- const validatedTags = validateFeedbackTags(rawTags);
1228
- const metadataObj = {
1229
- signal,
1230
- ...(reason?.trim() ? { reason: reason.trim() } : {}),
1231
- ...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
1232
- };
1233
- const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
1234
- // Auto-index when stale so the index is current before recording feedback.
1235
- const sources = resolveSourceEntries();
1236
- if (sources.length > 0) {
1237
- await ensureIndex(sources[0].path);
1238
- }
1239
- const db = openExistingDatabase();
1240
- try {
1241
- const entryId = findEntryIdByRef(db, ref);
1242
- if (entryId === undefined) {
1243
- throw new UsageError(`Ref "${ref}" is not in the index. ` +
1244
- "Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
1245
- }
1246
- // Persist the feedback signal into usage_events. For positive signals,
1247
- // the EMA utility score is updated immediately on the next read path.
1248
- // For negative signals, the score is adjusted the next time `akm index`
1249
- // runs — the signal is durable in the DB but does NOT suppress ranking
1250
- // in search results until after reindexing.
1251
- insertUsageEvent(db, {
1252
- event_type: "feedback",
1253
- entry_ref: ref,
1254
- entry_id: entryId,
1255
- signal,
1256
- metadata: metadataStr,
1257
- });
1258
- // Apply feedback-derived utility score adjustment immediately so that
1259
- // positive/negative signals influence search ranking without requiring
1260
- // a full reindex. We query the total accumulated feedback counts from
1261
- // usage_events so the delta reflects the entire signal history.
1262
- try {
1263
- const counts = db
1264
- .prepare(`SELECT
1265
- SUM(CASE WHEN signal = 'positive' THEN 1 ELSE 0 END) AS pos,
1266
- SUM(CASE WHEN signal = 'negative' THEN 1 ELSE 0 END) AS neg
1267
- FROM usage_events
1268
- WHERE event_type = 'feedback' AND entry_id = ?`)
1269
- .get(entryId);
1270
- const pos = counts?.pos ?? 0;
1271
- const neg = counts?.neg ?? 0;
1272
- applyFeedbackToUtilityScore(db, entryId, pos, neg);
1273
- }
1274
- catch {
1275
- // best-effort — feedback recording succeeds even if utility update fails
1276
- }
1277
- }
1278
- finally {
1279
- closeDatabase(db);
1280
- }
1281
- appendEvent({
1282
- eventType: "feedback",
1283
- ref,
1284
- metadata: metadataObj,
1285
- });
1286
- output("feedback", { ok: true, ref, signal, reason: reason?.trim() ?? null, tags: validatedTags });
1287
- });
1288
- },
1289
- });
1290
1283
  const historyCommand = defineCommand({
1291
1284
  meta: {
1292
1285
  name: "history",
@@ -1300,20 +1293,49 @@ const historyCommand = defineCommand({
1300
1293
  args: {
1301
1294
  ref: { type: "string", description: "Asset ref (type:name). Omit for stash-wide history." },
1302
1295
  since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
1296
+ generator: {
1297
+ type: "string",
1298
+ description: 'Filter by event generator: "user" (default) or "improve" (akm improve operations).',
1299
+ },
1300
+ source: {
1301
+ type: "string",
1302
+ description: "DEPRECATED — use --generator. Removed in 0.9.0.",
1303
+ },
1303
1304
  "include-proposals": {
1304
1305
  type: "boolean",
1305
1306
  description: "Also include proposal lifecycle events (promoted, rejected) from state.db events. " +
1306
1307
  "Default: false (usage_events only).",
1307
1308
  default: false,
1308
1309
  },
1310
+ "accept-rate-by-source": {
1311
+ type: "boolean",
1312
+ description: "Compute accept-rate-per-source metrics from the proposal store and include them in the output (F-4 / #385). " +
1313
+ "Useful for measuring which generators (reflect, distill, …) produce the most accepted proposals.",
1314
+ default: false,
1315
+ },
1309
1316
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
1310
1317
  },
1311
1318
  run({ args }) {
1312
1319
  return runWithJsonErrors(async () => {
1320
+ if (args.generator === undefined && args.source !== undefined) {
1321
+ emitFlagDeprecation("--source", "--generator", "history");
1322
+ }
1323
+ const generatorFlag = (args.generator ?? args.source);
1324
+ if (generatorFlag !== undefined && generatorFlag !== "user" && generatorFlag !== "improve") {
1325
+ // Name the flag the user actually typed so the diagnostic points at
1326
+ // their command line, not the canonical flag they may not have used.
1327
+ const usedFlag = args.generator !== undefined ? "--generator" : "--source";
1328
+ throw new UsageError(`Invalid ${usedFlag} value: "${generatorFlag}". Must be "user" or "improve".`, "INVALID_FLAG_VALUE");
1329
+ }
1330
+ const sources = resolveSourceEntries();
1331
+ const stashDir = sources[0]?.path;
1313
1332
  const result = await akmHistory({
1314
1333
  ref: args.ref,
1315
1334
  since: args.since,
1335
+ source: generatorFlag,
1316
1336
  includeProposals: args["include-proposals"],
1337
+ acceptRateBySource: args["accept-rate-by-source"],
1338
+ stashDir,
1317
1339
  });
1318
1340
  output("history", result);
1319
1341
  });
@@ -1327,10 +1349,17 @@ const workflowStartCommand = defineCommand({
1327
1349
  args: {
1328
1350
  ref: { type: "positional", description: "Workflow ref (workflow:<name>)", required: true },
1329
1351
  params: { type: "string", description: "Workflow parameters as a JSON object" },
1352
+ force: {
1353
+ type: "boolean",
1354
+ description: "Allow a parallel run when an active run already exists in this scope (#485)",
1355
+ default: false,
1356
+ },
1330
1357
  },
1331
1358
  async run({ args }) {
1332
1359
  await runWithJsonErrors(async () => {
1333
- const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params"));
1360
+ const result = await startWorkflowRun(args.ref, parseWorkflowJsonObject(args.params, "--params"), {
1361
+ force: args.force === true,
1362
+ });
1334
1363
  output("workflow-start", result);
1335
1364
  });
1336
1365
  },
@@ -1343,11 +1372,14 @@ const workflowNextCommand = defineCommand({
1343
1372
  args: {
1344
1373
  target: { type: "positional", description: "Workflow run id or workflow ref", required: true },
1345
1374
  params: { type: "string", description: "Workflow parameters as a JSON object (only for auto-started runs)" },
1346
- "dry-run": { type: "boolean", description: "Not supported — rejected with an error", default: false },
1347
1375
  },
1348
1376
  async run({ args }) {
1349
1377
  await runWithJsonErrors(async () => {
1350
- if (getHyphenatedBoolean(args, "dry-run")) {
1378
+ // `--dry-run` is intentionally NOT a declared arg (so it stays out of
1379
+ // --help). The guard reads it straight from process.argv so existing
1380
+ // callers still get a clear, actionable error instead of a generic
1381
+ // "unknown flag" from citty.
1382
+ if (hasBooleanFlag(process.argv, "--dry-run")) {
1351
1383
  throw new UsageError("`akm workflow next` does not support --dry-run. Remove the flag to start or resume a run.", "INVALID_FLAG_VALUE");
1352
1384
  }
1353
1385
  const parsedParams = args.params ? parseWorkflowJsonObject(args.params, "--params") : undefined;
@@ -1605,234 +1637,6 @@ const workflowCommand = defineCommand({
1605
1637
  });
1606
1638
  },
1607
1639
  });
1608
- const rememberCommand = defineCommand({
1609
- meta: {
1610
- name: "remember",
1611
- description: "Record a memory in the default stash",
1612
- },
1613
- args: {
1614
- content: {
1615
- type: "positional",
1616
- description: "Memory content. Omit to read markdown from stdin.",
1617
- required: false,
1618
- },
1619
- name: {
1620
- type: "string",
1621
- description: "Memory name (defaults to a slug from the content)",
1622
- },
1623
- force: {
1624
- type: "boolean",
1625
- description: "Overwrite an existing memory with the same name",
1626
- default: false,
1627
- },
1628
- description: {
1629
- type: "string",
1630
- description: "Short description written to frontmatter (persisted as the memory's description field)",
1631
- },
1632
- tag: {
1633
- type: "string",
1634
- description: "Tag to add to the memory (repeatable: --tag foo --tag bar)",
1635
- },
1636
- expires: {
1637
- type: "string",
1638
- description: "Expiry duration shorthand (e.g. 30d, 12h, 6m). Resolved to an ISO date.",
1639
- },
1640
- source: {
1641
- type: "string",
1642
- description: "Source reference (URL, asset ref, file path, or any free-form string)",
1643
- },
1644
- auto: {
1645
- type: "boolean",
1646
- description: "Apply heuristic tagging (code, subjective, source, observed_at) from the body",
1647
- default: false,
1648
- },
1649
- enrich: {
1650
- type: "boolean",
1651
- description: "Call the configured LLM to propose tags and description (requires LLM config)",
1652
- default: false,
1653
- },
1654
- target: {
1655
- type: "string",
1656
- description: "Override the write destination. Accepts a source name from your config; falls back to defaultWriteTarget then the working stash.",
1657
- },
1658
- user: {
1659
- type: "string",
1660
- description: "Scope this memory to a user id (persisted as `scope_user` frontmatter)",
1661
- },
1662
- agent: {
1663
- type: "string",
1664
- description: "Scope this memory to an agent id (persisted as `scope_agent` frontmatter)",
1665
- },
1666
- run: {
1667
- type: "string",
1668
- description: "Scope this memory to a run id (persisted as `scope_run` frontmatter)",
1669
- },
1670
- channel: {
1671
- type: "string",
1672
- description: "Scope this memory to a channel name (persisted as `scope_channel` frontmatter)",
1673
- },
1674
- showSimilar: {
1675
- type: "boolean",
1676
- description: "Return top-3 similar existing memories in output (opt-in)",
1677
- },
1678
- },
1679
- async run({ args }) {
1680
- return runWithJsonErrors(async () => {
1681
- const body = readMemoryContent(resolveRememberContentArg(args.content));
1682
- // Determine if the user has requested any structured metadata mode.
1683
- // Collect all --tag occurrences directly from process.argv because citty
1684
- // only exposes the last value for repeated string flags.
1685
- const rawTags = parseAllFlagValues("--tag");
1686
- // Collect scope flags. Scope alone counts as structured metadata so we
1687
- // emit frontmatter, but it does NOT trigger the "tags required" check —
1688
- // memory + scope (no tags) is a valid combination for multi-tenant use.
1689
- const scopeFields = {};
1690
- if (typeof args.user === "string" && args.user.trim())
1691
- scopeFields.user = args.user.trim();
1692
- if (typeof args.agent === "string" && args.agent.trim())
1693
- scopeFields.agent = args.agent.trim();
1694
- if (typeof args.run === "string" && args.run.trim())
1695
- scopeFields.run = args.run.trim();
1696
- if (typeof args.channel === "string" && args.channel.trim())
1697
- scopeFields.channel = args.channel.trim();
1698
- const hasScope = Object.keys(scopeFields).length > 0;
1699
- const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description;
1700
- const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto;
1701
- if (!hasStructuredArgs) {
1702
- const result = await writeMarkdownAsset({
1703
- type: "memory",
1704
- content: body,
1705
- name: args.name,
1706
- fallbackPrefix: "memory",
1707
- force: args.force,
1708
- target: args.target,
1709
- });
1710
- appendEvent({
1711
- eventType: "remember",
1712
- ref: result.ref,
1713
- metadata: { path: result.path, force: args.force === true },
1714
- });
1715
- if (args.showSimilar) {
1716
- const similar = await fetchSimilarMemories(body.slice(0, 500), result.ref);
1717
- output("remember", { ok: true, ...result, similar });
1718
- }
1719
- else {
1720
- output("remember", { ok: true, ...result });
1721
- }
1722
- return;
1723
- }
1724
- // ── Accumulate metadata from all three modes ──────────────────────────
1725
- // Start with CLI args (Mode 1: always)
1726
- const tags = [...rawTags];
1727
- // --description is persisted as-is; LLM enrichment may fill it if absent.
1728
- let description = args.description || undefined;
1729
- let source = args.source;
1730
- let observed_at;
1731
- let expires;
1732
- let subjective;
1733
- // Resolve --expires to an ISO date string
1734
- if (args.expires) {
1735
- const durationMs = parseDuration(args.expires);
1736
- const expiresDate = new Date(Date.now() + durationMs);
1737
- expires = expiresDate.toISOString().slice(0, 10);
1738
- }
1739
- // Mode 2: --auto heuristics
1740
- if (args.auto) {
1741
- const auto = runAutoHeuristics(body);
1742
- for (const t of auto.tags) {
1743
- if (!tags.includes(t))
1744
- tags.push(t);
1745
- }
1746
- if (!source && auto.source)
1747
- source = auto.source;
1748
- if (!observed_at && auto.observed_at)
1749
- observed_at = auto.observed_at;
1750
- if (!subjective && auto.subjective)
1751
- subjective = auto.subjective;
1752
- }
1753
- // Mode 3: --enrich LLM (fail-soft)
1754
- if (args.enrich) {
1755
- const enriched = await runLlmEnrich(body);
1756
- for (const t of enriched.tags) {
1757
- if (!tags.includes(t))
1758
- tags.push(t);
1759
- }
1760
- if (!description && enriched.description)
1761
- description = enriched.description;
1762
- if (!observed_at && enriched.observed_at)
1763
- observed_at = enriched.observed_at;
1764
- }
1765
- // ── Required-field check (before any write) ───────────────────────────
1766
- // Tags remain required when the user explicitly asked for tag-bearing
1767
- // metadata (--tag / --enrich / --description / --source / --expires).
1768
- // `--auto` alone is allowed even when its heuristics derive zero tags.
1769
- // Scope-only writes (`akm remember "..." --user u1`) also skip this
1770
- // check — scope is independent metadata and a memory with only scope is
1771
- // valid.
1772
- const missing = [];
1773
- if (hasTagRequiringArgs && tags.length === 0)
1774
- missing.push("tags");
1775
- if (missing.length > 0) {
1776
- throw new UsageError(`Memory is missing required frontmatter field(s): ${missing.join(", ")}. ` +
1777
- "Provide them via --tag <value>, --auto (heuristics), or --enrich (LLM).");
1778
- }
1779
- // ── Build frontmatter and write ───────────────────────────────────────
1780
- const frontmatterBlock = buildMemoryFrontmatter({
1781
- description,
1782
- tags,
1783
- source,
1784
- observed_at,
1785
- expires,
1786
- subjective,
1787
- ...(hasScope ? { scope: scopeFields } : {}),
1788
- });
1789
- const contentWithFrontmatter = `${frontmatterBlock}\n${body}`;
1790
- const result = await writeMarkdownAsset({
1791
- type: "memory",
1792
- content: contentWithFrontmatter,
1793
- name: args.name,
1794
- fallbackPrefix: "memory",
1795
- force: args.force,
1796
- target: args.target,
1797
- });
1798
- appendEvent({
1799
- eventType: "remember",
1800
- ref: result.ref,
1801
- metadata: {
1802
- path: result.path,
1803
- force: args.force === true,
1804
- tagCount: tags.length,
1805
- enriched: args.enrich === true,
1806
- auto: args.auto === true,
1807
- ...(hasScope ? { scope: scopeFields } : {}),
1808
- },
1809
- });
1810
- if (args.showSimilar) {
1811
- const similar = await fetchSimilarMemories((body ?? args.content ?? "").slice(0, 500), result.ref);
1812
- output("remember", { ok: true, ...result, similar });
1813
- }
1814
- else {
1815
- output("remember", { ok: true, ...result });
1816
- }
1817
- });
1818
- },
1819
- });
1820
- /**
1821
- * Best-effort top-3 similar memory search for `--show-similar`.
1822
- * Scoped to memory: type; excludes the just-written ref.
1823
- */
1824
- async function fetchSimilarMemories(query, excludeRef) {
1825
- try {
1826
- const result = await akmSearch({ query, type: "memory", limit: 4 });
1827
- return (result.hits ?? [])
1828
- .filter((h) => "ref" in h && h.ref !== excludeRef)
1829
- .slice(0, 3)
1830
- .map((h) => ({ ref: h.ref, ...(h.name ? { title: h.name } : {}) }));
1831
- }
1832
- catch {
1833
- return [];
1834
- }
1835
- }
1836
1640
  const importKnowledgeCommand = defineCommand({
1837
1641
  meta: {
1838
1642
  name: "import",
@@ -1887,15 +1691,18 @@ const hintsCommand = defineCommand({
1887
1691
  args: {
1888
1692
  detail: {
1889
1693
  type: "string",
1890
- description: "Hints detail level accepts only `normal` or `full`. Differs from the global --detail flag (brief|normal|full|summary|agent); other values are rejected with INVALID_DETAIL_VALUE.",
1694
+ description: "Hints detail level (brief|normal|full). `brief` prints the short guide; `normal`/`full` print the complete guide.",
1891
1695
  default: "normal",
1892
1696
  },
1893
1697
  },
1894
1698
  run({ args }) {
1895
- if (args.detail !== "normal" && args.detail !== "full") {
1896
- throw new UsageError(`Invalid value for --detail: ${args.detail}. Expected one of: normal|full.`, "INVALID_DETAIL_VALUE");
1897
- }
1898
- process.stdout.write(loadHints(args.detail));
1699
+ return runWithJsonErrors(() => {
1700
+ // Let the global parser validate the value so an invalid `--detail`
1701
+ // returns the standard JSON error envelope (exit 2) rather than a raw
1702
+ // stack trace + exit 1. `brief` → short doc; `normal`/`full` → full doc.
1703
+ const detail = parseDetailLevel(args.detail) ?? "normal";
1704
+ process.stdout.write(loadHints(detail === "brief" ? "brief" : "full"));
1705
+ });
1899
1706
  },
1900
1707
  });
1901
1708
  const helpCommand = defineCommand({
@@ -1997,38 +1804,44 @@ function toggleComponent(targetRaw, enabled) {
1997
1804
  // normalizeToggleTarget throws for any unsupported target; this is unreachable.
1998
1805
  throw new UsageError(`Unsupported target "${targetRaw}". Supported targets: skills.sh`);
1999
1806
  }
1807
+ // Deprecated top-level aliases (removed 0.9.0) — delegate to `config enable|disable`.
2000
1808
  const enableCommand = defineCommand({
2001
- meta: { name: "enable", description: "Enable an optional component (skills.sh)" },
1809
+ meta: { name: "enable", description: "DEPRECATED use `akm config enable`. Removed in 0.9.0." },
2002
1810
  args: {
2003
1811
  target: { type: "positional", description: "Component to enable (skills.sh)", required: true },
2004
1812
  },
2005
1813
  run({ args }) {
2006
1814
  return runWithJsonErrors(() => {
1815
+ emitCommandDeprecation("enable", "config enable");
2007
1816
  const result = toggleComponent(args.target, true);
2008
1817
  output("enable", result);
2009
1818
  });
2010
1819
  },
2011
1820
  });
2012
1821
  const disableCommand = defineCommand({
2013
- meta: { name: "disable", description: "Disable an optional component (skills.sh)" },
1822
+ meta: { name: "disable", description: "DEPRECATED use `akm config disable`. Removed in 0.9.0." },
2014
1823
  args: {
2015
1824
  target: { type: "positional", description: "Component to disable (skills.sh)", required: true },
2016
1825
  },
2017
1826
  run({ args }) {
2018
1827
  return runWithJsonErrors(() => {
1828
+ emitCommandDeprecation("disable", "config disable");
2019
1829
  const result = toggleComponent(args.target, false);
2020
1830
  output("disable", result);
2021
1831
  });
2022
1832
  },
2023
1833
  });
2024
- // ── vault ───────────────────────────────────────────────────────────────────
1834
+ // ── env ───────────────────────────────────────────────────────────────────
2025
1835
  //
2026
- // `akm vault` manages secrets stored in `.env` files under each stash's
2027
- // vaults/ directory. Values are NEVER written to stdout or structured output.
2028
- function parseVaultRef(ref) {
2029
- return parseAssetRef(ref.includes(":") ? ref : `vault:${ref}`);
1836
+ // `akm env` manages whole `.env` files under each stash's env/ directory.
1837
+ // Values are NEVER written to stdout or structured output — only key NAMES and
1838
+ // start-of-line comments are surfaced. akm does not manage individual entries;
1839
+ // you edit the `.env` file yourself and akm loads it. Replaces the deprecated
1840
+ // `vault` type (see the shim further below; removed in 0.9.0).
1841
+ function parseEnvRef(ref) {
1842
+ return parseAssetRef(ref.includes(":") ? ref : `env:${ref}`);
2030
1843
  }
2031
- function findVaultSource(origin) {
1844
+ function findEnvSource(origin) {
2032
1845
  const sources = resolveSourceEntries(undefined, loadConfig());
2033
1846
  if (sources.length === 0) {
2034
1847
  throw new UsageError("No stashes configured. Run `akm init` to create your working stash.");
@@ -2041,30 +1854,59 @@ function findVaultSource(origin) {
2041
1854
  }
2042
1855
  return named;
2043
1856
  }
2044
- function makeVaultRef(name, source) {
2045
- return source?.registryId ? `${source.registryId}//vault:${name}` : `vault:${name}`;
1857
+ function makeEnvRef(name, source) {
1858
+ return source?.registryId ? `${source.registryId}//env:${name}` : `env:${name}`;
2046
1859
  }
2047
- function resolveVaultPath(ref) {
2048
- const parsed = parseVaultRef(ref);
2049
- if (parsed.type !== "vault") {
2050
- throw new UsageError(`Expected a vault ref (vault:<name>); got "${ref}".`);
1860
+ /**
1861
+ * Resolve an env ref to an absolute `.env` path. Accepts `env:`, `environment:`
1862
+ * (alias), and `vault:` (deprecated) refs as well as bare names. Prefers the
1863
+ * `env/` directory; falls back to the legacy `vaults/` directory when the env
1864
+ * file is absent there (handles an upgraded-but-not-yet-migrated stash). When
1865
+ * neither exists the env path is returned (so `create` writes under `env/`).
1866
+ */
1867
+ function resolveEnvPath(ref) {
1868
+ const parsed = parseEnvRef(ref);
1869
+ if (parsed.type !== "env" && parsed.type !== "vault") {
1870
+ throw new UsageError(`Expected an env ref (env:<name>); got "${ref}".`);
1871
+ }
1872
+ const source = findEnvSource(parsed.origin);
1873
+ const envRoot = path.join(source.path, "env");
1874
+ const envPath = resolveAssetPathFromName("env", envRoot, parsed.name);
1875
+ // Defense-in-depth: ensure the resolved path stays inside the env directory.
1876
+ // validateName already rejects traversal patterns like "../../foo", but an
1877
+ // absolute-path override or symlink-based attack could still escape without
1878
+ // this second check.
1879
+ if (!isWithin(envPath, envRoot)) {
1880
+ throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
1881
+ }
1882
+ const vaultRoot = path.join(source.path, "vaults");
1883
+ const vaultPath = resolveAssetPathFromName("vault", vaultRoot, parsed.name);
1884
+ if (!isWithin(vaultPath, vaultRoot)) {
1885
+ throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
2051
1886
  }
2052
- const source = findVaultSource(parsed.origin);
2053
- const typeRoot = path.join(source.path, "vaults");
2054
- const absPath = resolveAssetPathFromName("vault", typeRoot, parsed.name);
2055
- return { name: parsed.name, absPath, source, parsedRef: parsed };
1887
+ // Prefer env/; fall back to the frozen vaults/ copy only when the env file
1888
+ // is absent and the legacy vault file is present.
1889
+ if (!fs.existsSync(envPath) && fs.existsSync(vaultPath)) {
1890
+ return { name: parsed.name, absPath: vaultPath, source, parsedRef: parsed, dir: "vaults" };
1891
+ }
1892
+ return { name: parsed.name, absPath: envPath, source, parsedRef: parsed, dir: "env" };
2056
1893
  }
2057
1894
  /**
2058
- * Walk `vaults/` recursively and return one entry per `.env` file, using the
2059
- * vault asset spec's canonical-name logic so listing matches what the
2060
- * matcher/asset-spec actually resolves (e.g. `vaults/team/prod.env`
2061
- * `vault:team/prod`, `vaults/team/.env` `vault:team/default`).
1895
+ * Walk each stash's env files and return one entry per `.env` file, using the
1896
+ * env asset spec's canonical-name logic (e.g. `env/team/prod.env`
1897
+ * `env:team/prod`, `env/team/.env` `env:team/default`). When a stash has not
1898
+ * yet migrated (no `env/` dir) the legacy `vaults/` dir is listed instead, so
1899
+ * `env list` stays continuous across the upgrade.
2062
1900
  */
2063
- function listVaultsRecursive(listKeysFn) {
1901
+ function listEnvsRecursive(listKeysFn) {
2064
1902
  const result = [];
2065
1903
  for (const source of resolveSourceEntries(undefined, loadConfig())) {
2066
- const vaultsDir = path.join(source.path, "vaults");
2067
- if (!fs.existsSync(vaultsDir))
1904
+ const envDir = path.join(source.path, "env");
1905
+ const legacyDir = path.join(source.path, "vaults");
1906
+ // Prefer env/; only fall back to the frozen vaults/ copy when env/ is absent.
1907
+ const scanType = fs.existsSync(envDir) ? "env" : "vault";
1908
+ const root = scanType === "env" ? envDir : legacyDir;
1909
+ if (!fs.existsSync(root))
2068
1910
  continue;
2069
1911
  const walk = (dir) => {
2070
1912
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -2077,196 +1919,730 @@ function listVaultsRecursive(listKeysFn) {
2077
1919
  continue;
2078
1920
  if (entry.name !== ".env" && !entry.name.endsWith(".env"))
2079
1921
  continue;
2080
- const canonical = deriveCanonicalAssetName("vault", vaultsDir, full);
1922
+ const canonical = deriveCanonicalAssetName(scanType, root, full);
2081
1923
  if (!canonical)
2082
1924
  continue;
1925
+ // Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
1926
+ const markerPath = full.replace(/\.env$/, ".sensitive");
1927
+ if (fs.existsSync(markerPath))
1928
+ continue;
2083
1929
  const { keys } = listKeysFn(full);
2084
- result.push({ ref: makeVaultRef(canonical, source), path: full, keys });
1930
+ result.push({ ref: makeEnvRef(canonical, source), path: full, keys });
2085
1931
  }
2086
1932
  };
2087
- walk(vaultsDir);
1933
+ walk(root);
2088
1934
  }
2089
1935
  return result;
2090
1936
  }
2091
- function splitVaultRunTarget(target) {
2092
- const full = resolveVaultPath(target);
2093
- if (fs.existsSync(full.absPath)) {
2094
- return { ref: makeVaultRef(full.name, full.source) };
2095
- }
2096
- const slashIndex = target.lastIndexOf("/");
2097
- if (slashIndex <= 0) {
2098
- throw new NotFoundError(`Vault not found: ${target.includes(":") ? target : `vault:${target}`}`);
2099
- }
2100
- const refPart = target.slice(0, slashIndex);
2101
- const key = target.slice(slashIndex + 1).trim();
2102
- if (!key) {
2103
- throw new UsageError("Expected vault run target in the form <ref> or <ref/KEY>.");
2104
- }
2105
- const resolved = resolveVaultPath(refPart);
2106
- if (!fs.existsSync(resolved.absPath)) {
2107
- throw new NotFoundError(`Vault not found: ${makeVaultRef(resolved.name, resolved.source)}`);
2108
- }
2109
- return { ref: makeVaultRef(resolved.name, resolved.source), key };
2110
- }
2111
- const vaultListCommand = defineCommand({
2112
- meta: { name: "list", description: "List all vaults across all stashes with their available key names (no values)" },
1937
+ const envListCommand = defineCommand({
1938
+ meta: { name: "list", description: "List all env files across all stashes with their key names (no values)" },
2113
1939
  run() {
2114
1940
  return runWithJsonErrors(async () => {
2115
- const { listKeys } = await import("./commands/vault.js");
2116
- const vaults = listVaultsRecursive(listKeys);
2117
- output("vault-list", { vaults });
2118
- });
2119
- },
2120
- });
2121
- const vaultCreateCommand = defineCommand({
2122
- meta: { name: "create", description: "Create an empty vault file (no-op if it already exists)" },
2123
- args: {
2124
- name: { type: "positional", description: "Vault name (e.g. prod) — file becomes <name>.env", required: true },
2125
- },
2126
- run({ args }) {
2127
- return runWithJsonErrors(async () => {
2128
- const { createVault } = await import("./commands/vault.js");
2129
- const { name, absPath, source } = resolveVaultPath(args.name);
2130
- createVault(absPath);
2131
- output("vault-create", { ref: makeVaultRef(name, source), path: absPath });
1941
+ const { listKeys } = await import("./commands/env.js");
1942
+ output("env-list", { envs: listEnvsRecursive(listKeys) });
2132
1943
  });
2133
1944
  },
2134
1945
  });
2135
- const vaultSetCommand = defineCommand({
1946
+ const envCreateCommand = defineCommand({
2136
1947
  meta: {
2137
- name: "set",
2138
- description: 'Set a key in a vault. Value is written to disk and never echoed back. Accepts KEY=VALUE combined form or separate KEY VALUE args. Optionally attach a comment with --comment "description".',
1948
+ name: "create",
1949
+ 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.",
2139
1950
  },
2140
1951
  args: {
2141
- ref: { type: "positional", description: "Vault ref (e.g. vault:prod or just prod)", required: true },
2142
- key: { type: "positional", description: "Key name (e.g. DB_URL) or KEY=VALUE combined form", required: true },
2143
- value: {
2144
- type: "positional",
2145
- description: "Value to store (omit when using KEY=VALUE combined form)",
2146
- required: false,
1952
+ name: { type: "positional", description: "Env name (e.g. prod) file becomes <name>.env", required: true },
1953
+ "from-file": { type: "string", description: "Seed the env file from an existing .env at this path" },
1954
+ "from-stdin": { type: "boolean", description: "Seed the env file from stdin", default: false },
1955
+ sensitive: {
1956
+ type: "boolean",
1957
+ description: "Exclude this env file from env list output and the search index",
1958
+ default: false,
2147
1959
  },
2148
- comment: { type: "string", description: "Optional comment written above the key line", required: false },
2149
1960
  },
2150
1961
  run({ args }) {
2151
1962
  return runWithJsonErrors(async () => {
2152
- const { setKey } = await import("./commands/vault.js");
2153
- const { name, absPath, source } = resolveVaultPath(args.ref);
2154
- let realKey;
2155
- let realValue;
2156
- if ((args.value === undefined || args.value === "") && args.key.includes("=")) {
2157
- const eqIdx = args.key.indexOf("=");
2158
- realKey = args.key.slice(0, eqIdx);
2159
- realValue = args.key.slice(eqIdx + 1);
1963
+ const { createEnv, writeEnv } = await import("./commands/env.js");
1964
+ // `create` always targets env/, never the frozen vaults/ copy.
1965
+ const parsed = parseEnvRef(args.name);
1966
+ const source = findEnvSource(parsed.origin);
1967
+ const envRoot = path.join(source.path, "env");
1968
+ const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
1969
+ if (!isWithin(absPath, envRoot)) {
1970
+ throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
1971
+ }
1972
+ const fromFile = getHyphenatedArg(args, "from-file");
1973
+ const fromStdin = getHyphenatedArg(args, "from-stdin") === true;
1974
+ if (fromFile !== undefined && fromStdin) {
1975
+ throw new UsageError("Pass only one of --from-file or --from-stdin.", "INVALID_FLAG_VALUE");
1976
+ }
1977
+ if (fromFile !== undefined || fromStdin) {
1978
+ // Ingest path: never silently clobber an existing env file.
1979
+ if (fs.existsSync(absPath)) {
1980
+ throw new UsageError(`Env "${makeEnvRef(parsed.name, source)}" already exists. Remove it first (\`akm env remove\`) or edit the file directly.`, "RESOURCE_ALREADY_EXISTS");
1981
+ }
1982
+ let content;
1983
+ if (fromFile !== undefined) {
1984
+ if (!fs.existsSync(fromFile)) {
1985
+ throw new NotFoundError(`Source file not found: ${fromFile}`, "FILE_NOT_FOUND");
1986
+ }
1987
+ content = fs.readFileSync(fromFile, "utf8");
1988
+ }
1989
+ else {
1990
+ const MAX_ENV_BYTES = 1024 * 1024; // 1 MB
1991
+ let total = 0;
1992
+ const chunks = [];
1993
+ for await (const chunk of Bun.stdin.stream()) {
1994
+ total += chunk.byteLength;
1995
+ if (total > MAX_ENV_BYTES) {
1996
+ throw new UsageError("Env file exceeds 1 MB limit.", "INVALID_FLAG_VALUE");
1997
+ }
1998
+ chunks.push(chunk);
1999
+ }
2000
+ content = Buffer.concat(chunks).toString("utf8");
2001
+ }
2002
+ writeEnv(absPath, content);
2160
2003
  }
2161
2004
  else {
2162
- realKey = args.key;
2163
- realValue = args.value ?? "";
2005
+ createEnv(absPath);
2164
2006
  }
2165
- setKey(absPath, realKey, realValue, args.comment);
2166
- output("vault-set", { ref: makeVaultRef(name, source), key: realKey, path: absPath });
2007
+ if (args.sensitive) {
2008
+ const markerPath = absPath.replace(/\.env$/, ".sensitive");
2009
+ if (!fs.existsSync(markerPath)) {
2010
+ fs.writeFileSync(markerPath, "", { mode: 0o600 });
2011
+ }
2012
+ }
2013
+ output("env-create", { ref: makeEnvRef(parsed.name, source) });
2167
2014
  });
2168
2015
  },
2169
2016
  });
2170
- const vaultUnsetCommand = defineCommand({
2171
- meta: { name: "unset", description: "Remove a key from a vault" },
2017
+ const envPathCommand = defineCommand({
2018
+ meta: {
2019
+ name: "path",
2020
+ 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.",
2021
+ },
2172
2022
  args: {
2173
- ref: { type: "positional", description: "Vault ref", required: true },
2174
- key: { type: "positional", description: "Key name to remove", required: true },
2023
+ ref: { type: "positional", description: "Env ref", required: true },
2024
+ quiet: { type: "boolean", alias: "q", description: "Suppress the unsafe-source warning", default: false },
2175
2025
  },
2176
2026
  run({ args }) {
2177
2027
  return runWithJsonErrors(async () => {
2178
- const { unsetKey } = await import("./commands/vault.js");
2179
- const { name, absPath, source } = resolveVaultPath(args.ref);
2028
+ const { name, absPath, source } = resolveEnvPath(args.ref);
2180
2029
  if (!fs.existsSync(absPath)) {
2181
- throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
2030
+ throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2031
+ }
2032
+ // The raw `.env` may contain `X=$(cmd)`, which executes if `source`d.
2033
+ // Warning goes to stderr (never contaminates the path on stdout) and is
2034
+ // suppressed with --quiet for the legitimate `_FILE` / `--env-file` use.
2035
+ if (args.quiet !== true) {
2036
+ process.stderr.write(`warning: this is the raw file path. Do NOT \`source\` it (shell substitutions in the file would execute).\n` +
2037
+ ` To inject values run: akm env run ${args.ref} -- <command>\n`);
2038
+ }
2039
+ process.stdout.write(`${absPath}\n`);
2040
+ });
2041
+ },
2042
+ });
2043
+ const envExportCommand = defineCommand({
2044
+ meta: {
2045
+ name: "export",
2046
+ 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>`.",
2047
+ },
2048
+ args: {
2049
+ ref: { type: "positional", description: "Env ref", required: true },
2050
+ out: { type: "string", alias: "o", description: "Destination file (required). Written at mode 0600." },
2051
+ },
2052
+ run({ args }) {
2053
+ return runWithJsonErrors(async () => {
2054
+ const outPath = getHyphenatedArg(args, "out");
2055
+ if (!outPath) {
2056
+ throw new UsageError("`akm env export` writes to a file — pass --out <path>.\n" +
2057
+ " To use values directly, run `akm env run <ref> -- <command>` (or `-- $SHELL` for an interactive\n" +
2058
+ " session). export never prints values to stdout, to avoid leaking them into a captured context.", "MISSING_REQUIRED_ARGUMENT");
2059
+ }
2060
+ const { name, absPath, source } = resolveEnvPath(args.ref);
2061
+ if (!fs.existsSync(absPath)) {
2062
+ throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2063
+ }
2064
+ const { buildShellExportScript } = await import("./commands/env.js");
2065
+ const resolvedOut = path.resolve(outPath);
2066
+ writeFileAtomic(resolvedOut, buildShellExportScript(absPath), 0o600);
2067
+ output("env-export", { ref: makeEnvRef(name, source), out: resolvedOut });
2068
+ });
2069
+ },
2070
+ });
2071
+ /**
2072
+ * Shared implementation for `env run` (and the deprecated `vault run` shim).
2073
+ * Injects an entire env file's values into the child process env — never via a
2074
+ * shell — after scanning the injected keys for process-hijacking variables.
2075
+ */
2076
+ async function runEnvInjected(target, opts) {
2077
+ const dashIndex = process.argv.indexOf("--");
2078
+ if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2079
+ throw new UsageError("Missing command. Usage: akm env run <ref> -- <command>");
2080
+ }
2081
+ const command = process.argv.slice(dashIndex + 1);
2082
+ const { name, absPath, source } = resolveEnvPath(target);
2083
+ if (!fs.existsSync(absPath)) {
2084
+ // Help users who reach for the removed single-key `ref/KEY` form.
2085
+ const slash = target.lastIndexOf("/");
2086
+ if (slash > 0) {
2087
+ const maybeKey = target.slice(slash + 1);
2088
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(maybeKey)) {
2089
+ let baseExists = false;
2090
+ try {
2091
+ baseExists = fs.existsSync(resolveEnvPath(target.slice(0, slash)).absPath);
2092
+ }
2093
+ catch {
2094
+ baseExists = false;
2095
+ }
2096
+ if (baseExists) {
2097
+ throw new UsageError(`'akm env run' injects the whole file; the single-key '<ref>/${maybeKey}' form was removed.\n` +
2098
+ ` For one value use a secret: \`akm secret run secret:${maybeKey} ${maybeKey} -- <command>\`.`, "INVALID_FLAG_VALUE");
2099
+ }
2100
+ }
2101
+ }
2102
+ throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2103
+ }
2104
+ const { loadEnv } = await import("./commands/env.js");
2105
+ const allValues = loadEnv(absPath);
2106
+ // Value-safe key filtering (--only / --except operate on key NAMES only).
2107
+ let envValues = allValues;
2108
+ if (opts.only && opts.except) {
2109
+ throw new UsageError("Pass only one of --only or --except.", "INVALID_FLAG_VALUE");
2110
+ }
2111
+ if (opts.only) {
2112
+ const wanted = new Set(opts.only);
2113
+ const missing = opts.only.filter((k) => !(k in allValues));
2114
+ if (missing.length > 0) {
2115
+ process.stderr.write(`warning: --only key(s) not present in ${makeEnvRef(name, source)}: ${missing.join(", ")}\n`);
2116
+ }
2117
+ envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => wanted.has(k)));
2118
+ }
2119
+ else if (opts.except) {
2120
+ const excluded = new Set(opts.except);
2121
+ envValues = Object.fromEntries(Object.entries(allValues).filter(([k]) => !excluded.has(k)));
2122
+ }
2123
+ const keys = Object.keys(envValues);
2124
+ // Scan injected keys for known process-hijacking variables (LD_PRELOAD,
2125
+ // PATH, ...). Block for third-party-sourced stashes (origin has a registryId);
2126
+ // warn for the operator's own first-party stash, where they own the file.
2127
+ const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
2128
+ const dangerous = keys.filter(isDangerousEnvKey);
2129
+ if (dangerous.length > 0) {
2130
+ const detail = `Env "${makeEnvRef(name, source)}" injects process-hijacking variable(s): ${dangerous.join(", ")}.`;
2131
+ if (source.registryId) {
2132
+ throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
2133
+ ` Review the file, then copy the values into a first-party env if you trust them.`, "INVALID_FLAG_VALUE");
2134
+ }
2135
+ process.stderr.write(`warning: ${detail} Injecting anyway (first-party stash).\n`);
2136
+ }
2137
+ const mergedEnv = { ...process.env };
2138
+ for (const [envKey, envValue] of Object.entries(envValues)) {
2139
+ mergedEnv[envKey] = envValue;
2140
+ }
2141
+ // Audit trail: keys only, never values. A single `env_access` event carries a
2142
+ // `deprecatedAlias` marker when reached via the `vault run` shim, so log
2143
+ // consumers see one stable event type without a doubled physical record.
2144
+ appendEvent({
2145
+ eventType: "env_access",
2146
+ ref: makeEnvRef(name, source),
2147
+ metadata: opts.viaVault ? { keys, deprecatedAlias: "vault_access" } : { keys },
2148
+ });
2149
+ const result = spawnSync(command[0], command.slice(1), {
2150
+ stdio: "inherit",
2151
+ env: mergedEnv,
2152
+ });
2153
+ if (result.error) {
2154
+ // Classify spawn failures (#483). Raw ErrnoException leaks a bare
2155
+ // "spawn ENOENT" with no hint — wrap it so consumers get a usable
2156
+ // code + hint in the standard JSON envelope.
2157
+ const err = result.error;
2158
+ if (err.code === "ENOENT") {
2159
+ 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'.`);
2160
+ }
2161
+ if (err.code === "EACCES") {
2162
+ throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2163
+ }
2164
+ throw err;
2165
+ }
2166
+ process.exit(result.status ?? 0);
2167
+ }
2168
+ /** Parse a comma/space-separated key list flag into a trimmed, non-empty array. */
2169
+ function parseKeyListFlag(raw) {
2170
+ if (raw === undefined)
2171
+ return undefined;
2172
+ const keys = raw
2173
+ .split(/[,\s]+/)
2174
+ .map((k) => k.trim())
2175
+ .filter(Boolean);
2176
+ return keys.length > 0 ? keys : undefined;
2177
+ }
2178
+ const envRunCommand = defineCommand({
2179
+ meta: {
2180
+ name: "run",
2181
+ 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.",
2182
+ },
2183
+ args: {
2184
+ target: { type: "positional", description: "Env ref", required: true },
2185
+ only: {
2186
+ type: "string",
2187
+ description: "Inject ONLY these keys (comma-separated). Mutually exclusive with --except.",
2188
+ },
2189
+ except: { type: "string", description: "Inject all keys EXCEPT these (comma-separated)." },
2190
+ },
2191
+ run({ args }) {
2192
+ return runWithJsonErrors(() => runEnvInjected(args.target, {
2193
+ viaVault: false,
2194
+ only: parseKeyListFlag(getHyphenatedArg(args, "only")),
2195
+ except: parseKeyListFlag(getHyphenatedArg(args, "except")),
2196
+ }));
2197
+ },
2198
+ });
2199
+ const envRemoveCommand = defineCommand({
2200
+ meta: { name: "remove", description: "Remove an env file (and its .sensitive marker, if any)" },
2201
+ args: {
2202
+ ref: { type: "positional", description: "Env ref", required: true },
2203
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2204
+ },
2205
+ run({ args }) {
2206
+ return runWithJsonErrors(async () => {
2207
+ // Resolve against env/ specifically — never delete the frozen vaults/ copy.
2208
+ const parsed = parseEnvRef(args.ref);
2209
+ const source = findEnvSource(parsed.origin);
2210
+ const envRoot = path.join(source.path, "env");
2211
+ const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
2212
+ if (!isWithin(absPath, envRoot)) {
2213
+ throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
2214
+ }
2215
+ const { confirmDestructive } = await import("./cli/confirm.js");
2216
+ const confirmed = await confirmDestructive(`Remove env "${args.ref}"? This cannot be undone.`, {
2217
+ yes: args.yes === true,
2218
+ });
2219
+ if (!confirmed) {
2220
+ process.stderr.write("Aborted.\n");
2221
+ return;
2222
+ }
2223
+ if (!fs.existsSync(absPath)) {
2224
+ throw new NotFoundError(`Env not found: ${makeEnvRef(parsed.name, source)}`);
2225
+ }
2226
+ const { removeEnv } = await import("./commands/env.js");
2227
+ const removed = removeEnv(absPath);
2228
+ output("env-remove", { ref: makeEnvRef(parsed.name, source), removed });
2229
+ });
2230
+ },
2231
+ });
2232
+ const envCommand = defineCommand({
2233
+ meta: {
2234
+ name: "env",
2235
+ 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`.",
2236
+ },
2237
+ subCommands: {
2238
+ list: envListCommand,
2239
+ path: envPathCommand,
2240
+ export: envExportCommand,
2241
+ run: envRunCommand,
2242
+ create: envCreateCommand,
2243
+ remove: envRemoveCommand,
2244
+ },
2245
+ run({ args }) {
2246
+ return runWithJsonErrors(async () => {
2247
+ if (hasSubcommand(args, ENV_SUBCOMMAND_SET))
2248
+ return;
2249
+ const { listKeys } = await import("./commands/env.js");
2250
+ output("env-list", { envs: listEnvsRecursive(listKeys) });
2251
+ });
2252
+ },
2253
+ });
2254
+ // ── vault (DEPRECATED) ────────────────────────────────────────────────────────
2255
+ //
2256
+ // `akm vault` is deprecated in 0.8.0 and removed in 0.9.0. The verb now warns
2257
+ // to stderr and delegates to the `env` handlers. Entry management (`set` /
2258
+ // `unset`) and the single-key `run <ref>/KEY` form are hard-errors with a
2259
+ // signpost to `akm secret` — silent behaviour changes around secret material
2260
+ // are unacceptable.
2261
+ function emitVaultDeprecation(sub) {
2262
+ process.stderr.write(`warning: 'akm vault ${sub}' is deprecated and will be removed in 0.9.0. Use 'akm env ${sub}'.\n` +
2263
+ " For single-value injection use 'akm secret'.\n");
2264
+ }
2265
+ function emitFlagDeprecation(oldFlag, newFlag, cmd) {
2266
+ if (isQuiet())
2267
+ return;
2268
+ process.stderr.write(`warning: '${oldFlag}' is deprecated for 'akm ${cmd}'; use '${newFlag}'. Removed in 0.9.0.\n`);
2269
+ }
2270
+ /**
2271
+ * Emit a stderr deprecation warning for a renamed top-level command. The old
2272
+ * spelling keeps working in 0.8 (wrap-and-delegate) and is removed in 0.9.0.
2273
+ * Suppressed under --quiet; never written to stdout so JSON consumers are
2274
+ * unaffected.
2275
+ */
2276
+ function emitCommandDeprecation(oldCmd, newCmd) {
2277
+ if (isQuiet())
2278
+ return;
2279
+ process.stderr.write(`warning: 'akm ${oldCmd}' is deprecated and will be removed in 0.9.0. Use 'akm ${newCmd}'.\n`);
2280
+ }
2281
+ const vaultSetCommand = defineCommand({
2282
+ meta: { name: "set", description: "DEPRECATED — removed. Edit the .env file directly, or use `akm secret set`." },
2283
+ args: {
2284
+ ref: { type: "positional", description: "(deprecated)", required: false },
2285
+ key: { type: "positional", description: "(deprecated)", required: false },
2286
+ },
2287
+ run() {
2288
+ return runWithJsonErrors(async () => {
2289
+ throw new UsageError("'akm vault set' was removed: akm no longer manages individual env entries.\n" +
2290
+ " Edit the .env file directly (then run with `akm env run <ref> -- <cmd>`),\n" +
2291
+ " or store a single value as a secret: `akm secret set secret:<name>`.", "INVALID_FLAG_VALUE");
2292
+ });
2293
+ },
2294
+ });
2295
+ const vaultUnsetCommand = defineCommand({
2296
+ meta: { name: "unset", description: "DEPRECATED — removed. Edit the .env file directly." },
2297
+ args: {
2298
+ ref: { type: "positional", description: "(deprecated)", required: false },
2299
+ key: { type: "positional", description: "(deprecated)", required: false },
2300
+ },
2301
+ run() {
2302
+ return runWithJsonErrors(async () => {
2303
+ throw new UsageError("'akm vault unset' was removed: akm no longer manages individual env entries.\n" +
2304
+ " Edit the .env file directly, or remove a secret with `akm secret remove secret:<name>`.", "INVALID_FLAG_VALUE");
2305
+ });
2306
+ },
2307
+ });
2308
+ const vaultListCommand = defineCommand({
2309
+ meta: { name: "list", description: "DEPRECATED — use `akm env list`." },
2310
+ run() {
2311
+ return runWithJsonErrors(async () => {
2312
+ emitVaultDeprecation("list");
2313
+ const { listKeys } = await import("./commands/env.js");
2314
+ output("env-list", { envs: listEnvsRecursive(listKeys) });
2315
+ });
2316
+ },
2317
+ });
2318
+ const vaultCreateCommand = defineCommand({
2319
+ meta: { name: "create", description: "DEPRECATED — use `akm env create`." },
2320
+ args: {
2321
+ name: { type: "positional", description: "Env name", required: true },
2322
+ sensitive: { type: "boolean", description: "Exclude from list output and the search index", default: false },
2323
+ },
2324
+ run({ args }) {
2325
+ return runWithJsonErrors(async () => {
2326
+ emitVaultDeprecation("create");
2327
+ const { createEnv } = await import("./commands/env.js");
2328
+ const parsed = parseEnvRef(args.name);
2329
+ const source = findEnvSource(parsed.origin);
2330
+ const envRoot = path.join(source.path, "env");
2331
+ const absPath = resolveAssetPathFromName("env", envRoot, parsed.name);
2332
+ if (!isWithin(absPath, envRoot)) {
2333
+ throw new UsageError(`Env name "${parsed.name}" escapes the env directory.`);
2334
+ }
2335
+ createEnv(absPath);
2336
+ if (args.sensitive) {
2337
+ const markerPath = absPath.replace(/\.env$/, ".sensitive");
2338
+ if (!fs.existsSync(markerPath))
2339
+ fs.writeFileSync(markerPath, "", { mode: 0o600 });
2182
2340
  }
2183
- const removed = unsetKey(absPath, args.key);
2184
- output("vault-unset", { ref: makeVaultRef(name, source), key: args.key, removed, path: absPath });
2341
+ output("env-create", { ref: makeEnvRef(parsed.name, source) });
2185
2342
  });
2186
2343
  },
2187
2344
  });
2188
2345
  const vaultPathCommand = defineCommand({
2346
+ meta: { name: "path", description: "DEPRECATED — use `akm env path`." },
2347
+ args: {
2348
+ ref: { type: "positional", description: "Env ref", required: true },
2349
+ },
2350
+ run({ args }) {
2351
+ return runWithJsonErrors(async () => {
2352
+ emitVaultDeprecation("path");
2353
+ const { name, absPath, source } = resolveEnvPath(args.ref);
2354
+ if (!fs.existsSync(absPath)) {
2355
+ throw new NotFoundError(`Env not found: ${makeEnvRef(name, source)}`);
2356
+ }
2357
+ process.stderr.write(`warning: sourcing the raw file executes shell substitutions it contains. Use: akm env run ${args.ref} -- <command>\n`);
2358
+ process.stdout.write(`${absPath}\n`);
2359
+ });
2360
+ },
2361
+ });
2362
+ const vaultRunCommand = defineCommand({
2363
+ meta: { name: "run", description: "DEPRECATED — use `akm env run`. The single-key `<ref>/KEY` form was removed." },
2364
+ args: {
2365
+ target: { type: "positional", description: "Env ref", required: true },
2366
+ },
2367
+ run({ args }) {
2368
+ return runWithJsonErrors(async () => {
2369
+ emitVaultDeprecation("run");
2370
+ await runEnvInjected(args.target, { viaVault: true });
2371
+ });
2372
+ },
2373
+ });
2374
+ const vaultCommand = defineCommand({
2375
+ meta: {
2376
+ name: "vault",
2377
+ description: "DEPRECATED (use `akm env`) — removed in 0.9.0. Manages whole `.env` files; values never printed.",
2378
+ },
2379
+ subCommands: {
2380
+ list: vaultListCommand,
2381
+ path: vaultPathCommand,
2382
+ run: vaultRunCommand,
2383
+ create: vaultCreateCommand,
2384
+ set: vaultSetCommand,
2385
+ unset: vaultUnsetCommand,
2386
+ },
2387
+ run({ args }) {
2388
+ return runWithJsonErrors(async () => {
2389
+ if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
2390
+ return;
2391
+ emitVaultDeprecation("list");
2392
+ const { listKeys } = await import("./commands/env.js");
2393
+ output("env-list", { envs: listEnvsRecursive(listKeys) });
2394
+ });
2395
+ },
2396
+ });
2397
+ // ── secret ──────────────────────────────────────────────────────────────────
2398
+ //
2399
+ // `akm secret` manages whole-file secrets under each stash's secrets/ directory.
2400
+ // Unlike vaults (.env key/value), the ENTIRE file is the secret value. The bytes
2401
+ // are NEVER written to stdout or structured output. Values reach a command only
2402
+ // via `akm secret run` (injected into a child env var) or `akm secret path`
2403
+ // (the Docker /run/secrets + `_FILE` convention).
2404
+ function parseSecretRef(ref) {
2405
+ return parseAssetRef(ref.includes(":") ? ref : `secret:${ref}`);
2406
+ }
2407
+ function makeSecretRef(name, source) {
2408
+ return source?.registryId ? `${source.registryId}//secret:${name}` : `secret:${name}`;
2409
+ }
2410
+ function resolveSecretPath(ref) {
2411
+ const parsed = parseSecretRef(ref);
2412
+ if (parsed.type !== "secret") {
2413
+ throw new UsageError(`Expected a secret ref (secret:<name>); got "${ref}".`);
2414
+ }
2415
+ // Source resolution is identical for every asset type; reuse the env helper.
2416
+ const source = findEnvSource(parsed.origin);
2417
+ const typeRoot = path.join(source.path, "secrets");
2418
+ const absPath = resolveAssetPathFromName("secret", typeRoot, parsed.name);
2419
+ // Defense-in-depth: ensure the resolved path stays inside the secrets dir.
2420
+ if (!isWithin(absPath, typeRoot)) {
2421
+ throw new UsageError(`Secret name "${parsed.name}" escapes the secrets directory.`);
2422
+ }
2423
+ return { name: parsed.name, absPath, source };
2424
+ }
2425
+ /** Walk `secrets/` across all stashes, returning one entry per secret file. */
2426
+ function listSecretsRecursive() {
2427
+ const result = [];
2428
+ for (const source of resolveSourceEntries(undefined, loadConfig())) {
2429
+ const secretsDir = path.join(source.path, "secrets");
2430
+ if (!fs.existsSync(secretsDir))
2431
+ continue;
2432
+ const walk = (dir) => {
2433
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
2434
+ const full = path.join(dir, entry.name);
2435
+ if (entry.isDirectory()) {
2436
+ walk(full);
2437
+ continue;
2438
+ }
2439
+ if (!entry.isFile())
2440
+ continue;
2441
+ if (entry.name.endsWith(".lock") || entry.name.endsWith(".sensitive"))
2442
+ continue;
2443
+ // A sibling `<name>.sensitive` marker suppresses listing.
2444
+ if (fs.existsSync(`${full}.sensitive`))
2445
+ continue;
2446
+ const canonical = deriveCanonicalAssetName("secret", secretsDir, full);
2447
+ if (!canonical)
2448
+ continue;
2449
+ result.push({ ref: makeSecretRef(canonical, source), path: full });
2450
+ }
2451
+ };
2452
+ walk(secretsDir);
2453
+ }
2454
+ return result;
2455
+ }
2456
+ const secretListCommand = defineCommand({
2457
+ meta: {
2458
+ name: "list",
2459
+ description: "List all secrets across all stashes by name (the file contents are never shown)",
2460
+ },
2461
+ run() {
2462
+ return runWithJsonErrors(async () => {
2463
+ output("secret-list", { secrets: listSecretsRecursive() });
2464
+ });
2465
+ },
2466
+ });
2467
+ const secretSetCommand = defineCommand({
2468
+ meta: {
2469
+ name: "set",
2470
+ 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.",
2471
+ },
2472
+ args: {
2473
+ ref: { type: "positional", description: "Secret ref (e.g. secret:deploy-key or just deploy-key)", required: true },
2474
+ "from-file": { type: "string", description: "Read the value from this file (stored byte-exact)" },
2475
+ "from-env": { type: "string", description: "Read the value from the named environment variable" },
2476
+ },
2477
+ run({ args }) {
2478
+ return runWithJsonErrors(async () => {
2479
+ const { setSecret } = await import("./commands/secret.js");
2480
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2481
+ const fromEnv = getHyphenatedArg(args, "from-env");
2482
+ const fromFile = getHyphenatedArg(args, "from-file");
2483
+ if (fromEnv !== undefined && fromFile !== undefined) {
2484
+ throw new UsageError("Pass only one of --from-file or --from-env (or use stdin).", "INVALID_FLAG_VALUE");
2485
+ }
2486
+ const MAX_SECRET_BYTES = 5 * 1024 * 1024; // 5 MB
2487
+ let value;
2488
+ if (fromFile !== undefined) {
2489
+ if (!fs.existsSync(fromFile)) {
2490
+ throw new NotFoundError(`File not found: ${fromFile}`, "FILE_NOT_FOUND");
2491
+ }
2492
+ value = fs.readFileSync(fromFile);
2493
+ if (value.byteLength > MAX_SECRET_BYTES) {
2494
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2495
+ }
2496
+ }
2497
+ else if (fromEnv !== undefined) {
2498
+ const envVal = process.env[fromEnv];
2499
+ if (envVal === undefined) {
2500
+ throw new UsageError(`Environment variable "${fromEnv}" is not set.`, "INVALID_FLAG_VALUE");
2501
+ }
2502
+ value = Buffer.from(envVal, "utf8");
2503
+ }
2504
+ else {
2505
+ if (process.stdin.isTTY) {
2506
+ process.stderr.write(`Enter value for secret "${name}" (Ctrl-D when done):\n`);
2507
+ }
2508
+ let totalBytes = 0;
2509
+ const chunks = [];
2510
+ for await (const chunk of Bun.stdin.stream()) {
2511
+ totalBytes += chunk.byteLength;
2512
+ if (totalBytes > MAX_SECRET_BYTES) {
2513
+ throw new UsageError("Secret exceeds the 5 MB limit.");
2514
+ }
2515
+ chunks.push(chunk);
2516
+ }
2517
+ // Strip a single trailing newline so `echo "$TOKEN" | akm secret set`
2518
+ // stores the token without the shell-added newline. Use --from-file for
2519
+ // byte-exact storage of multi-line material (PEM keys, certs).
2520
+ value = Buffer.from(Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""), "utf8");
2521
+ }
2522
+ setSecret(absPath, value);
2523
+ output("secret-set", { ref: makeSecretRef(name, source) });
2524
+ });
2525
+ },
2526
+ });
2527
+ const secretPathCommand = defineCommand({
2189
2528
  meta: {
2190
2529
  name: "path",
2191
- description: 'Print the absolute vault file path so you can load it directly, e.g. `source "$(akm vault path vault:prod)"`.',
2530
+ description: "Print the absolute secret file path for the Docker `_FILE` convention, e.g. `MY_SECRET_FILE=$(akm secret path secret:deploy-key)`.",
2192
2531
  },
2193
2532
  args: {
2194
- ref: { type: "positional", description: "Vault ref", required: true },
2533
+ ref: { type: "positional", description: "Secret ref", required: true },
2195
2534
  },
2196
2535
  run({ args }) {
2197
2536
  return runWithJsonErrors(async () => {
2198
- const { name, absPath, source } = resolveVaultPath(args.ref);
2537
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2199
2538
  if (!fs.existsSync(absPath)) {
2200
- throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
2539
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2201
2540
  }
2202
2541
  process.stdout.write(`${absPath}\n`);
2203
2542
  });
2204
2543
  },
2205
2544
  });
2206
- const vaultRunCommand = defineCommand({
2545
+ const secretRunCommand = defineCommand({
2207
2546
  meta: {
2208
2547
  name: "run",
2209
- description: "Run a command with env injected from a vault or a single vault key: `akm vault run <ref[/KEY]> -- <command>`",
2548
+ 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.",
2210
2549
  },
2211
2550
  args: {
2212
- target: { type: "positional", description: "Vault ref or ref/key target", required: true },
2551
+ ref: { type: "positional", description: "Secret ref", required: true },
2552
+ var: { type: "positional", description: "Environment variable name to inject the value into", required: true },
2213
2553
  },
2214
2554
  run({ args }) {
2215
2555
  return runWithJsonErrors(async () => {
2556
+ // Validate the target env var name FIRST (before the command split) so a
2557
+ // dangerous/invalid name is rejected regardless of how the command is
2558
+ // supplied — and so the failure does not depend on argv parsing.
2559
+ const varName = args.var;
2560
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
2561
+ throw new UsageError(`"${varName}" is not a valid environment variable name.`, "INVALID_FLAG_VALUE");
2562
+ }
2563
+ const { isDangerousEnvKey } = await import("./commands/lint/env-key-rules.js");
2564
+ if (isDangerousEnvKey(varName)) {
2565
+ 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");
2566
+ }
2216
2567
  const dashIndex = process.argv.indexOf("--");
2217
2568
  if (dashIndex < 0 || dashIndex === process.argv.length - 1) {
2218
- throw new UsageError("Missing command. Usage: akm vault run <ref[/KEY]> -- <command>");
2569
+ throw new UsageError("Missing command. Usage: akm secret run <ref> <VAR> -- <command>");
2219
2570
  }
2220
2571
  const command = process.argv.slice(dashIndex + 1);
2221
- const { loadEnv } = await import("./commands/vault.js");
2222
- const { ref, key } = splitVaultRunTarget(args.target);
2223
- const { name, absPath, source } = resolveVaultPath(ref);
2572
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2224
2573
  if (!fs.existsSync(absPath)) {
2225
- throw new NotFoundError(`Vault not found: ${makeVaultRef(name, source)}`);
2574
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2226
2575
  }
2227
- const envValues = loadEnv(absPath);
2576
+ const { readValue } = await import("./commands/secret.js");
2228
2577
  const mergedEnv = { ...process.env };
2229
- if (key) {
2230
- if (!(key in envValues)) {
2231
- throw new NotFoundError(`Key not found in ${makeVaultRef(name, source)}: ${key}`);
2232
- }
2233
- mergedEnv[key] = envValues[key];
2234
- }
2235
- else {
2236
- for (const [envKey, envValue] of Object.entries(envValues)) {
2237
- mergedEnv[envKey] = envValue;
2238
- }
2239
- }
2578
+ mergedEnv[varName] = readValue(absPath).toString("utf8");
2579
+ // Audit trail: record access by ref + var name only — never the value.
2580
+ appendEvent({
2581
+ eventType: "secret_access",
2582
+ ref: makeSecretRef(name, source),
2583
+ metadata: { var: varName },
2584
+ });
2240
2585
  const result = spawnSync(command[0], command.slice(1), {
2241
2586
  stdio: "inherit",
2242
2587
  env: mergedEnv,
2243
2588
  });
2244
- if (result.error)
2245
- throw result.error;
2589
+ if (result.error) {
2590
+ const err = result.error;
2591
+ if (err.code === "ENOENT") {
2592
+ 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'.`);
2593
+ }
2594
+ if (err.code === "EACCES") {
2595
+ throw new ConfigError(`Command not executable: ${command[0]}`, "STASH_DIR_UNREADABLE", `Add execute permission ('chmod +x ${command[0]}') or invoke via an interpreter.`);
2596
+ }
2597
+ throw err;
2598
+ }
2246
2599
  process.exit(result.status ?? 0);
2247
2600
  });
2248
2601
  },
2249
2602
  });
2250
- const vaultCommand = defineCommand({
2603
+ const secretRemoveCommand = defineCommand({
2604
+ meta: { name: "remove", description: "Remove a secret (and its .sensitive marker, if any)" },
2605
+ args: {
2606
+ ref: { type: "positional", description: "Secret ref", required: true },
2607
+ yes: { type: "boolean", alias: "y", description: "Skip confirmation prompt", default: false },
2608
+ },
2609
+ run({ args }) {
2610
+ return runWithJsonErrors(async () => {
2611
+ const { name, absPath, source } = resolveSecretPath(args.ref);
2612
+ const { confirmDestructive } = await import("./cli/confirm.js");
2613
+ const confirmed = await confirmDestructive(`Remove secret "${args.ref}"? This cannot be undone.`, {
2614
+ yes: args.yes === true,
2615
+ });
2616
+ if (!confirmed) {
2617
+ process.stderr.write("Aborted.\n");
2618
+ return;
2619
+ }
2620
+ const { removeSecret } = await import("./commands/secret.js");
2621
+ if (!fs.existsSync(absPath)) {
2622
+ throw new NotFoundError(`Secret not found: ${makeSecretRef(name, source)}`);
2623
+ }
2624
+ const removed = removeSecret(absPath);
2625
+ output("secret-remove", { ref: makeSecretRef(name, source), removed });
2626
+ });
2627
+ },
2628
+ });
2629
+ const secretCommand = defineCommand({
2251
2630
  meta: {
2252
- name: "vault",
2253
- description: "Manage secret vaults (.env files). Keys are visible, values stay on disk and never appear in structured output.",
2631
+ name: "secret",
2632
+ 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`.",
2254
2633
  },
2255
2634
  subCommands: {
2256
- list: vaultListCommand,
2257
- path: vaultPathCommand,
2258
- run: vaultRunCommand,
2259
- create: vaultCreateCommand,
2260
- set: vaultSetCommand,
2261
- unset: vaultUnsetCommand,
2635
+ list: secretListCommand,
2636
+ path: secretPathCommand,
2637
+ run: secretRunCommand,
2638
+ set: secretSetCommand,
2639
+ remove: secretRemoveCommand,
2262
2640
  },
2263
2641
  run({ args }) {
2264
2642
  return runWithJsonErrors(async () => {
2265
- if (hasSubcommand(args, VAULT_SUBCOMMAND_SET))
2643
+ if (hasSubcommand(args, SECRET_SUBCOMMAND_SET))
2266
2644
  return;
2267
- // Default action: list all vaults
2268
- const { listKeys } = await import("./commands/vault.js");
2269
- output("vault-list", { vaults: listVaultsRecursive(listKeys) });
2645
+ output("secret-list", { secrets: listSecretsRecursive() });
2270
2646
  });
2271
2647
  },
2272
2648
  });
@@ -2298,11 +2674,6 @@ const wikiRegisterCommand = defineCommand({
2298
2674
  description: "Mark a git-backed source as writable so changes can be pushed back",
2299
2675
  default: false,
2300
2676
  },
2301
- trust: {
2302
- type: "boolean",
2303
- description: "Bypass install-audit blocking for this registration only",
2304
- default: false,
2305
- },
2306
2677
  "max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
2307
2678
  "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
2308
2679
  },
@@ -2313,7 +2684,6 @@ const wikiRegisterCommand = defineCommand({
2313
2684
  ref: args.ref.trim(),
2314
2685
  name: args.name,
2315
2686
  options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
2316
- trustThisInstall: args.trust,
2317
2687
  writable: args.writable,
2318
2688
  });
2319
2689
  output("wiki-register", result);
@@ -2352,9 +2722,15 @@ const wikiRemoveCommand = defineCommand({
2352
2722
  },
2353
2723
  args: {
2354
2724
  name: { type: "positional", description: "Wiki name", required: true },
2725
+ yes: {
2726
+ type: "boolean",
2727
+ alias: "y",
2728
+ description: "Skip confirmation prompt (required in non-interactive shells)",
2729
+ default: false,
2730
+ },
2355
2731
  force: {
2356
2732
  type: "boolean",
2357
- description: "Remove without prompting (required in non-interactive shells)",
2733
+ description: "DEPRECATED use -y/--yes. Removed in 0.9.0.",
2358
2734
  default: false,
2359
2735
  },
2360
2736
  "with-sources": {
@@ -2365,8 +2741,16 @@ const wikiRemoveCommand = defineCommand({
2365
2741
  },
2366
2742
  run({ args }) {
2367
2743
  return runWithJsonErrors(async () => {
2368
- if (!args.force) {
2369
- throw new UsageError("Refusing to remove without --force. Pass `--force` to confirm.");
2744
+ if (args.yes !== true && args.force === true) {
2745
+ emitFlagDeprecation("--force", "-y/--yes", "wiki remove");
2746
+ }
2747
+ const { confirmDestructive } = await import("./cli/confirm.js");
2748
+ const confirmed = await confirmDestructive(`Remove wiki "${args.name}"? This cannot be undone.`, {
2749
+ yes: args.yes === true || args.force === true,
2750
+ });
2751
+ if (!confirmed) {
2752
+ process.stderr.write("Aborted.\n");
2753
+ return;
2370
2754
  }
2371
2755
  const withSources = getHyphenatedBoolean(args, "with-sources");
2372
2756
  const { removeWiki } = await import("./wiki/wiki.js");
@@ -2494,17 +2878,52 @@ const wikiLintCommand = defineCommand({
2494
2878
  const wikiIngestCommand = defineCommand({
2495
2879
  meta: {
2496
2880
  name: "ingest",
2497
- description: "Print the ingest workflow for this wiki. Does not perform the ingest; instructs the agent to.",
2881
+ description: "Dispatch an agent to execute the ingest workflow for this wiki. Uses --profile or config.defaults.agent.",
2498
2882
  },
2499
2883
  args: {
2500
2884
  name: { type: "positional", description: "Wiki name", required: true },
2885
+ profile: {
2886
+ type: "string",
2887
+ description: "Agent profile to use (default: config.defaults.agent).",
2888
+ },
2889
+ model: {
2890
+ type: "string",
2891
+ description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs.",
2892
+ },
2893
+ "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds." },
2501
2894
  },
2502
2895
  run({ args }) {
2503
2896
  return runWithJsonErrors(async () => {
2504
2897
  const { buildIngestWorkflow } = await import("./wiki/wiki.js");
2505
2898
  const stashDir = resolveStashDir();
2506
- const result = buildIngestWorkflow(stashDir, args.name);
2507
- output("wiki-ingest", result);
2899
+ const built = buildIngestWorkflow(stashDir, args.name);
2900
+ const config = loadConfig();
2901
+ const profileName = getStringArg(args, "profile") ?? config.defaults?.agent;
2902
+ if (!profileName) {
2903
+ 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.");
2904
+ }
2905
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
2906
+ const model = getStringArg(args, "model");
2907
+ const { getDefaultLlmConfig } = await import("./core/config.js");
2908
+ const dispatchResult = await akmAgentDispatch({
2909
+ profileName,
2910
+ agentConfig: config,
2911
+ llmConfig: getDefaultLlmConfig(config),
2912
+ prompt: built.workflow,
2913
+ dispatch: {
2914
+ prompt: built.workflow,
2915
+ ...(model !== undefined ? { model } : {}),
2916
+ },
2917
+ ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
2918
+ });
2919
+ output("wiki-ingest", {
2920
+ wiki: built.wiki,
2921
+ path: built.path,
2922
+ schemaPath: built.schemaPath,
2923
+ dispatched: true,
2924
+ profile: profileName,
2925
+ agentResult: dispatchResult,
2926
+ });
2508
2927
  });
2509
2928
  },
2510
2929
  });
@@ -2595,9 +3014,9 @@ const eventsTailCommand = defineCommand({
2595
3014
  },
2596
3015
  async run({ args }) {
2597
3016
  await runWithJsonErrors(async () => {
2598
- const intervalMs = parsePositiveInt(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
2599
- const maxDurationMs = parsePositiveInt(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
2600
- const maxEvents = parsePositiveInt(getHyphenatedArg(args, "max-events"), "--max-events");
3017
+ const intervalMs = parsePositiveIntFlag(getHyphenatedArg(args, "interval-ms"), "--interval-ms");
3018
+ const maxDurationMs = parsePositiveIntFlag(getHyphenatedArg(args, "max-duration-ms"), "--max-duration-ms");
3019
+ const maxEvents = parsePositiveIntFlag(getHyphenatedArg(args, "max-events"), "--max-events");
2601
3020
  const mode = getOutputMode();
2602
3021
  // In streaming text mode we want each event to print as soon as it
2603
3022
  // arrives. The polling loop emits via `onEvent`; the final result is
@@ -2632,121 +3051,631 @@ const eventsTailCommand = defineCommand({
2632
3051
  if (!stream) {
2633
3052
  output("events-tail", result);
2634
3053
  }
2635
- else if (mode.format === "jsonl") {
2636
- // Final discriminated trailer row so jsonl consumers can resume.
2637
- const trailer = {
2638
- _kind: "trailer",
2639
- schemaVersion: 1,
2640
- nextOffset: result.nextOffset,
2641
- totalCount: result.totalCount,
2642
- reason: result.reason,
2643
- };
2644
- console.log(JSON.stringify(trailer));
3054
+ else if (mode.format === "jsonl") {
3055
+ // Final discriminated trailer row so jsonl consumers can resume.
3056
+ const trailer = {
3057
+ _kind: "trailer",
3058
+ schemaVersion: 1,
3059
+ nextOffset: result.nextOffset,
3060
+ totalCount: result.totalCount,
3061
+ reason: result.reason,
3062
+ };
3063
+ console.log(JSON.stringify(trailer));
3064
+ }
3065
+ else {
3066
+ // text mode: keep stdout pristine for line-oriented parsers and
3067
+ // emit the trailer on stderr.
3068
+ process.stderr.write(`[events-tail] reason=${result.reason} nextOffset=${result.nextOffset} total=${result.totalCount}\n`);
3069
+ }
3070
+ });
3071
+ },
3072
+ });
3073
+ const eventsCommand = defineCommand({
3074
+ meta: {
3075
+ name: "events",
3076
+ alias: "log",
3077
+ description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
3078
+ },
3079
+ subCommands: {
3080
+ list: eventsListCommand,
3081
+ tail: eventsTailCommand,
3082
+ },
3083
+ });
3084
+ // ── lessons subcommands (Phase 7A / Advantage D4c) ──────────────────────────
3085
+ const lessonsCoverageCommand = defineCommand({
3086
+ meta: {
3087
+ name: "coverage",
3088
+ description: "Report tags that exist on indexed assets but are NOT yet covered by any lesson.\n\n" +
3089
+ "Useful for spotting topics where the stash has skills/commands/scripts but no\n" +
3090
+ "crystallized lesson — a signal that the team has tacit knowledge worth distilling.\n\n" +
3091
+ "Default output is JSON: { uncoveredTags: string[], lessonTagCount: number, totalTagCount: number }.\n" +
3092
+ "Pass --format text for a plain-text bulleted list.",
3093
+ },
3094
+ args: {},
3095
+ run() {
3096
+ return runWithJsonErrors(() => {
3097
+ const db = openExistingDatabase();
3098
+ try {
3099
+ const allTagSet = collectTagSetFromEntries(db, undefined);
3100
+ const lessonTagSet = collectTagSetFromEntries(db, "lesson");
3101
+ const uncovered = [];
3102
+ for (const tag of allTagSet) {
3103
+ if (!lessonTagSet.has(tag))
3104
+ uncovered.push(tag);
3105
+ }
3106
+ uncovered.sort((a, b) => a.localeCompare(b));
3107
+ output("lessons-coverage", {
3108
+ ok: true,
3109
+ uncoveredTags: uncovered,
3110
+ lessonTagCount: lessonTagSet.size,
3111
+ totalTagCount: allTagSet.size,
3112
+ });
3113
+ }
3114
+ finally {
3115
+ closeDatabase(db);
3116
+ }
3117
+ });
3118
+ },
3119
+ });
3120
+ /**
3121
+ * Walk indexed entries and collect a deduplicated set of tags. When
3122
+ * `entryType` is provided, only entries of that type contribute tags.
3123
+ *
3124
+ * Pure read; never mutates the DB. Used by `akm lessons coverage` (Phase 7A)
3125
+ * to compute the diff between all-asset tags and lesson tags.
3126
+ */
3127
+ function collectTagSetFromEntries(db, entryType) {
3128
+ const tags = new Set();
3129
+ const stmt = entryType
3130
+ ? db.prepare("SELECT entry_json FROM entries WHERE entry_type = ?")
3131
+ : db.prepare("SELECT entry_json FROM entries");
3132
+ const rows = (entryType ? stmt.all(entryType) : stmt.all());
3133
+ for (const row of rows) {
3134
+ let parsed;
3135
+ try {
3136
+ parsed = JSON.parse(row.entry_json);
3137
+ }
3138
+ catch {
3139
+ continue;
3140
+ }
3141
+ if (!Array.isArray(parsed.tags))
3142
+ continue;
3143
+ for (const tag of parsed.tags) {
3144
+ if (typeof tag === "string" && tag.trim().length > 0) {
3145
+ tags.add(tag.trim().toLowerCase());
3146
+ }
3147
+ }
3148
+ }
3149
+ return tags;
3150
+ }
3151
+ const lessonsCommand = defineCommand({
3152
+ meta: {
3153
+ name: "lessons",
3154
+ alias: "lesson",
3155
+ description: "Lesson-asset tooling: tag-coverage gaps, strength queries.",
3156
+ },
3157
+ subCommands: {
3158
+ coverage: lessonsCoverageCommand,
3159
+ },
3160
+ });
3161
+ // ── proposal substrate (#225) ────────────────────────────────────────────────
3162
+ const proposalListCommand = defineCommand({
3163
+ meta: { name: "list", description: "List proposal queue entries" },
3164
+ args: {
3165
+ status: {
3166
+ type: "string",
3167
+ description: "Filter by status (pending|accepted|rejected|reverted)",
3168
+ },
3169
+ ref: { type: "string", description: "Filter by asset ref (type:name)" },
3170
+ type: { type: "string", description: "Filter by asset type" },
3171
+ },
3172
+ run({ args }) {
3173
+ return runWithJsonErrors(() => {
3174
+ const status = parseProposalStatus(args.status);
3175
+ const result = akmProposalList({
3176
+ status,
3177
+ ref: args.ref,
3178
+ type: args.type,
3179
+ includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
3180
+ });
3181
+ output("proposal-list", result);
3182
+ });
3183
+ },
3184
+ });
3185
+ const proposalAcceptCommand = defineCommand({
3186
+ meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
3187
+ args: {
3188
+ id: {
3189
+ type: "positional",
3190
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
3191
+ required: false,
3192
+ },
3193
+ target: { type: "string", description: "Override the write target by source name" },
3194
+ // F-6 / #393: Batch accept by generator, diff size, or age.
3195
+ generator: {
3196
+ type: "string",
3197
+ description: "F-6: Bulk-accept all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
3198
+ },
3199
+ source: {
3200
+ type: "string",
3201
+ description: "DEPRECATED — use --generator. Removed in 0.9.0.",
3202
+ },
3203
+ "max-diff-lines": {
3204
+ type: "string",
3205
+ description: "F-6: When bulk-accepting, only accept proposals whose content is <= this many lines. Skips larger proposals.",
3206
+ },
3207
+ "older-than": {
3208
+ type: "string",
3209
+ description: "F-6: When bulk-accepting, only accept proposals created more than this many days ago (e.g. '7' for 7 days).",
3210
+ },
3211
+ "dry-run": {
3212
+ type: "boolean",
3213
+ description: "F-6: List proposals that would be bulk-accepted without accepting them.",
3214
+ default: false,
3215
+ },
3216
+ yes: {
3217
+ type: "boolean",
3218
+ alias: "y",
3219
+ description: "Skip confirmation prompt (required in non-interactive mode for bulk accept)",
3220
+ default: false,
3221
+ },
3222
+ },
3223
+ async run({ args }) {
3224
+ await runWithJsonErrors(async () => {
3225
+ if (args.generator === undefined && args.source !== undefined) {
3226
+ emitFlagDeprecation("--source", "--generator", "proposal accept");
3227
+ }
3228
+ const generator = (args.generator ?? args.source);
3229
+ // F-6 / #393: Bulk-accept when --generator is provided without a positional id.
3230
+ if (generator && !args.id) {
3231
+ const { confirmDestructive } = await import("./cli/confirm.js");
3232
+ const confirmed = await confirmDestructive(`Bulk-accept all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
3233
+ if (!confirmed) {
3234
+ process.stderr.write("Aborted.\n");
3235
+ return;
3236
+ }
3237
+ const { listProposals } = await import("./core/proposals");
3238
+ const stashDir = resolveStashDir();
3239
+ const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3240
+ if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3241
+ throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3242
+ }
3243
+ const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3244
+ if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3245
+ throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3246
+ }
3247
+ const maxDiffLines = rawMaxDiff;
3248
+ const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3249
+ const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3250
+ if (p.source !== generator)
3251
+ return false;
3252
+ if (maxDiffLines !== undefined) {
3253
+ const lines = (p.payload.content ?? "").split("\n").length;
3254
+ if (lines > maxDiffLines)
3255
+ return false;
3256
+ }
3257
+ if (olderThanMs !== undefined) {
3258
+ const age = Date.now() - new Date(p.createdAt).getTime();
3259
+ if (age < olderThanMs)
3260
+ return false;
3261
+ }
3262
+ return true;
3263
+ });
3264
+ const results = [];
3265
+ for (const proposal of pending) {
3266
+ if (args["dry-run"]) {
3267
+ results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3268
+ }
3269
+ else {
3270
+ const result = await akmProposalAccept({ id: proposal.id, target: args.target });
3271
+ results.push(result);
3272
+ }
3273
+ }
3274
+ output("proposal-accept-batch", { accepted: results.length, results, dryRun: args["dry-run"] });
3275
+ return;
3276
+ }
3277
+ if (!args.id) {
3278
+ throw new UsageError("Usage: akm proposal accept <id> OR akm proposal accept --generator <generator>", "MISSING_REQUIRED_ARGUMENT");
3279
+ }
3280
+ const result = await akmProposalAccept({ id: args.id, target: args.target });
3281
+ output("proposal-accept", result);
3282
+ });
3283
+ },
3284
+ });
3285
+ const proposalRejectCommand = defineCommand({
3286
+ meta: { name: "reject", description: "Reject a proposal and record the reason" },
3287
+ args: {
3288
+ id: {
3289
+ type: "positional",
3290
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream). Optional when --generator is provided.",
3291
+ required: false,
3292
+ },
3293
+ reason: { type: "string", description: "Reason for rejection (required)" },
3294
+ // F-6 / #393: Batch reject by generator, diff size, or age.
3295
+ generator: {
3296
+ type: "string",
3297
+ description: "F-6: Bulk-reject all pending proposals from this generator (e.g. reflect, distill). Requires no positional id.",
3298
+ },
3299
+ source: {
3300
+ type: "string",
3301
+ description: "DEPRECATED — use --generator. Removed in 0.9.0.",
3302
+ },
3303
+ "max-diff-lines": {
3304
+ type: "string",
3305
+ description: "F-6: When bulk-rejecting, only reject proposals whose content is <= this many lines. Skips larger proposals.",
3306
+ },
3307
+ "older-than": {
3308
+ type: "string",
3309
+ description: "F-6: When bulk-rejecting, only reject proposals created more than this many days ago (e.g. '7' for 7 days).",
3310
+ },
3311
+ "dry-run": {
3312
+ type: "boolean",
3313
+ description: "F-6: List proposals that would be bulk-rejected without rejecting them.",
3314
+ default: false,
3315
+ },
3316
+ yes: {
3317
+ type: "boolean",
3318
+ alias: "y",
3319
+ description: "Skip confirmation prompt (required in non-interactive mode)",
3320
+ default: false,
3321
+ },
3322
+ },
3323
+ run({ args }) {
3324
+ return runWithJsonErrors(async () => {
3325
+ if (args.generator === undefined && args.source !== undefined) {
3326
+ emitFlagDeprecation("--source", "--generator", "proposal reject");
3327
+ }
3328
+ const generator = (args.generator ?? args.source);
3329
+ if (!args.reason || !String(args.reason).trim()) {
3330
+ throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3331
+ }
3332
+ // F-6 / #393: Bulk-reject when --generator is provided without a positional id.
3333
+ if (generator && !args.id) {
3334
+ const { confirmDestructive } = await import("./cli/confirm.js");
3335
+ const confirmed = await confirmDestructive(`Bulk-reject all matching proposals from generator "${generator}"? This cannot be undone.`, { yes: args.yes === true || args["dry-run"] === true });
3336
+ if (!confirmed) {
3337
+ process.stderr.write("Aborted.\n");
3338
+ return;
3339
+ }
3340
+ const { listProposals } = await import("./core/proposals");
3341
+ const stashDir = resolveStashDir();
3342
+ const rawMaxDiff = args["max-diff-lines"] ? Number.parseInt(String(args["max-diff-lines"]), 10) : undefined;
3343
+ if (rawMaxDiff !== undefined && (Number.isNaN(rawMaxDiff) || rawMaxDiff < 0)) {
3344
+ throw new UsageError("--max-diff-lines must be a non-negative integer", "INVALID_FLAG_VALUE");
3345
+ }
3346
+ const rawOlderThan = args["older-than"] ? Number.parseInt(String(args["older-than"]), 10) : undefined;
3347
+ if (rawOlderThan !== undefined && (Number.isNaN(rawOlderThan) || rawOlderThan < 0)) {
3348
+ throw new UsageError("--older-than must be a non-negative integer (days)", "INVALID_FLAG_VALUE");
3349
+ }
3350
+ const maxDiffLines = rawMaxDiff;
3351
+ const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3352
+ const pending = listProposals(stashDir, { status: "pending" }).filter((p) => {
3353
+ if (p.source !== generator)
3354
+ return false;
3355
+ if (maxDiffLines !== undefined) {
3356
+ const lines = (p.payload.content ?? "").split("\n").length;
3357
+ if (lines > maxDiffLines)
3358
+ return false;
3359
+ }
3360
+ if (olderThanMs !== undefined) {
3361
+ const age = Date.now() - new Date(p.createdAt).getTime();
3362
+ if (age < olderThanMs)
3363
+ return false;
3364
+ }
3365
+ return true;
3366
+ });
3367
+ const results = [];
3368
+ for (const proposal of pending) {
3369
+ if (args["dry-run"]) {
3370
+ results.push({ id: proposal.id, ref: proposal.ref, source: proposal.source, dryRun: true });
3371
+ }
3372
+ else {
3373
+ const result = akmProposalReject({ id: proposal.id, reason: String(args.reason) });
3374
+ results.push(result);
3375
+ }
3376
+ }
3377
+ output("proposal-reject-batch", { rejected: results.length, results, dryRun: args["dry-run"] });
3378
+ return;
3379
+ }
3380
+ if (!args.id) {
3381
+ throw new UsageError("Usage: akm proposal reject <id> --reason '<reason>' OR akm proposal reject --generator <generator> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
2645
3382
  }
2646
- else {
2647
- // text mode: keep stdout pristine for line-oriented parsers and
2648
- // emit the trailer on stderr.
2649
- process.stderr.write(`[events-tail] reason=${result.reason} nextOffset=${result.nextOffset} total=${result.totalCount}\n`);
3383
+ const { confirmDestructive } = await import("./cli/confirm.js");
3384
+ const confirmed = await confirmDestructive(`Reject proposal "${args.id}"? This cannot be undone.`, {
3385
+ yes: args.yes === true,
3386
+ });
3387
+ if (!confirmed) {
3388
+ process.stderr.write("Aborted.\n");
3389
+ return;
2650
3390
  }
3391
+ const result = akmProposalReject({ id: args.id, reason: String(args.reason) });
3392
+ output("proposal-reject", result);
2651
3393
  });
2652
3394
  },
2653
3395
  });
2654
- function parsePositiveInt(raw, flag) {
2655
- if (raw === undefined)
2656
- return undefined;
2657
- const trimmed = raw.trim();
2658
- if (!trimmed)
2659
- return undefined;
2660
- const value = Number.parseInt(trimmed, 10);
2661
- if (Number.isNaN(value) || value <= 0) {
2662
- throw new UsageError(`Invalid ${flag} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
2663
- }
2664
- return value;
2665
- }
2666
- const eventsCommand = defineCommand({
2667
- meta: {
2668
- name: "events",
2669
- description: "Read or follow the append-only state.db events stream (mutations, feedback, indexing)",
2670
- },
2671
- subCommands: {
2672
- list: eventsListCommand,
2673
- tail: eventsTailCommand,
2674
- },
2675
- });
2676
- // ── proposal substrate (#225) ────────────────────────────────────────────────
2677
- const proposalsCommand = defineCommand({
2678
- meta: { name: "proposals", description: "List proposal queue entries" },
3396
+ const proposalDiffCommand = defineCommand({
3397
+ meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
2679
3398
  args: {
2680
- status: { type: "string", description: "Filter by status (pending|accepted|rejected)" },
2681
- ref: { type: "string", description: "Filter by asset ref (type:name)" },
2682
- type: { type: "string", description: "Filter by asset type" },
3399
+ id: {
3400
+ type: "positional",
3401
+ description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
3402
+ required: true,
3403
+ },
3404
+ target: { type: "string", description: "Override the write target by source name" },
2683
3405
  },
2684
3406
  run({ args }) {
2685
3407
  return runWithJsonErrors(() => {
2686
- const status = parseProposalStatus(args.status);
2687
- const result = akmProposalList({
2688
- status,
2689
- ref: args.ref,
2690
- includeArchive: status === "accepted" || status === "rejected",
2691
- });
2692
- output("proposal-list", result);
3408
+ const result = akmProposalDiff({ id: args.id, target: args.target });
3409
+ output("proposal-diff", result);
2693
3410
  });
2694
3411
  },
2695
3412
  });
2696
- const acceptCommand = defineCommand({
2697
- meta: { name: "accept", description: "Accept a proposal and promote it into the stash" },
3413
+ // Phase 6C (Advantage D6c): revert an accepted proposal.
3414
+ //
3415
+ // Exit codes (mapped by `runWithJsonErrors` from the typed errors thrown by
3416
+ // `akmProposalRevert` / `revertProposal`):
3417
+ // 0 — success; prior content restored.
3418
+ // 1 — generic error (also used by `UsageError("INVALID_FLAG_VALUE")` and
3419
+ // `UsageError("MISSING_REQUIRED_ARGUMENT")` when the proposal is not
3420
+ // accepted, or no backup is available).
3421
+ // 1 — `NotFoundError("FILE_NOT_FOUND")` when the proposal id does not resolve.
3422
+ const proposalRevertCommand = defineCommand({
3423
+ meta: {
3424
+ name: "revert",
3425
+ description: "Revert an accepted proposal: restore the prior asset content from the backup captured at promotion time. " +
3426
+ "Errors if the proposal is not accepted or has no backup (new-asset proposals leave no backup). " +
3427
+ "Accepts the full proposal UUID or the asset ref. UUID prefixes are not supported for archived proposals — use the full UUID.",
3428
+ },
2698
3429
  args: {
2699
3430
  id: {
2700
3431
  type: "positional",
2701
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
3432
+ 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.",
2702
3433
  required: true,
2703
3434
  },
2704
3435
  target: { type: "string", description: "Override the write target by source name" },
2705
3436
  },
2706
3437
  async run({ args }) {
2707
3438
  await runWithJsonErrors(async () => {
2708
- const result = await akmProposalAccept({ id: args.id, target: args.target });
2709
- output("proposal-accept", result);
3439
+ const result = await akmProposalRevert({
3440
+ id: args.id,
3441
+ target: args.target,
3442
+ });
3443
+ output("proposal-revert", result);
2710
3444
  });
2711
3445
  },
2712
3446
  });
2713
- const rejectCommand = defineCommand({
2714
- meta: { name: "reject", description: "Reject a proposal and record the reason" },
3447
+ // `proposal show` (#225): show a single proposal with its validation findings.
3448
+ // `akmProposalShow` already backs `akm show proposal <id>` (now deprecated); this
3449
+ // is the canonical noun-group entry point.
3450
+ const proposalShowCommand = defineCommand({
3451
+ meta: { name: "show", description: "Show a single proposal and its validation findings" },
2715
3452
  args: {
2716
3453
  id: {
2717
3454
  type: "positional",
2718
3455
  description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
2719
3456
  required: true,
2720
3457
  },
2721
- reason: { type: "string", description: "Reason for rejection (required)" },
2722
3458
  },
2723
3459
  run({ args }) {
2724
3460
  return runWithJsonErrors(() => {
2725
- if (!args.reason || !String(args.reason).trim()) {
2726
- throw new UsageError("Usage: akm reject <id> --reason '<reason>'", "MISSING_REQUIRED_ARGUMENT");
3461
+ const result = akmProposalShow({ id: args.id });
3462
+ output("proposal-show", result);
3463
+ });
3464
+ },
3465
+ });
3466
+ const proposalDrainCommand = defineCommand({
3467
+ meta: {
3468
+ name: "drain",
3469
+ description: "Drain the standing pending proposal backlog using a deterministic triage policy",
3470
+ },
3471
+ args: {
3472
+ policy: {
3473
+ type: "string",
3474
+ description: "Built-in preset (personal-stash|conservative|manual) or path to a policy file",
3475
+ },
3476
+ "dry-run": {
3477
+ type: "boolean",
3478
+ description: "List what would be accepted/rejected/deferred without writing.",
3479
+ default: false,
3480
+ },
3481
+ yes: {
3482
+ type: "boolean",
3483
+ alias: "y",
3484
+ description: "Skip confirmation prompt (required in non-interactive mode for promotion).",
3485
+ default: false,
3486
+ },
3487
+ "max-accepts": {
3488
+ type: "string",
3489
+ description: "Hard per-run accept ceiling. Accepts beyond this are reported as skippedByCap.",
3490
+ },
3491
+ "max-diff-lines": {
3492
+ type: "string",
3493
+ description: "Defer (never promote) accepts whose proposed content exceeds this many lines.",
3494
+ },
3495
+ "older-than": {
3496
+ type: "string",
3497
+ description: "Only consider proposals created more than this many days ago.",
3498
+ },
3499
+ promote: {
3500
+ type: "boolean",
3501
+ description: "Promote (accept) matching proposals. Default is queue mode (stage only, no writes to assets).",
3502
+ default: false,
3503
+ },
3504
+ judgment: {
3505
+ type: "boolean",
3506
+ 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.",
3507
+ default: false,
3508
+ },
3509
+ profile: {
3510
+ type: "string",
3511
+ description: "Read the triage block (policy, applyMode, ceilings, judgment) from this improve profile.",
3512
+ },
3513
+ },
3514
+ async run({ args }) {
3515
+ await runWithJsonErrors(async () => {
3516
+ const stashDir = resolveStashDir();
3517
+ const cfg = loadConfig();
3518
+ // Phase 2: read the triage block from the named improve profile. CLI flags
3519
+ // always override config; config supplies defaults for any flag omitted.
3520
+ const triageConfig = args.profile !== undefined ? resolveImproveProfile(args.profile, cfg).processes?.triage : undefined;
3521
+ const policy = resolveDrainPolicy(args.policy ?? triageConfig?.policy);
3522
+ const dryRun = args["dry-run"] === true;
3523
+ const applyMode = args.promote === true ? "promote" : (triageConfig?.applyMode ?? "queue");
3524
+ const maxAccepts = parsePositiveIntFlag(args["max-accepts"], "--max-accepts") ??
3525
+ triageConfig?.maxAcceptsPerRun ??
3526
+ 25;
3527
+ const maxDiffLines = parsePositiveIntFlag(args["max-diff-lines"], "--max-diff-lines") ??
3528
+ triageConfig?.maxDiffLines;
3529
+ const rawOlderThan = parsePositiveIntFlag(args["older-than"], "--older-than");
3530
+ const olderThanMs = rawOlderThan !== undefined ? rawOlderThan * 86_400_000 : undefined;
3531
+ // Promotion in promote mode is destructive (commits to git, no batch revert).
3532
+ if (applyMode === "promote" && !dryRun) {
3533
+ const { confirmDestructive } = await import("./cli/confirm.js");
3534
+ 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 });
3535
+ if (!confirmed) {
3536
+ process.stderr.write("Aborted.\n");
3537
+ return;
3538
+ }
2727
3539
  }
2728
- const result = akmProposalReject({ id: args.id, reason: args.reason });
2729
- output("proposal-reject", result);
3540
+ // `--older-than` is applied here as a pre-filter on excludeIds: ids that
3541
+ // are too fresh are excluded so the engine never touches them. This reads
3542
+ // the pending set once here; drainProposals reads the pending set again
3543
+ // internally, so a future engine-level olderThan option could remove this
3544
+ // second read (engine API owned by another agent — not changed here).
3545
+ let excludeIds;
3546
+ if (olderThanMs !== undefined) {
3547
+ const { listProposals } = await import("./core/proposals");
3548
+ const now = Date.now();
3549
+ excludeIds = new Set(listProposals(stashDir, { status: "pending" })
3550
+ // Fail SAFE: exclude a proposal when its age cannot be computed
3551
+ // (NaN createdAt) OR it is too fresh. An unparseable createdAt must
3552
+ // never be treated as old enough to drain/promote.
3553
+ .filter((proposal) => {
3554
+ const age = now - new Date(proposal.createdAt).getTime();
3555
+ return Number.isNaN(age) || age < olderThanMs;
3556
+ })
3557
+ .map((proposal) => proposal.id));
3558
+ }
3559
+ // Phase 3: resolve the judgment runner when --judgment is set. Default
3560
+ // mode is llm; falls back to defaults.llm when the triage block sets
3561
+ // neither mode nor profile (mirrors resolveValidationRunner). null when
3562
+ // nothing is configured → the engine leaves deferred items unresolved and
3563
+ // emits triage_deferred.
3564
+ const judgment = args.judgment === true ? resolveTriageJudgmentRunner(triageConfig?.judgment, cfg) : null;
3565
+ const result = await drainProposals({
3566
+ stashDir,
3567
+ policy,
3568
+ applyMode,
3569
+ maxAccepts,
3570
+ dryRun,
3571
+ ...(maxDiffLines !== undefined ? { maxDiffLines } : {}),
3572
+ ...(excludeIds ? { excludeIds } : {}),
3573
+ judgment,
3574
+ });
3575
+ output("proposal-drain", {
3576
+ schemaVersion: 1,
3577
+ ok: true,
3578
+ policy: policy.name,
3579
+ applyMode,
3580
+ dryRun,
3581
+ promoted: result.promoted,
3582
+ rejected: result.rejected,
3583
+ deferred: result.deferred,
3584
+ skippedByCap: result.skippedByCap,
3585
+ });
2730
3586
  });
2731
3587
  },
2732
3588
  });
2733
- const diffCommand = defineCommand({
2734
- meta: { name: "diff", description: "Show the diff for a proposal (accepts full UUID, UUID prefix, or asset ref)" },
3589
+ // ── proposal noun group (#225 / 0.8 CLI stabilization) ────────────────────────
3590
+ //
3591
+ // `akm proposal <verb>` is the canonical grammar in 0.8. The flat verbs
3592
+ // (`proposals`/`accept`/`reject`/`diff`/`revert`) remain as deprecated aliases
3593
+ // that warn to stderr and delegate to the same command bodies; they are removed
3594
+ // in 0.9.0. Bare `akm proposal` behaves as `proposal list` (mirrors `akm env`).
3595
+ const PROPOSAL_SUBCOMMAND_SET = new Set(["list", "show", "diff", "accept", "reject", "revert", "drain"]);
3596
+ function emitProposalVerbDeprecation(oldVerb, canonical) {
3597
+ if (isQuiet())
3598
+ return;
3599
+ process.stderr.write(`warning: 'akm ${oldVerb}' is deprecated and will be removed in 0.9.0. Use 'akm ${canonical}'.\n`);
3600
+ }
3601
+ const proposalCommand = defineCommand({
3602
+ meta: { name: "proposal", description: "Manage the proposal queue: list, show, diff, accept, reject, revert" },
2735
3603
  args: {
2736
- id: {
2737
- type: "positional",
2738
- description: "Proposal id (uuid / prefix) or asset ref (e.g. skill:akm-dream)",
2739
- required: true,
3604
+ status: {
3605
+ type: "string",
3606
+ description: "Filter by status (pending|accepted|rejected|reverted)",
2740
3607
  },
2741
- target: { type: "string", description: "Override the write target by source name" },
3608
+ ref: { type: "string", description: "Filter by asset ref (type:name)" },
3609
+ type: { type: "string", description: "Filter by asset type" },
3610
+ },
3611
+ subCommands: {
3612
+ list: proposalListCommand,
3613
+ show: proposalShowCommand,
3614
+ diff: proposalDiffCommand,
3615
+ accept: proposalAcceptCommand,
3616
+ reject: proposalRejectCommand,
3617
+ revert: proposalRevertCommand,
3618
+ drain: proposalDrainCommand,
2742
3619
  },
2743
3620
  run({ args }) {
2744
3621
  return runWithJsonErrors(() => {
2745
- const result = akmProposalDiff({ id: args.id, target: args.target });
2746
- output("proposal-diff", result);
3622
+ // citty runs the group body even after a subcommand; short-circuit so the
3623
+ // default-to-list body only fires for bare `akm proposal [--status …]`.
3624
+ if (hasSubcommand(args, PROPOSAL_SUBCOMMAND_SET))
3625
+ return;
3626
+ const status = parseProposalStatus(args.status);
3627
+ const result = akmProposalList({
3628
+ status,
3629
+ ref: args.ref,
3630
+ type: args.type,
3631
+ includeArchive: status === "accepted" || status === "rejected" || status === "reverted",
3632
+ });
3633
+ output("proposal-list", result);
2747
3634
  });
2748
3635
  },
2749
3636
  });
3637
+ // Deprecated flat-verb aliases (removed 0.9.0). Each wraps the canonical command
3638
+ // body so bulk/guard logic is not duplicated.
3639
+ const proposalsCommand = defineCommand({
3640
+ meta: { name: "proposals", description: "DEPRECATED — use `akm proposal list`. Removed in 0.9.0." },
3641
+ args: proposalListCommand.args,
3642
+ run(ctx) {
3643
+ emitProposalVerbDeprecation("proposals", "proposal list");
3644
+ return proposalListCommand.run?.(ctx);
3645
+ },
3646
+ });
3647
+ const acceptCommand = defineCommand({
3648
+ meta: { name: "accept", description: "DEPRECATED — use `akm proposal accept`. Removed in 0.9.0." },
3649
+ args: proposalAcceptCommand.args,
3650
+ run(ctx) {
3651
+ emitProposalVerbDeprecation("accept", "proposal accept");
3652
+ return proposalAcceptCommand.run?.(ctx);
3653
+ },
3654
+ });
3655
+ const rejectCommand = defineCommand({
3656
+ meta: { name: "reject", description: "DEPRECATED — use `akm proposal reject`. Removed in 0.9.0." },
3657
+ args: proposalRejectCommand.args,
3658
+ run(ctx) {
3659
+ emitProposalVerbDeprecation("reject", "proposal reject");
3660
+ return proposalRejectCommand.run?.(ctx);
3661
+ },
3662
+ });
3663
+ const diffCommand = defineCommand({
3664
+ meta: { name: "diff", description: "DEPRECATED — use `akm proposal diff`. Removed in 0.9.0." },
3665
+ args: proposalDiffCommand.args,
3666
+ run(ctx) {
3667
+ emitProposalVerbDeprecation("diff", "proposal diff");
3668
+ return proposalDiffCommand.run?.(ctx);
3669
+ },
3670
+ });
3671
+ const revertCommand = defineCommand({
3672
+ meta: { name: "revert", description: "DEPRECATED — use `akm proposal revert`. Removed in 0.9.0." },
3673
+ args: proposalRevertCommand.args,
3674
+ run(ctx) {
3675
+ emitProposalVerbDeprecation("revert", "proposal revert");
3676
+ return proposalRevertCommand.run?.(ctx);
3677
+ },
3678
+ });
2750
3679
  // ── distill (#228) ──────────────────────────────────────────────────────────
2751
3680
  function parseProposalStatus(raw) {
2752
3681
  if (raw === undefined)
@@ -2754,50 +3683,85 @@ function parseProposalStatus(raw) {
2754
3683
  const trimmed = raw.trim();
2755
3684
  if (!trimmed)
2756
3685
  return undefined;
2757
- if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected")
3686
+ if (trimmed === "pending" || trimmed === "accepted" || trimmed === "rejected" || trimmed === "reverted") {
2758
3687
  return trimmed;
2759
- throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected.`, "INVALID_FLAG_VALUE");
3688
+ }
3689
+ throw new UsageError(`Invalid --status value: "${raw}". Expected one of: pending, accepted, rejected, reverted.`, "INVALID_FLAG_VALUE");
2760
3690
  }
2761
3691
  const agentCommand = defineCommand({
2762
3692
  meta: {
2763
3693
  name: "agent",
2764
- description: "Dispatch an agent by named profile, optionally injecting a prompt from inline text, a stash command: asset, or a stash workflow: asset",
3694
+ 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.",
2765
3695
  },
2766
3696
  args: {
2767
3697
  profile: {
2768
3698
  type: "positional",
2769
- description: "Agent profile name (from config.agent.profiles or a built-in)",
3699
+ description: "Agent profile / platform to use (opencode, claude, …)",
3700
+ required: false,
3701
+ },
3702
+ "agent-ref": {
3703
+ type: "positional",
3704
+ description: "Optional agent asset ref (e.g. agent:code-reviewer). Loads system prompt, model, and tool policy from the stash asset.",
2770
3705
  required: false,
2771
3706
  },
2772
- prompt: { type: "string", description: "Inline prompt text to pass to the agent" },
2773
- command: { type: "string", description: "Load the body of a command: asset from the index and use as the prompt" },
2774
- workflow: {
3707
+ prompt: { type: "string", description: "Task prompt to pass to the agent" },
3708
+ command: { type: "string", description: "Load prompt from a command: asset" },
3709
+ workflow: { type: "string", description: "Load prompt from a workflow: asset" },
3710
+ model: {
2775
3711
  type: "string",
2776
- description: "Load the body of a workflow: asset from the index and use as the prompt",
3712
+ description: "Model override accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
2777
3713
  },
2778
3714
  "timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
2779
3715
  },
2780
3716
  async run({ args }) {
2781
3717
  await runWithJsonErrors(async () => {
2782
3718
  if (!args.profile) {
2783
- throw new UsageError("Usage: akm agent <profile> [--prompt <text>] [--command <ref>] [--workflow <ref>] [args...]", "MISSING_REQUIRED_ARGUMENT", "Provide the agent profile name. Available profiles are listed in config.agent.profiles.");
3719
+ 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.");
2784
3720
  }
2785
- // Collect extra positional args (forwarded to the agent and used as
2786
- // template placeholders when a command/workflow ref is specified).
2787
- const extraArgs = Array.isArray(args._) ? args._.filter((a) => a !== args.profile) : [];
2788
- const timeoutRaw = args["timeout-ms"];
2789
- const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
3721
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
2790
3722
  const config = loadConfig();
2791
- const { parseAgentConfig } = await import("./integrations/agent/config.js");
2792
- const agentConfig = parseAgentConfig(config.agent);
3723
+ const { getDefaultLlmConfig } = await import("./core/config.js");
3724
+ // After 0.8.0 the agent block IS the loaded AkmConfig.
3725
+ const agentConfig = config;
3726
+ // Resolve agent asset ref → extract system prompt, model, and tool policy.
3727
+ const agentRef = getStringArg(args, "agent-ref");
3728
+ let systemPrompt;
3729
+ let assetModel;
3730
+ let assetTools;
3731
+ if (agentRef) {
3732
+ const { akmShowUnified } = await import("./commands/show.js");
3733
+ const asset = await akmShowUnified({ ref: agentRef, detail: "full" });
3734
+ systemPrompt = typeof asset.content === "string" ? asset.content : undefined;
3735
+ assetModel = typeof asset.modelHint === "string" ? asset.modelHint : undefined;
3736
+ assetTools = asset.toolPolicy;
3737
+ }
3738
+ // --model flag wins over the asset's modelHint.
3739
+ const model = getStringArg(args, "model") ?? assetModel;
3740
+ const promptText = getStringArg(args, "prompt");
3741
+ const commandRef = getStringArg(args, "command");
3742
+ const workflowRef = getStringArg(args, "workflow");
3743
+ // Only build a dispatch request when there is something to dispatch — a
3744
+ // prompt, an agent asset, or a model override. When none of these are
3745
+ // present the agent is launched interactively (no injected prompt, no
3746
+ // platform-specific flags beyond the profile's base args).
3747
+ const hasDispatchContent = !!(promptText ?? commandRef ?? workflowRef ?? systemPrompt ?? model ?? assetTools);
2793
3748
  const result = await akmAgentDispatch({
2794
3749
  profileName: String(args.profile),
2795
- prompt: typeof args.prompt === "string" ? args.prompt : undefined,
2796
- commandRef: typeof args.command === "string" && args.command.trim() ? args.command.trim() : undefined,
2797
- workflowRef: typeof args.workflow === "string" && args.workflow.trim() ? args.workflow.trim() : undefined,
2798
- args: extraArgs.length > 0 ? extraArgs : undefined,
3750
+ prompt: promptText,
3751
+ commandRef,
3752
+ workflowRef,
2799
3753
  agentConfig,
2800
- llmConfig: config.llm,
3754
+ llmConfig: getDefaultLlmConfig(config),
3755
+ ...(hasDispatchContent
3756
+ ? {
3757
+ dispatch: {
3758
+ prompt: promptText ?? "",
3759
+ systemPrompt,
3760
+ model,
3761
+ tools: assetTools,
3762
+ },
3763
+ }
3764
+ : {}),
2801
3765
  ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
2802
3766
  });
2803
3767
  output("agent-result", result);
@@ -2810,159 +3774,29 @@ const agentCommand = defineCommand({
2810
3774
  const lintCommand = defineCommand({
2811
3775
  meta: {
2812
3776
  name: "lint",
2813
- 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.",
3777
+ 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.",
2814
3778
  },
2815
3779
  args: {
2816
3780
  fix: { type: "boolean", description: "Apply auto-fixes in place", default: false },
2817
3781
  dir: { type: "string", description: "Override stash root directory (default: from config)" },
3782
+ "fail-on-flagged": {
3783
+ type: "boolean",
3784
+ description: "Exit non-zero when summary.flagged > 0 (CI-friendly). Default: exit 0 regardless of findings.",
3785
+ default: false,
3786
+ },
2818
3787
  },
2819
3788
  async run({ args }) {
2820
3789
  await runWithJsonErrors(async () => {
2821
3790
  const result = akmLint({
2822
3791
  fix: args.fix ?? false,
2823
- dir: typeof args.dir === "string" && args.dir.trim() ? args.dir.trim() : undefined,
3792
+ dir: getStringArg(args, "dir"),
2824
3793
  });
2825
3794
  output("lint", result);
2826
- if (!result.ok)
3795
+ if (args["fail-on-flagged"] && result.summary.flagged > 0)
2827
3796
  process.exit(EXIT_GENERAL);
2828
3797
  });
2829
3798
  },
2830
3799
  });
2831
- const improveCommand = defineCommand({
2832
- meta: {
2833
- name: "improve",
2834
- description: "Analyze existing AKM assets and generate improvement proposals; also consolidates memories when llm.features.memory_consolidation is enabled",
2835
- },
2836
- args: {
2837
- scope: {
2838
- type: "positional",
2839
- description: "Optional asset type or asset ref to improve",
2840
- required: false,
2841
- },
2842
- task: { type: "string", description: "Add extra guidance for this improvement pass" },
2843
- "dry-run": { type: "boolean", description: "Show planned actions without writing", default: false },
2844
- target: { type: "string", description: "Override the write target for accepted proposals" },
2845
- "auto-accept": {
2846
- type: "string",
2847
- description: "Automatically accept low-risk proposals (only 'safe' is supported)",
2848
- },
2849
- limit: { type: "string", description: "Maximum number of assets to process (highest utility first)" },
2850
- "timeout-ms": {
2851
- type: "string",
2852
- description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
2853
- },
2854
- "ignore-cooldown": {
2855
- type: "boolean",
2856
- description: "Ignore all cooldown periods (equivalent to --reflect-cooldown-days 0 --distill-cooldown-days 0 --consolidate-cooldown-days 0)",
2857
- default: false,
2858
- },
2859
- "reflect-cooldown-days": {
2860
- type: "string",
2861
- description: "Override reflect cooldown for this run only (default: 7, 0 to disable)",
2862
- },
2863
- "distill-cooldown-days": {
2864
- type: "string",
2865
- description: "Override distill cooldown for this run only (default: 30, 0 to disable)",
2866
- },
2867
- "consolidate-cooldown-days": {
2868
- type: "string",
2869
- description: "Override consolidate cooldown for this run only (default: 14, 0 to disable)",
2870
- },
2871
- "consolidate-recovery": {
2872
- type: "string",
2873
- description: "How to handle stale/incomplete consolidation journals: abort (default) or clean (remove stale journal artifacts)",
2874
- },
2875
- "require-feedback-signal": {
2876
- type: "boolean",
2877
- description: "Only process assets with recent feedback signals (disables retrieval fallback)",
2878
- default: false,
2879
- },
2880
- "min-retrieval-count": {
2881
- type: "string",
2882
- description: "Minimum retrieval count for zero-feedback fallback eligibility (default: 5)",
2883
- },
2884
- },
2885
- async run({ args }) {
2886
- await runWithJsonErrors(async () => {
2887
- const autoAcceptRaw = getHyphenatedArg(args, "auto-accept");
2888
- if (autoAcceptRaw !== undefined && autoAcceptRaw !== "safe") {
2889
- throw new UsageError("--auto-accept only supports the value 'safe'.", "INVALID_FLAG_VALUE");
2890
- }
2891
- const targetArg = typeof args.target === "string" && args.target.trim() ? args.target.trim() : undefined;
2892
- const taskArg = typeof args.task === "string" && args.task.trim() ? args.task : undefined;
2893
- const dryRun = getHyphenatedBoolean(args, "dry-run");
2894
- const autoAccept = autoAcceptRaw === "safe" ? "safe" : undefined;
2895
- const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
2896
- const timeoutRaw = getHyphenatedArg(args, "timeout-ms");
2897
- const timeoutMs = timeoutRaw !== undefined ? parseInt(timeoutRaw, 10) : undefined;
2898
- if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
2899
- throw new UsageError(`Invalid --timeout-ms value: "${timeoutRaw}". Must be a positive integer.`);
2900
- }
2901
- const parseNonNegativeCooldownDays = (raw, flagName) => {
2902
- if (raw === undefined)
2903
- return undefined;
2904
- if (!/^\d+$/.test(raw.trim())) {
2905
- throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a non-negative integer.`);
2906
- }
2907
- return parseInt(raw, 10);
2908
- };
2909
- const ignoreCooldown = getHyphenatedBoolean(args, "ignore-cooldown");
2910
- const reflectCooldownRaw = getHyphenatedArg(args, "reflect-cooldown-days");
2911
- const reflectCooldownDays = ignoreCooldown
2912
- ? 0
2913
- : parseNonNegativeCooldownDays(reflectCooldownRaw, "--reflect-cooldown-days");
2914
- const distillCooldownRaw = getHyphenatedArg(args, "distill-cooldown-days");
2915
- const distillCooldownDays = ignoreCooldown
2916
- ? 0
2917
- : parseNonNegativeCooldownDays(distillCooldownRaw, "--distill-cooldown-days");
2918
- const consolidateCooldownRaw = getHyphenatedArg(args, "consolidate-cooldown-days");
2919
- const consolidateCooldownDays = ignoreCooldown
2920
- ? 0
2921
- : parseNonNegativeCooldownDays(consolidateCooldownRaw, "--consolidate-cooldown-days");
2922
- const consolidateRecoveryRaw = getHyphenatedArg(args, "consolidate-recovery");
2923
- const consolidateRecovery = consolidateRecoveryRaw === undefined
2924
- ? undefined
2925
- : consolidateRecoveryRaw.trim().toLowerCase();
2926
- if (consolidateRecovery !== undefined && consolidateRecovery !== "abort" && consolidateRecovery !== "clean") {
2927
- throw new UsageError(`Invalid --consolidate-recovery value: "${consolidateRecoveryRaw}". Must be one of: abort, clean.`, "INVALID_FLAG_VALUE");
2928
- }
2929
- const minRetrievalCountRaw = getHyphenatedArg(args, "min-retrieval-count");
2930
- const minRetrievalCount = parseNonNegativeCooldownDays(minRetrievalCountRaw, "--min-retrieval-count");
2931
- const requireFeedbackSignal = getHyphenatedBoolean(args, "require-feedback-signal");
2932
- const improveLogFile = path.join(getCacheDir(), "logs", "improve", `${new Date().toISOString().replace(/[:.]/g, "-")}.log`);
2933
- setLogFile(improveLogFile);
2934
- let improveResult;
2935
- try {
2936
- improveResult = await akmImprove({
2937
- scope: typeof args.scope === "string" && args.scope.trim() ? args.scope : undefined,
2938
- task: taskArg,
2939
- dryRun,
2940
- target: targetArg,
2941
- autoAccept,
2942
- ...(limitRaw !== undefined ? { limit: limitRaw } : {}),
2943
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
2944
- ...(reflectCooldownDays !== undefined ? { reflectCooldownDays } : {}),
2945
- ...(distillCooldownDays !== undefined ? { distillCooldownDays } : {}),
2946
- ...(consolidateCooldownDays !== undefined ? { consolidateCooldownDays } : {}),
2947
- ...(minRetrievalCount !== undefined ? { minRetrievalCount } : {}),
2948
- ...(requireFeedbackSignal ? { requireFeedbackSignal } : {}),
2949
- consolidateOptions: {
2950
- target: targetArg,
2951
- dryRun,
2952
- autoAccept,
2953
- task: taskArg,
2954
- ...(consolidateRecovery !== undefined ? { recoveryMode: consolidateRecovery } : {}),
2955
- },
2956
- });
2957
- }
2958
- finally {
2959
- clearLogFile();
2960
- }
2961
- output("improve", improveResult);
2962
- process.exit(0);
2963
- });
2964
- },
2965
- });
2966
3800
  const proposeCommand = defineCommand({
2967
3801
  meta: {
2968
3802
  name: "propose",
@@ -2993,14 +3827,13 @@ const proposeCommand = defineCommand({
2993
3827
  throw new UsageError("Pass exactly one of --task or --file.", "INVALID_FLAG_VALUE");
2994
3828
  }
2995
3829
  const taskText = fileFromFlag ? fs.readFileSync(path.resolve(fileFromFlag), "utf8") : (taskFromFlag ?? "");
2996
- const timeoutRaw = args["timeout-ms"];
2997
- const timeoutMs = typeof timeoutRaw === "string" && timeoutRaw.trim() ? Number.parseInt(timeoutRaw, 10) : undefined;
3830
+ const timeoutMs = parsePositiveIntFlag(getHyphenatedArg(args, "timeout-ms"), "--timeout-ms");
2998
3831
  const result = await akmPropose({
2999
3832
  type: String(args.type),
3000
3833
  name: String(args.name),
3001
3834
  task: taskText,
3002
- profile: typeof args.profile === "string" && args.profile.trim() ? args.profile : undefined,
3003
- ...(timeoutMs !== undefined && Number.isFinite(timeoutMs) ? { timeoutMs } : {}),
3835
+ profile: getStringArg(args, "profile"),
3836
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3004
3837
  });
3005
3838
  output("propose", result);
3006
3839
  if (result.ok === false) {
@@ -3021,7 +3854,16 @@ const TASKS_SUBCOMMAND_SET = new Set([
3021
3854
  "sync",
3022
3855
  "doctor",
3023
3856
  ]);
3024
- const GRAPH_SUBCOMMAND_SET = new Set(["summary", "entities", "relations", "related", "export"]);
3857
+ const GRAPH_SUBCOMMAND_SET = new Set([
3858
+ "summary",
3859
+ "entities",
3860
+ "entity",
3861
+ "relations",
3862
+ "related",
3863
+ "orphans",
3864
+ "export",
3865
+ "update",
3866
+ ]);
3025
3867
  const tasksAddCommand = defineCommand({
3026
3868
  meta: { name: "add", description: "Register a new scheduled task and install it in the OS scheduler" },
3027
3869
  args: {
@@ -3032,8 +3874,14 @@ const tasksAddCommand = defineCommand({
3032
3874
  type: "string",
3033
3875
  description: "Prompt for the configured agent harness — inline text, an asset ref like agent:foo, or ./path.md",
3034
3876
  },
3035
- profile: { type: "string", description: "Agent profile to use for prompt targets (default: config.agent.default)" },
3877
+ command: {
3878
+ type: "string",
3879
+ 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.',
3880
+ },
3881
+ profile: { type: "string", description: "Agent profile to use for prompt targets (default: defaults.agent)" },
3036
3882
  params: { type: "string", description: "Workflow params as a JSON object" },
3883
+ name: { type: "string", description: "Human-readable name for the task" },
3884
+ "when-to-use": { type: "string", description: "Guidance on when this task runs or should be used" },
3037
3885
  description: { type: "string", description: "Human-readable description" },
3038
3886
  tags: { type: "string", description: "Comma-separated tags" },
3039
3887
  disabled: { type: "boolean", description: "Register but leave disabled in the OS scheduler", default: false },
@@ -3046,8 +3894,11 @@ const tasksAddCommand = defineCommand({
3046
3894
  schedule: args.schedule,
3047
3895
  workflow: args.workflow,
3048
3896
  prompt: args.prompt,
3897
+ command: args.command,
3049
3898
  profile: args.profile,
3050
3899
  params: args.params,
3900
+ name: args.name,
3901
+ when_to_use: getHyphenatedArg(args, "when-to-use"),
3051
3902
  description: args.description,
3052
3903
  tags: args.tags
3053
3904
  ? args.tags
@@ -3093,28 +3944,25 @@ const tasksRemoveCommand = defineCommand({
3093
3944
  });
3094
3945
  },
3095
3946
  });
3096
- const tasksEnableCommand = defineCommand({
3097
- meta: { name: "enable", description: "Enable a previously-disabled task" },
3098
- args: { id: { type: "positional", description: "Task id", required: true } },
3099
- async run({ args }) {
3100
- await runWithJsonErrors(async () => {
3101
- const { id } = parseTaskRef(args.id);
3102
- const result = await akmTasksSetEnabled(id, true);
3103
- output("tasks-enable", result);
3104
- });
3105
- },
3106
- });
3107
- const tasksDisableCommand = defineCommand({
3108
- meta: { name: "disable", description: "Disable a task in the OS scheduler without removing the file" },
3109
- args: { id: { type: "positional", description: "Task id", required: true } },
3110
- async run({ args }) {
3111
- await runWithJsonErrors(async () => {
3112
- const { id } = parseTaskRef(args.id);
3113
- const result = await akmTasksSetEnabled(id, false);
3114
- output("tasks-disable", result);
3115
- });
3116
- },
3117
- });
3947
+ function makeTasksToggleCommand(enabled) {
3948
+ const verb = enabled ? "enable" : "disable";
3949
+ const description = enabled
3950
+ ? "Enable a previously-disabled task"
3951
+ : "Disable a task in the OS scheduler without removing the file";
3952
+ return defineCommand({
3953
+ meta: { name: verb, description },
3954
+ args: { id: { type: "positional", description: "Task id", required: true } },
3955
+ async run({ args }) {
3956
+ await runWithJsonErrors(async () => {
3957
+ const { id } = parseTaskRef(args.id);
3958
+ const result = await akmTasksSetEnabled(id, enabled);
3959
+ output(`tasks-${verb}`, result);
3960
+ });
3961
+ },
3962
+ });
3963
+ }
3964
+ const tasksEnableCommand = makeTasksToggleCommand(true);
3965
+ const tasksDisableCommand = makeTasksToggleCommand(false);
3118
3966
  const tasksRunCommand = defineCommand({
3119
3967
  meta: {
3120
3968
  name: "run",
@@ -3172,6 +4020,7 @@ const tasksDoctorCommand = defineCommand({
3172
4020
  const tasksCommand = defineCommand({
3173
4021
  meta: {
3174
4022
  name: "tasks",
4023
+ alias: "task",
3175
4024
  description: "Schedule workflows or prompts via the OS-native scheduler (cron / launchd / schtasks)",
3176
4025
  },
3177
4026
  subCommands: {
@@ -3195,16 +4044,43 @@ const tasksCommand = defineCommand({
3195
4044
  });
3196
4045
  },
3197
4046
  });
3198
- const main = defineCommand({
4047
+ export const main = defineCommand({
3199
4048
  meta: {
3200
4049
  name: "akm",
3201
4050
  version: pkgVersion,
3202
- description: "Agent Kit Manager — search, show, and manage assets from your stash.",
4051
+ description: "Agent Knowledge Management — search, show, and manage assets from your stash.\n\n" +
4052
+ "Exit codes:\n" +
4053
+ " 0 success\n" +
4054
+ " 1 general error / not found\n" +
4055
+ " 2 usage error\n" +
4056
+ " 4 health warn (akm health only)\n" +
4057
+ " 78 config error",
3203
4058
  },
3204
4059
  args: {
3205
4060
  format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
3206
- detail: { type: "string", description: "Detail level (brief|normal|full|summary|agent)", default: "brief" },
3207
- quiet: { type: "boolean", alias: "q", description: "Suppress stderr warnings", default: false },
4061
+ detail: {
4062
+ type: "string",
4063
+ description: "Detail level (verbosity): brief|normal|full. Default: brief.",
4064
+ default: "brief",
4065
+ },
4066
+ shape: {
4067
+ type: "string",
4068
+ description: "Output projection: human|agent|summary. 'agent' trims to agent-essential fields; " +
4069
+ "'summary' is only valid on 'akm show'. Default: human.",
4070
+ },
4071
+ "for-agent": {
4072
+ type: "boolean",
4073
+ description: "DEPRECATED alias for '--shape agent' (removed 0.9.0).",
4074
+ default: false,
4075
+ },
4076
+ quiet: {
4077
+ type: "boolean",
4078
+ alias: "q",
4079
+ description: "Suppress non-essential stderr output (banners, spinners, progress info). " +
4080
+ "Safety-critical output is never suppressed: errors, destructive-action confirmation prompts, " +
4081
+ "and auto-migration banners always appear regardless of --quiet.",
4082
+ default: false,
4083
+ },
3208
4084
  verbose: {
3209
4085
  type: "boolean",
3210
4086
  description: "Print per-spec diagnostics to stderr (also honours AKM_VERBOSE env var)",
@@ -3218,6 +4094,7 @@ const main = defineCommand({
3218
4094
  health: healthCommand,
3219
4095
  info: infoCommand,
3220
4096
  graph: graphCommand,
4097
+ db: dbCommand,
3221
4098
  add: addCommand,
3222
4099
  list: listCommand,
3223
4100
  remove: removeCommand,
@@ -3229,6 +4106,8 @@ const main = defineCommand({
3229
4106
  workflow: workflowCommand,
3230
4107
  remember: rememberCommand,
3231
4108
  import: importKnowledgeCommand,
4109
+ sync: syncCommand,
4110
+ // Deprecated alias (removed 0.9.0) — delegates to `sync`.
3232
4111
  save: saveCommand,
3233
4112
  clone: cloneCommand,
3234
4113
  registry: registryCommand,
@@ -3238,24 +4117,33 @@ const main = defineCommand({
3238
4117
  feedback: feedbackCommand,
3239
4118
  history: historyCommand,
3240
4119
  events: eventsCommand,
4120
+ lessons: lessonsCommand,
3241
4121
  agent: agentCommand,
3242
4122
  lint: lintCommand,
3243
4123
  improve: improveCommand,
4124
+ extract: extractCommand,
3244
4125
  propose: proposeCommand,
4126
+ proposal: proposalCommand,
4127
+ // Deprecated flat verbs (removed 0.9.0) — delegate to `proposal <verb>`.
3245
4128
  proposals: proposalsCommand,
3246
4129
  accept: acceptCommand,
3247
4130
  reject: rejectCommand,
3248
4131
  diff: diffCommand,
4132
+ revert: revertCommand,
3249
4133
  help: helpCommand,
3250
4134
  hints: hintsCommand,
3251
4135
  completions: completionsCommand,
4136
+ env: envCommand,
3252
4137
  vault: vaultCommand,
4138
+ secret: secretCommand,
3253
4139
  wiki: wikiCommand,
3254
4140
  tasks: tasksCommand,
3255
4141
  },
3256
4142
  });
3257
- const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset"]);
4143
+ const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "show", "get", "set", "unset", "enable", "disable"]);
4144
+ const ENV_SUBCOMMAND_SET = new Set(["list", "path", "export", "run", "create", "remove"]);
3258
4145
  const VAULT_SUBCOMMAND_SET = new Set(["list", "path", "run", "create", "set", "unset"]);
4146
+ const SECRET_SUBCOMMAND_SET = new Set(["list", "path", "run", "set", "remove"]);
3259
4147
  const WIKI_SUBCOMMAND_SET = new Set([
3260
4148
  "create",
3261
4149
  "register",
@@ -3269,74 +4157,90 @@ const WIKI_SUBCOMMAND_SET = new Set([
3269
4157
  "ingest",
3270
4158
  ]);
3271
4159
  // ── Exit codes ──────────────────────────────────────────────────────────────
3272
- const EXIT_GENERAL = 1;
3273
- const EXIT_USAGE = 2;
3274
- const EXIT_CONFIG = 78;
3275
- // citty reads process.argv directly and does not accept a custom argv array,
3276
- // so we must replace process.argv with the normalized version before runMain.
3277
- process.argv = normalizeShowArgv(process.argv);
3278
- // Resolve output mode once at startup from the (normalized) argv and persisted
3279
- // config. All subsequent output() calls read from this in-memory singleton.
3280
- // `initOutputMode` can throw a UsageError when --format/--detail values are
3281
- // invalid; surface it through the same JSON-error path the rest of the CLI uses
3282
- // rather than letting the raw exception escape with a stack trace.
3283
- try {
3284
- applyEarlyStderrFlags(process.argv);
3285
- initOutputMode(process.argv, loadConfig().output ?? {});
3286
- }
3287
- catch (error) {
3288
- const message = error instanceof Error ? error.message : String(error);
3289
- const hint = extractHint(error);
3290
- const exitCode = classifyExitCode(error);
3291
- const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
3292
- ? error.code
3293
- : undefined;
3294
- console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
3295
- process.exit(exitCode);
3296
- }
3297
- runMain(main);
3298
- function classifyExitCode(error) {
3299
- if (error instanceof UsageError)
3300
- return EXIT_USAGE;
3301
- if (error instanceof ConfigError)
3302
- return EXIT_CONFIG;
3303
- if (error instanceof NotFoundError)
3304
- return EXIT_GENERAL;
3305
- return EXIT_GENERAL;
3306
- }
3307
- async function runWithJsonErrors(fn) {
4160
+ // Canonical table lives in `src/cli/shared.ts` (EXIT_CODES). These aliases keep
4161
+ // the local call sites terse. EXIT_HEALTH_WARN (4) is the `akm health` "warn"
4162
+ // status advisories fired but no hard failure; chosen to avoid colliding with
4163
+ // GENERAL (1) and USAGE (2). CI monitors can map: 0=pass, 4=warn, 1=fail.
4164
+ const EXIT_GENERAL = EXIT_CODES.GENERAL;
4165
+ const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
4166
+ // Only run the CLI when this module is the direct entry point. When it is
4167
+ // imported (e.g. by the in-process test harness in tests/_helpers/cli.ts),
4168
+ // `import.meta.main` is false and we skip all startup side effects (argv
4169
+ // mutation, output-mode init, index cleanup, banner, runMain) so importers
4170
+ // can drive the `main` command themselves without the process exiting.
4171
+ if (import.meta.main) {
4172
+ // citty reads process.argv directly and does not accept a custom argv array,
4173
+ // so we must replace process.argv with the normalized version before runMain.
4174
+ process.argv = normalizeShowArgv(process.argv);
4175
+ // Resolve output mode once at startup from the (normalized) argv and persisted
4176
+ // config. All subsequent output() calls read from this in-memory singleton.
4177
+ // `initOutputMode` can throw a UsageError when --format/--detail values are
4178
+ // invalid; surface it through the same JSON-error path the rest of the CLI uses
4179
+ // rather than letting the raw exception escape with a stack trace.
3308
4180
  try {
3309
4181
  applyEarlyStderrFlags(process.argv);
3310
- await fn();
4182
+ initOutputMode(process.argv, loadConfig().output ?? {});
3311
4183
  }
3312
4184
  catch (error) {
3313
- const message = error instanceof Error ? error.message : String(error);
3314
- const hint = extractHint(error);
3315
- const exitCode = classifyExitCode(error);
3316
- // Surface machine-readable error code from typed errors when present so
3317
- // scripts can branch on `.code` instead of message-string matching.
3318
- const code = error instanceof UsageError || error instanceof ConfigError || error instanceof NotFoundError
3319
- ? error.code
3320
- : undefined;
3321
- console.error(JSON.stringify({ ok: false, error: message, ...(code ? { code } : {}), hint }, null, 2));
3322
- process.exit(exitCode);
4185
+ emitJsonError(error);
3323
4186
  }
3324
- }
3325
- /**
3326
- * Extract an actionable hint from an error instance. Hints live on the error
3327
- * classes themselves (see src/errors.ts) either supplied explicitly at the
3328
- * throw site, or derived from the error code via the per-class default mapping.
3329
- */
3330
- function extractHint(error) {
3331
- if (error instanceof Error && "hint" in error && typeof error.hint === "function") {
3332
- return error.hint();
4187
+ // `--shape summary` is only meaningful on `akm show`. Reject it up front for
4188
+ // every other command so a write command (e.g. `akm proposal accept …`)
4189
+ // fails fast BEFORE performing its mutation, rather than throwing at
4190
+ // output-shaping time after the side effect has already happened. The
4191
+ // shape-registry gate in shapeForCommand() remains as defense-in-depth (and
4192
+ // covers the in-process test harness, which skips this startup block).
4193
+ if (getOutputMode().shape === "summary" && process.argv[2] !== "show") {
4194
+ emitJsonError(new UsageError("'--shape summary' is only valid on 'akm show'.", "INVALID_SHAPE_VALUE"));
3333
4195
  }
3334
- return undefined;
4196
+ // One-time cleanup of stale 0.7.x index file at the old cache location.
4197
+ // 0.8.0 moved the index to $XDG_DATA_HOME/akm/index.db (getDataDir()).
4198
+ // If the old file exists at $XDG_CACHE_HOME/akm/index.db, remove it so the
4199
+ // user isn't confused by a phantom DB. Best-effort; never fatal.
4200
+ try {
4201
+ const oldIndexPath = path.join(getCacheDir(), "index.db");
4202
+ if (fs.existsSync(oldIndexPath)) {
4203
+ fs.rmSync(oldIndexPath, { force: true });
4204
+ fs.rmSync(`${oldIndexPath}-shm`, { force: true });
4205
+ fs.rmSync(`${oldIndexPath}-wal`, { force: true });
4206
+ warn(`Cleaned up stale 0.7.x index from ${oldIndexPath}. Canonical path is now ${getDbPath()}.`);
4207
+ }
4208
+ }
4209
+ catch {
4210
+ // Non-fatal; one-time warning only.
4211
+ }
4212
+ // First-time-user breadcrumb: when run with no subcommand AND no config
4213
+ // exists yet AND stderr is a TTY, print a friendly pointer to `akm setup`
4214
+ // above citty's auto-generated usage block. Triggers only when stdin/stderr
4215
+ // are interactive (so JSON-output users / CI consumers see nothing extra)
4216
+ // and stays silent for any flag-only invocation citty would handle itself
4217
+ // (--help, --version).
4218
+ (function maybePrintFirstTimeBanner() {
4219
+ const argv = process.argv.slice(2);
4220
+ // Fire only on completely bare `akm` invocation. Any explicit flag or
4221
+ // subcommand means the user knows what they want.
4222
+ if (argv.length > 0)
4223
+ return;
4224
+ if (!process.stderr.isTTY)
4225
+ return;
4226
+ try {
4227
+ if (fs.existsSync(getConfigPath()))
4228
+ return;
4229
+ }
4230
+ catch {
4231
+ // If we can't resolve the config path, assume non-fresh and stay silent.
4232
+ return;
4233
+ }
4234
+ console.error(plainize("👋 First time with akm? Run `akm setup` to get started.\n Docs: https://github.com/itlackey/akm#readme\n"));
4235
+ })();
4236
+ runMain(main);
3335
4237
  }
3336
4238
  // ── Hints (embedded AGENTS.md) ──────────────────────────────────────────────
3337
4239
  function loadHints(detail = "normal") {
3338
- const filename = detail === "full" ? "AGENTS.full.md" : "AGENTS.md";
3339
- const fallback = detail === "full" ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
4240
+ // `brief` the short AGENTS.md guide; `normal`/`full` → the complete guide.
4241
+ const wantFull = detail !== "brief";
4242
+ const filename = wantFull ? "AGENTS.full.md" : "AGENTS.md";
4243
+ const fallback = wantFull ? EMBEDDED_HINTS_FULL : EMBEDDED_HINTS;
3340
4244
  // Try reading from the docs/ directory (works in dev and when installed via npm)
3341
4245
  try {
3342
4246
  const docsPath = path.resolve(import.meta.dir ?? __dirname, `../docs/agents/${filename}`);