akm-cli 0.7.5 → 0.8.0-rc.6

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 (236) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
  2. package/README.md +20 -4
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli.js +1995 -551
  9. package/dist/commands/agent-dispatch.js +110 -0
  10. package/dist/commands/agent-support.js +68 -0
  11. package/dist/commands/completions.js +3 -0
  12. package/dist/commands/config-cli.js +130 -534
  13. package/dist/commands/consolidate.js +1531 -0
  14. package/dist/commands/curate.js +44 -3
  15. package/dist/commands/db-cli.js +23 -0
  16. package/dist/commands/distill-promotion-policy.js +660 -0
  17. package/dist/commands/distill.js +990 -75
  18. package/dist/commands/eval-cases.js +43 -0
  19. package/dist/commands/events.js +5 -23
  20. package/dist/commands/graph.js +477 -0
  21. package/dist/commands/health.js +400 -0
  22. package/dist/commands/help/help-accept.md +9 -0
  23. package/dist/commands/help/help-improve.md +77 -0
  24. package/dist/commands/help/help-proposals.md +15 -0
  25. package/dist/commands/help/help-propose.md +17 -0
  26. package/dist/commands/help/help-reject.md +8 -0
  27. package/dist/commands/history.js +54 -46
  28. package/dist/commands/improve-profiles.js +146 -0
  29. package/dist/commands/improve-result-file.js +103 -0
  30. package/dist/commands/improve.js +2175 -0
  31. package/dist/commands/info.js +5 -2
  32. package/dist/commands/init.js +50 -2
  33. package/dist/commands/installed-stashes.js +102 -139
  34. package/dist/commands/knowledge.js +136 -0
  35. package/dist/commands/lint/agent-linter.js +49 -0
  36. package/dist/commands/lint/base-linter.js +479 -0
  37. package/dist/commands/lint/command-linter.js +49 -0
  38. package/dist/commands/lint/default-linter.js +16 -0
  39. package/dist/commands/lint/index.js +183 -0
  40. package/dist/commands/lint/knowledge-linter.js +16 -0
  41. package/dist/commands/lint/markdown-insertion.js +343 -0
  42. package/dist/commands/lint/memory-linter.js +61 -0
  43. package/dist/commands/lint/registry.js +36 -0
  44. package/dist/commands/lint/skill-linter.js +45 -0
  45. package/dist/commands/lint/task-linter.js +50 -0
  46. package/dist/commands/lint/types.js +4 -0
  47. package/dist/commands/lint/vault-key-rules.js +139 -0
  48. package/dist/commands/lint/workflow-linter.js +56 -0
  49. package/dist/commands/lint.js +4 -0
  50. package/dist/commands/migration-help.js +5 -2
  51. package/dist/commands/proposal.js +66 -12
  52. package/dist/commands/propose.js +86 -31
  53. package/dist/commands/reflect.js +1119 -73
  54. package/dist/commands/registry-search.js +5 -2
  55. package/dist/commands/remember.js +69 -6
  56. package/dist/commands/schema-repair.js +203 -0
  57. package/dist/commands/search.js +115 -14
  58. package/dist/commands/self-update.js +3 -0
  59. package/dist/commands/show.js +144 -25
  60. package/dist/commands/source-add.js +17 -45
  61. package/dist/commands/source-clone.js +3 -0
  62. package/dist/commands/source-manage.js +14 -19
  63. package/dist/commands/tasks.js +438 -0
  64. package/dist/commands/url-checker.js +42 -0
  65. package/dist/commands/vault.js +130 -77
  66. package/dist/core/action-contributors.js +28 -0
  67. package/dist/core/asset-ref.js +7 -0
  68. package/dist/core/asset-registry.js +7 -16
  69. package/dist/core/asset-serialize.js +88 -0
  70. package/dist/core/asset-spec.js +22 -0
  71. package/dist/core/common.js +157 -0
  72. package/dist/core/concurrent.js +25 -0
  73. package/dist/core/config-io.js +347 -0
  74. package/dist/core/config-migration.js +625 -0
  75. package/dist/core/config-schema.js +501 -0
  76. package/dist/core/config-sources.js +108 -0
  77. package/dist/core/config-types.js +4 -0
  78. package/dist/core/config-walker.js +337 -0
  79. package/dist/core/config.js +327 -987
  80. package/dist/core/errors.js +40 -19
  81. package/dist/core/events.js +91 -138
  82. package/dist/core/file-lock.js +104 -0
  83. package/dist/core/frontmatter.js +3 -6
  84. package/dist/core/lesson-lint.js +3 -0
  85. package/dist/core/markdown.js +20 -0
  86. package/dist/core/memory-belief.js +62 -0
  87. package/dist/core/memory-contradiction-detect.js +274 -0
  88. package/dist/core/memory-improve.js +806 -0
  89. package/dist/core/parse.js +158 -0
  90. package/dist/core/paths.js +326 -14
  91. package/dist/core/proposal-quality-validators.js +364 -0
  92. package/dist/core/proposal-validators.js +69 -0
  93. package/dist/core/proposals.js +498 -42
  94. package/dist/core/state-db.js +927 -0
  95. package/dist/core/text-truncation.js +107 -0
  96. package/dist/core/time.js +54 -0
  97. package/dist/core/warn.js +62 -1
  98. package/dist/core/write-source.js +3 -0
  99. package/dist/indexer/db-backup.js +391 -0
  100. package/dist/indexer/db-search.js +152 -253
  101. package/dist/indexer/db.js +933 -103
  102. package/dist/indexer/ensure-index.js +64 -0
  103. package/dist/indexer/file-context.js +3 -0
  104. package/dist/indexer/graph-boost.js +376 -101
  105. package/dist/indexer/graph-db.js +391 -0
  106. package/dist/indexer/graph-dedup.js +95 -0
  107. package/dist/indexer/graph-extraction.js +550 -124
  108. package/dist/indexer/index-context.js +4 -0
  109. package/dist/indexer/indexer.js +506 -291
  110. package/dist/indexer/llm-cache.js +47 -0
  111. package/dist/indexer/manifest.js +3 -0
  112. package/dist/indexer/matchers.js +148 -160
  113. package/dist/indexer/memory-inference.js +99 -74
  114. package/dist/indexer/metadata-contributors.js +29 -0
  115. package/dist/indexer/metadata.js +255 -196
  116. package/dist/indexer/path-resolver.js +92 -0
  117. package/dist/indexer/project-context.js +192 -0
  118. package/dist/indexer/ranking-contributors.js +331 -0
  119. package/dist/indexer/ranking.js +81 -0
  120. package/dist/indexer/search-fields.js +5 -9
  121. package/dist/indexer/search-hit-enrichers.js +111 -0
  122. package/dist/indexer/search-source.js +44 -10
  123. package/dist/indexer/semantic-status.js +5 -16
  124. package/dist/indexer/staleness-detect.js +447 -0
  125. package/dist/indexer/usage-events.js +12 -9
  126. package/dist/indexer/walker.js +28 -0
  127. package/dist/integrations/agent/builders.js +135 -0
  128. package/dist/integrations/agent/config.js +122 -230
  129. package/dist/integrations/agent/detect.js +3 -0
  130. package/dist/integrations/agent/index.js +7 -13
  131. package/dist/integrations/agent/model-aliases.js +55 -0
  132. package/dist/integrations/agent/profiles.js +70 -5
  133. package/dist/integrations/agent/prompts.js +150 -74
  134. package/dist/integrations/agent/runner.js +151 -0
  135. package/dist/integrations/agent/sdk-runner.js +126 -0
  136. package/dist/integrations/agent/spawn.js +118 -23
  137. package/dist/integrations/github.js +3 -0
  138. package/dist/integrations/lockfile.js +32 -69
  139. package/dist/integrations/session-logs/index.js +68 -0
  140. package/dist/integrations/session-logs/providers/claude-code.js +59 -0
  141. package/dist/integrations/session-logs/providers/opencode.js +55 -0
  142. package/dist/integrations/session-logs/types.js +4 -0
  143. package/dist/llm/call-ai.js +62 -0
  144. package/dist/llm/client.js +72 -124
  145. package/dist/llm/embedder.js +3 -19
  146. package/dist/llm/embedders/cache.js +3 -7
  147. package/dist/llm/embedders/local.js +3 -0
  148. package/dist/llm/embedders/remote.js +20 -8
  149. package/dist/llm/embedders/types.js +3 -7
  150. package/dist/llm/feature-gate.js +89 -48
  151. package/dist/llm/graph-extract.js +676 -70
  152. package/dist/llm/index-passes.js +9 -23
  153. package/dist/llm/memory-infer.js +52 -71
  154. package/dist/llm/metadata-enhance.js +42 -29
  155. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  156. package/dist/output/cli-hints-full.md +281 -0
  157. package/dist/output/cli-hints-short.md +65 -0
  158. package/dist/output/cli-hints.js +5 -318
  159. package/dist/output/context.js +3 -0
  160. package/dist/output/renderers.js +223 -256
  161. package/dist/output/shapes.js +150 -105
  162. package/dist/output/text.js +318 -30
  163. package/dist/registry/build-index.js +3 -0
  164. package/dist/registry/create-provider-registry.js +3 -0
  165. package/dist/registry/factory.js +3 -0
  166. package/dist/registry/origin-resolve.js +3 -0
  167. package/dist/registry/providers/index.js +3 -0
  168. package/dist/registry/providers/skills-sh.js +70 -49
  169. package/dist/registry/providers/static-index.js +53 -48
  170. package/dist/registry/providers/types.js +3 -24
  171. package/dist/registry/resolve.js +11 -16
  172. package/dist/registry/types.js +3 -0
  173. package/dist/scripts/migrate-storage.js +17307 -0
  174. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
  175. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  176. package/dist/setup/detect.js +3 -0
  177. package/dist/setup/ripgrep-install.js +3 -0
  178. package/dist/setup/ripgrep-resolve.js +3 -0
  179. package/dist/setup/setup.js +775 -37
  180. package/dist/setup/steps.js +3 -15
  181. package/dist/sources/include.js +3 -0
  182. package/dist/sources/provider-factory.js +5 -12
  183. package/dist/sources/provider.js +3 -20
  184. package/dist/sources/providers/filesystem.js +19 -23
  185. package/dist/sources/providers/git.js +7 -5
  186. package/dist/sources/providers/index.js +3 -0
  187. package/dist/sources/providers/install-types.js +3 -13
  188. package/dist/sources/providers/npm.js +3 -4
  189. package/dist/sources/providers/provider-utils.js +3 -0
  190. package/dist/sources/providers/sync-from-ref.js +3 -11
  191. package/dist/sources/providers/tar-utils.js +3 -0
  192. package/dist/sources/providers/website.js +18 -22
  193. package/dist/sources/resolve.js +3 -0
  194. package/dist/sources/types.js +3 -0
  195. package/dist/sources/website-ingest.js +7 -0
  196. package/dist/tasks/backends/cron.js +203 -0
  197. package/dist/tasks/backends/exec-utils.js +28 -0
  198. package/dist/tasks/backends/index.js +24 -0
  199. package/dist/tasks/backends/launchd-template.xml +19 -0
  200. package/dist/tasks/backends/launchd.js +187 -0
  201. package/dist/tasks/backends/schtasks-template.xml +29 -0
  202. package/dist/tasks/backends/schtasks.js +215 -0
  203. package/dist/tasks/parser.js +211 -0
  204. package/dist/tasks/resolveAkmBin.js +87 -0
  205. package/dist/tasks/runner.js +458 -0
  206. package/dist/tasks/schedule.js +211 -0
  207. package/dist/tasks/schema.js +15 -0
  208. package/dist/tasks/validator.js +62 -0
  209. package/dist/version.js +3 -0
  210. package/dist/wiki/index-template.md +12 -0
  211. package/dist/wiki/ingest-workflow-template.md +54 -0
  212. package/dist/wiki/log-template.md +8 -0
  213. package/dist/wiki/schema-template.md +61 -0
  214. package/dist/wiki/wiki-templates.js +15 -0
  215. package/dist/wiki/wiki.js +13 -61
  216. package/dist/workflows/authoring.js +8 -25
  217. package/dist/workflows/cli.js +3 -0
  218. package/dist/workflows/db.js +140 -10
  219. package/dist/workflows/document-cache.js +3 -10
  220. package/dist/workflows/parser.js +3 -0
  221. package/dist/workflows/renderer.js +11 -3
  222. package/dist/workflows/runs.js +62 -91
  223. package/dist/workflows/schema.js +3 -0
  224. package/dist/workflows/scope-key.js +3 -0
  225. package/dist/workflows/validator.js +4 -8
  226. package/dist/workflows/workflow-template.md +24 -0
  227. package/docs/README.md +9 -2
  228. package/docs/data-and-telemetry.md +225 -0
  229. package/docs/migration/release-notes/0.7.0.md +1 -1
  230. package/docs/migration/release-notes/0.7.5.md +2 -2
  231. package/docs/migration/release-notes/0.8.0.md +48 -0
  232. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  233. package/package.json +20 -8
  234. package/.github/LICENSE +0 -374
  235. package/dist/commands/install-audit.js +0 -381
  236. package/dist/templates/wiki-templates.js +0 -100
@@ -1,6 +1,9 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  import fs from "node:fs";
2
5
  import { getAssetTypes } from "../core/asset-spec";
3
- import { loadConfig } from "../core/config";
6
+ import { getSources, loadConfig } from "../core/config";
4
7
  import { getDbPath } from "../core/paths";
5
8
  import { closeDatabase, getEntryCount, getMeta, isVecAvailable, openExistingDatabase } from "../indexer/db";
6
9
  import { getEffectiveSemanticStatus, readSemanticStatus } from "../indexer/semantic-status";
@@ -32,7 +35,7 @@ export function assembleInfo(options) {
32
35
  // Stash providers — prefer `sources[]`; fall back to `stashDir` when the
33
36
  // user has not yet migrated to the sources[] config shape so that info
34
37
  // always reflects at least one provider when a stash is configured.
35
- const configuredSources = config.sources ?? config.stashes ?? [];
38
+ const configuredSources = getSources(config);
36
39
  const stashesList = configuredSources.length === 0 && config.stashDir
37
40
  ? [{ type: "filesystem", path: config.stashDir, name: "primary" }]
38
41
  : configuredSources;
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * akm initialization logic.
3
6
  *
@@ -8,11 +11,56 @@ import { spawnSync } from "node:child_process";
8
11
  import fs from "node:fs";
9
12
  import path from "node:path";
10
13
  import { TYPE_DIRS } from "../core/asset-spec";
11
- import { getConfigPath, loadUserConfig, saveConfig } from "../core/config";
12
- import { getBinDir, getDefaultStashDir } from "../core/paths";
14
+ import { loadUserConfig, saveConfig } from "../core/config";
15
+ import { ConfigError } from "../core/errors";
16
+ import { assertSafeStashDir, getBinDir, getConfigPath, getDefaultStashDir } from "../core/paths";
13
17
  import { ensureRg } from "../setup/ripgrep-install";
18
+ /**
19
+ * Refuse to persist a temporary-directory stashDir to the user's config when
20
+ * running under a test runner AND `--dir <tempdir>` was passed explicitly.
21
+ * This guard targets the exact agent-overreach pattern documented in
22
+ * `memory:akm-init-persists-stashdir-warning`: an agent ran
23
+ * `akm init --dir $(mktemp -d)` for an E2E test and silently rewrote the
24
+ * developer's real config to point at a now-deleted temp dir.
25
+ *
26
+ * Tests that legitimately resolve a tempdir via HOME (default-path init) are
27
+ * unaffected — those are normal `~/akm` resolutions and not the failure mode.
28
+ *
29
+ * Test sentinels (either suffices):
30
+ * - `BUN_TEST=1` — explicit opt-in
31
+ * - `NODE_ENV=test` — what `bun test` sets today
32
+ *
33
+ * Tests that genuinely need to exercise `akm init --dir /tmp/...` should set
34
+ * `AKM_FORCE_INIT_TMP_STASH=1`.
35
+ */
36
+ function assertInitSandbox(stashDir, dirExplicitlyProvided) {
37
+ if (!dirExplicitlyProvided)
38
+ return; // Only guard explicit --dir, not default HOME resolution.
39
+ const isUnderTest = process.env.BUN_TEST === "1" || process.env.NODE_ENV === "test";
40
+ if (!isUnderTest)
41
+ return;
42
+ if (process.env.AKM_FORCE_INIT_TMP_STASH === "1")
43
+ return;
44
+ const isTmp = stashDir.startsWith("/tmp/") ||
45
+ stashDir === "/tmp" ||
46
+ stashDir.startsWith("/var/tmp/") ||
47
+ stashDir === "/var/tmp" ||
48
+ stashDir.startsWith("/private/var/folders/") ||
49
+ stashDir.startsWith("/private/tmp/");
50
+ if (!isTmp)
51
+ return;
52
+ throw new ConfigError(`refusing to persist --dir stashDir to a temporary path while under test runner; set AKM_FORCE_INIT_TMP_STASH=1 if you really mean it (stashDir=${stashDir})`, "INIT_TMP_STASH_REFUSED");
53
+ }
14
54
  export async function akmInit(options) {
15
55
  const stashDir = options?.dir ? path.resolve(options.dir) : getDefaultStashDir();
56
+ // Safety check (#473): refuse stashDir at /, $HOME, /etc, ~/.config, etc.
57
+ // Runs BEFORE any disk write — a fat-fingered `akm init --dir /` or
58
+ // `akm init --dir ~` would otherwise mkdir + git-init the user's system
59
+ // root or home directory. Catastrophic-on-misuse vs. trivial-to-recover-from.
60
+ assertSafeStashDir(stashDir);
61
+ // Defense-in-depth: refuse to persist an explicit --dir /tmp/... stashDir
62
+ // to config under a test runner. Default HOME-resolved paths are exempt.
63
+ assertInitSandbox(stashDir, options?.dir != null);
16
64
  let created = false;
17
65
  if (!fs.existsSync(stashDir)) {
18
66
  fs.mkdirSync(stashDir, { recursive: true });
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Source operations: list, remove, update.
3
6
  *
@@ -7,7 +10,7 @@
7
10
  import fs from "node:fs";
8
11
  import path from "node:path";
9
12
  import { isWithin, resolveStashDir } from "../core/common";
10
- import { loadConfig } from "../core/config";
13
+ import { getSources, loadConfig } from "../core/config";
11
14
  import { NotFoundError, UsageError } from "../core/errors";
12
15
  import { akmIndex } from "../indexer/indexer";
13
16
  import { removeLockEntry, upsertLockEntry } from "../integrations/lockfile";
@@ -16,7 +19,6 @@ import { parseGitRepoUrl, syncMirroredRepo } from "../sources/providers/git";
16
19
  import { syncFromRef } from "../sources/providers/sync-from-ref";
17
20
  import { ensureWebsiteMirror } from "../sources/website-ingest";
18
21
  import { listWikis, resolveWikisRoot } from "../wiki/wiki";
19
- import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
20
22
  import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./source-add";
21
23
  import { removeStash } from "./source-manage";
22
24
  export async function akmListSources(input) {
@@ -26,7 +28,7 @@ export async function akmListSources(input) {
26
28
  const sources = [];
27
29
  // Stash entries — each entry exposes its provider type as kind (spec §2.1).
28
30
  // Writable defaults: true for filesystem, false for git/npm/website (CLAUDE.md "Writes").
29
- for (const stash of config.sources ?? config.stashes ?? []) {
31
+ for (const stash of getSources(config)) {
30
32
  const kind = stash.type ?? "filesystem";
31
33
  if (kindFilter && !kindFilter.includes(kind))
32
34
  continue;
@@ -119,7 +121,7 @@ export async function akmRemove(input) {
119
121
  stashRoot: entry.stashRoot,
120
122
  },
121
123
  config: {
122
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
124
+ sourceCount: getSources(updatedConfig).length,
123
125
  installedKitCount: updatedConfig.installed?.length ?? 0,
124
126
  },
125
127
  index: {
@@ -150,7 +152,7 @@ export async function akmRemove(input) {
150
152
  stashRoot: removedEntry.path ?? "",
151
153
  },
152
154
  config: {
153
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
155
+ sourceCount: getSources(updatedConfig).length,
154
156
  installedKitCount: updatedConfig.installed?.length ?? 0,
155
157
  },
156
158
  index: {
@@ -161,6 +163,91 @@ export async function akmRemove(input) {
161
163
  },
162
164
  };
163
165
  }
166
+ // ── akmUpdate helpers ────────────────────────────────────────────────────────
167
+ /** Build a standard UpdateResponse summary block from the current config and index run. */
168
+ async function buildUpdateResponse(stashDir, target, all, processed, full = false) {
169
+ const index = await akmIndex({ stashDir, ...(full ? { full: true } : {}) });
170
+ const finalConfig = loadConfig();
171
+ return {
172
+ schemaVersion: 1,
173
+ stashDir,
174
+ target,
175
+ all,
176
+ processed,
177
+ config: {
178
+ sourceCount: getSources(finalConfig).length,
179
+ installedKitCount: finalConfig.installed?.length ?? 0,
180
+ },
181
+ index: {
182
+ mode: index.mode,
183
+ totalEntries: index.totalEntries,
184
+ directoriesScanned: index.directoriesScanned,
185
+ directoriesSkipped: index.directoriesSkipped,
186
+ },
187
+ };
188
+ }
189
+ /** Sync a git-mirrored source and return an UpdateResponse. */
190
+ async function updateGitSource(stashDir, target, all, gitSource) {
191
+ await syncMirroredRepo(gitSource, { force: true, writable: gitSource.writable === true });
192
+ return buildUpdateResponse(stashDir, target, all, [], true);
193
+ }
194
+ /** Re-crawl a website source and return an UpdateResponse. */
195
+ async function updateWebsiteSource(stashDir, target, all, websiteSource) {
196
+ // TODO: full incremental re-crawl with delta tracking (#19)
197
+ await ensureWebsiteMirror(websiteSource, { requireStashDir: true, force: true });
198
+ return buildUpdateResponse(stashDir, target, all, []);
199
+ }
200
+ /** Sync a single installed registry entry and return the processed record. */
201
+ async function updateRegistryEntry(entry, force) {
202
+ if (force && shouldCleanupCache(entry)) {
203
+ cleanupDirectoryBestEffort(entry.cacheDir);
204
+ }
205
+ const synced = await syncFromRef(entry.ref, { force });
206
+ const installedEntry = {
207
+ id: synced.id,
208
+ source: synced.source,
209
+ ref: synced.ref,
210
+ artifactUrl: synced.artifactUrl,
211
+ resolvedVersion: synced.resolvedVersion,
212
+ resolvedRevision: synced.resolvedRevision,
213
+ stashRoot: synced.contentDir,
214
+ cacheDir: synced.cacheDir,
215
+ installedAt: synced.syncedAt,
216
+ writable: synced.writable ?? entry.writable,
217
+ ...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
218
+ };
219
+ upsertInstalledRegistryEntry(installedEntry);
220
+ await upsertLockEntry({
221
+ id: synced.id,
222
+ source: synced.source,
223
+ ref: synced.ref,
224
+ resolvedVersion: synced.resolvedVersion,
225
+ resolvedRevision: synced.resolvedRevision,
226
+ integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
227
+ });
228
+ if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
229
+ cleanupDirectoryBestEffort(entry.cacheDir);
230
+ }
231
+ const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
232
+ const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
233
+ return {
234
+ id: entry.id,
235
+ source: entry.source,
236
+ ref: entry.ref,
237
+ previous: {
238
+ resolvedVersion: entry.resolvedVersion,
239
+ resolvedRevision: entry.resolvedRevision,
240
+ cacheDir: entry.cacheDir,
241
+ },
242
+ installed: { ...installedEntry, extractedDir: synced.extractedDir },
243
+ changed: {
244
+ version: versionChanged,
245
+ revision: revisionChanged,
246
+ any: versionChanged || revisionChanged,
247
+ },
248
+ };
249
+ }
250
+ // ── akmUpdate dispatcher ─────────────────────────────────────────────────────
164
251
  export async function akmUpdate(input) {
165
252
  const stashDir = input?.stashDir ?? resolveStashDir();
166
253
  const target = input?.target?.trim();
@@ -168,10 +255,10 @@ export async function akmUpdate(input) {
168
255
  const force = input?.force === true;
169
256
  const config = loadConfig();
170
257
  const installedEntries = config.installed ?? [];
171
- // Check if the target refers to a website source — those are syncable via
172
- // ensureWebsiteMirror and are stored in sources[] not installed[].
258
+ // Check if the target refers to a git or website source — those are stored
259
+ // in sources[] not installed[] and need a different update path.
173
260
  if (target && !all) {
174
- const stashes = config.sources ?? config.stashes ?? [];
261
+ const stashes = getSources(config);
175
262
  const isUrl = target.startsWith("http://") || target.startsWith("https://");
176
263
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
177
264
  const gitMatch = stashes.find((s) => {
@@ -195,28 +282,8 @@ export async function akmUpdate(input) {
195
282
  }
196
283
  return false;
197
284
  });
198
- if (gitMatch) {
199
- await syncMirroredRepo(gitMatch, { force: true, writable: gitMatch.writable === true });
200
- const index = await akmIndex({ stashDir, full: true });
201
- const updatedConfig = loadConfig();
202
- return {
203
- schemaVersion: 1,
204
- stashDir,
205
- target,
206
- all,
207
- processed: [],
208
- config: {
209
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
210
- installedKitCount: updatedConfig.installed?.length ?? 0,
211
- },
212
- index: {
213
- mode: index.mode,
214
- totalEntries: index.totalEntries,
215
- directoriesScanned: index.directoriesScanned,
216
- directoriesSkipped: index.directoriesSkipped,
217
- },
218
- };
219
- }
285
+ if (gitMatch)
286
+ return updateGitSource(stashDir, target, all, gitMatch);
220
287
  const websiteMatch = stashes.find((s) => {
221
288
  if (s.type !== "website")
222
289
  return false;
@@ -228,119 +295,15 @@ export async function akmUpdate(input) {
228
295
  return true;
229
296
  return false;
230
297
  });
231
- if (websiteMatch) {
232
- // TODO: full incremental re-crawl with delta tracking (#19)
233
- await ensureWebsiteMirror(websiteMatch, { requireStashDir: true, force: true });
234
- const index = await akmIndex({ stashDir });
235
- const updatedConfig = loadConfig();
236
- return {
237
- schemaVersion: 1,
238
- stashDir,
239
- target,
240
- all,
241
- processed: [],
242
- config: {
243
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
244
- installedKitCount: updatedConfig.installed?.length ?? 0,
245
- },
246
- index: {
247
- mode: index.mode,
248
- totalEntries: index.totalEntries,
249
- directoriesScanned: index.directoriesScanned,
250
- directoriesSkipped: index.directoriesSkipped,
251
- },
252
- };
253
- }
298
+ if (websiteMatch)
299
+ return updateWebsiteSource(stashDir, target, all, websiteMatch);
254
300
  }
255
301
  const selectedEntries = selectTargets(installedEntries, target, all);
256
- const auditConfig = config;
257
302
  const processed = [];
258
303
  for (const entry of selectedEntries) {
259
- if (force && shouldCleanupCache(entry)) {
260
- cleanupDirectoryBestEffort(entry.cacheDir);
261
- }
262
- const synced = await syncFromRef(entry.ref, { force });
263
- // Mirror the post-sync audit hook from akmAdd so `akm update` can't
264
- // silently land malicious content during refresh.
265
- const registryLabels = deriveRegistryLabels({
266
- source: synced.source,
267
- ref: synced.ref,
268
- artifactUrl: synced.artifactUrl,
269
- });
270
- enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
271
- const audit = auditInstallCandidate({
272
- rootDir: synced.extractedDir,
273
- source: synced.source,
274
- ref: synced.ref,
275
- registryLabels,
276
- config: auditConfig,
277
- });
278
- if (audit.blocked) {
279
- throw new Error(formatInstallAuditFailure(synced.ref, audit));
280
- }
281
- const installedEntry = {
282
- id: synced.id,
283
- source: synced.source,
284
- ref: synced.ref,
285
- artifactUrl: synced.artifactUrl,
286
- resolvedVersion: synced.resolvedVersion,
287
- resolvedRevision: synced.resolvedRevision,
288
- stashRoot: synced.contentDir,
289
- cacheDir: synced.cacheDir,
290
- installedAt: synced.syncedAt,
291
- writable: synced.writable ?? entry.writable,
292
- ...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
293
- };
294
- upsertInstalledRegistryEntry(installedEntry);
295
- await upsertLockEntry({
296
- id: synced.id,
297
- source: synced.source,
298
- ref: synced.ref,
299
- resolvedVersion: synced.resolvedVersion,
300
- resolvedRevision: synced.resolvedRevision,
301
- integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
302
- });
303
- if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
304
- cleanupDirectoryBestEffort(entry.cacheDir);
305
- }
306
- const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
307
- const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
308
- processed.push({
309
- id: entry.id,
310
- source: entry.source,
311
- ref: entry.ref,
312
- previous: {
313
- resolvedVersion: entry.resolvedVersion,
314
- resolvedRevision: entry.resolvedRevision,
315
- cacheDir: entry.cacheDir,
316
- },
317
- installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
318
- changed: {
319
- version: versionChanged,
320
- revision: revisionChanged,
321
- any: versionChanged || revisionChanged,
322
- },
323
- });
304
+ processed.push(await updateRegistryEntry(entry, force));
324
305
  }
325
- const index = await akmIndex({ stashDir });
326
- const finalConfig = loadConfig();
327
- return {
328
- schemaVersion: 1,
329
- stashDir,
330
- target,
331
- all,
332
- processed,
333
- config: {
334
- sourceCount: (finalConfig.sources ?? finalConfig.stashes ?? []).length,
335
- installedKitCount: finalConfig.installed?.length ?? 0,
336
- },
337
- index: {
338
- mode: index.mode,
339
- totalEntries: index.totalEntries,
340
- directoriesScanned: index.directoriesScanned,
341
- directoriesSkipped: index.directoriesSkipped,
342
- },
343
- };
306
+ return buildUpdateResponse(stashDir, target, all, processed);
344
307
  }
345
308
  function selectTargets(installed, target, all) {
346
309
  if (all && target) {
@@ -356,7 +319,7 @@ function selectTargets(installed, target, all) {
356
319
  return [found];
357
320
  // Check if target matches a stash source and give a helpful message
358
321
  const config = loadConfig();
359
- const stashes = config.sources ?? config.stashes ?? [];
322
+ const stashes = getSources(config);
360
323
  const isUrl = target.startsWith("http://") || target.startsWith("https://");
361
324
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
362
325
  const stashMatch = stashes.find((s) => {
@@ -0,0 +1,136 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Knowledge-command helpers extracted from `src/cli.ts`.
6
+ *
7
+ * Covers the shared pipeline for reading, naming, and writing markdown assets
8
+ * (knowledge and memory) from the CLI. Extracted to keep the CLI entry point
9
+ * focused on command definitions and routing.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { resolveAssetPathFromName } from "../core/asset-spec";
14
+ import { isHttpUrl, isWithin, tryReadStdinText } from "../core/common";
15
+ import { loadConfig } from "../core/config";
16
+ import { UsageError } from "../core/errors";
17
+ import { resolveWriteTarget, writeAssetToSource } from "../core/write-source";
18
+ import { fetchWebsiteMarkdownSnapshot } from "../sources/website-ingest";
19
+ const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
20
+ // ── Asset-name normalisation ─────────────────────────────────────────────────
21
+ /**
22
+ * Validate and normalise a markdown asset name supplied by the user.
23
+ *
24
+ * Strips the `.md` extension, rejects empty names, and guards against path
25
+ * traversal (`..` segments). The `fallback` is used when `name` is undefined.
26
+ */
27
+ export function normalizeMarkdownAssetName(name, fallback) {
28
+ const trimmed = (name ?? fallback)
29
+ .trim()
30
+ .replace(/\\/g, "/")
31
+ .replace(/^\/+|\/+$/g, "")
32
+ .replace(/\.md$/i, "");
33
+ if (!trimmed)
34
+ throw new UsageError("Asset name cannot be empty.");
35
+ const segments = trimmed.split("/");
36
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
37
+ throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
38
+ }
39
+ return trimmed;
40
+ }
41
+ function slugifyAssetName(value, fallbackPrefix) {
42
+ const slug = value
43
+ .toLowerCase()
44
+ .replace(/^[#>\-\s]+/, "")
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
48
+ return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
49
+ }
50
+ /**
51
+ * Derive a slug-style asset name from `content` and an optional `preferred`
52
+ * hint (e.g. a URL-derived page title or the source filename stem).
53
+ */
54
+ export function inferAssetName(content, fallbackPrefix, preferred) {
55
+ const firstNonEmptyLine = content
56
+ .split(/\r?\n/)
57
+ .map((line) => line.trim())
58
+ .find((line) => line.length > 0);
59
+ const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
60
+ return slugifyAssetName(basis, fallbackPrefix);
61
+ }
62
+ // ── Content reading ──────────────────────────────────────────────────────────
63
+ /**
64
+ * Read knowledge content from a local file path or stdin (`"-"`).
65
+ *
66
+ * Returns the raw text and an optional `preferredName` derived from the
67
+ * source filename stem (used as a slug fallback when no `--name` flag was
68
+ * supplied).
69
+ */
70
+ export function readKnowledgeContent(source) {
71
+ if (source === "-") {
72
+ const content = tryReadStdinText();
73
+ if (!content?.trim()) {
74
+ throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
75
+ }
76
+ return { content };
77
+ }
78
+ const resolvedSource = path.resolve(source);
79
+ let stat;
80
+ try {
81
+ stat = fs.statSync(resolvedSource);
82
+ }
83
+ catch {
84
+ throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
85
+ }
86
+ if (!stat.isFile()) {
87
+ throw new UsageError(`Knowledge source must be a file: "${source}".`);
88
+ }
89
+ return {
90
+ content: fs.readFileSync(resolvedSource, "utf8"),
91
+ preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
92
+ };
93
+ }
94
+ /**
95
+ * Read knowledge content from a local path, stdin (`"-"`), or a remote URL.
96
+ *
97
+ * URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
98
+ * to `readKnowledgeContent`.
99
+ */
100
+ export async function readKnowledgeInput(source) {
101
+ if (!isHttpUrl(source))
102
+ return readKnowledgeContent(source);
103
+ const snapshot = await fetchWebsiteMarkdownSnapshot(source);
104
+ return { content: snapshot.content, preferredName: snapshot.preferredName };
105
+ }
106
+ // ── Asset writing ────────────────────────────────────────────────────────────
107
+ /**
108
+ * Write a markdown asset (knowledge or memory) to the resolved write target.
109
+ *
110
+ * Resolves the write target via the v1 precedence chain (`--target` →
111
+ * `defaultWriteTarget` → working stash), validates the path is within the
112
+ * type root, enforces `--force` semantics, and delegates the actual write
113
+ * to `writeAssetToSource`.
114
+ */
115
+ export async function writeMarkdownAsset(options) {
116
+ const cfg = loadConfig();
117
+ const { source, config } = resolveWriteTarget(cfg, options.target);
118
+ const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
119
+ const normalizedName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
120
+ // Pre-flight: existence + force semantics. The helper itself overwrites
121
+ // unconditionally; the CLI surfaces a friendlier UsageError before any
122
+ // disk activity when --force is absent.
123
+ const assetPath = resolveAssetPathFromName(options.type, typeRoot, normalizedName);
124
+ if (!isWithin(assetPath, typeRoot)) {
125
+ throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
126
+ }
127
+ if (fs.existsSync(assetPath) && !options.force) {
128
+ throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
129
+ }
130
+ const result = await writeAssetToSource(source, config, { type: options.type, name: normalizedName }, options.content);
131
+ return {
132
+ ref: result.ref,
133
+ path: result.path,
134
+ stashDir: source.path,
135
+ };
136
+ }
@@ -0,0 +1,49 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import path from "node:path";
5
+ import { BaseLinter } from "./base-linter";
6
+ /**
7
+ * Linter for `agents/` assets.
8
+ *
9
+ * Extra check beyond base:
10
+ * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
11
+ * absent. Not auto-fixable; detail includes a suggested slug.
12
+ */
13
+ export class AgentLinter extends BaseLinter {
14
+ types = ["agents"];
15
+ lint(ctx) {
16
+ const issues = this.runBaseChecks(ctx);
17
+ const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
18
+ if (missingFieldDetail) {
19
+ const slug = this.#suggestSlug(ctx.filePath);
20
+ issues.push({
21
+ file: ctx.relPath,
22
+ issue: "missing-name-or-type",
23
+ detail: `${missingFieldDetail}; suggested slug: ${slug}`,
24
+ fixed: false,
25
+ });
26
+ }
27
+ return issues;
28
+ }
29
+ #checkMissingNameOrType(data, frontmatterText) {
30
+ if (!frontmatterText)
31
+ return null;
32
+ const missingFields = [];
33
+ if (!("name" in data) || !data.name)
34
+ missingFields.push("name");
35
+ if (!("type" in data) || !data.type)
36
+ missingFields.push("type");
37
+ if (missingFields.length === 0)
38
+ return null;
39
+ return `missing fields: ${missingFields.join(", ")}`;
40
+ }
41
+ #suggestSlug(filePath) {
42
+ return path
43
+ .basename(filePath, ".md")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9-]+/g, "-")
46
+ .replace(/-+/g, "-")
47
+ .replace(/^-|-$/g, "");
48
+ }
49
+ }