akm-cli 0.7.5 → 0.8.0-rc2

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 (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { getAssetTypes } from "../core/asset-spec";
3
- import { loadConfig } from "../core/config";
3
+ import { getSources, loadConfig } from "../core/config";
4
4
  import { getDbPath } from "../core/paths";
5
5
  import { closeDatabase, getEntryCount, getMeta, isVecAvailable, openExistingDatabase } from "../indexer/db";
6
6
  import { getEffectiveSemanticStatus, readSemanticStatus } from "../indexer/semantic-status";
@@ -32,7 +32,7 @@ export function assembleInfo(options) {
32
32
  // Stash providers — prefer `sources[]`; fall back to `stashDir` when the
33
33
  // user has not yet migrated to the sources[] config shape so that info
34
34
  // always reflects at least one provider when a stash is configured.
35
- const configuredSources = config.sources ?? config.stashes ?? [];
35
+ const configuredSources = getSources(config);
36
36
  const stashesList = configuredSources.length === 0 && config.stashDir
37
37
  ? [{ type: "filesystem", path: config.stashDir, name: "primary" }]
38
38
  : configuredSources;
@@ -8,8 +8,8 @@ import { spawnSync } from "node:child_process";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { TYPE_DIRS } from "../core/asset-spec";
11
- import { getConfigPath, loadUserConfig, saveConfig } from "../core/config";
12
- import { getBinDir, getDefaultStashDir } from "../core/paths";
11
+ import { loadUserConfig, saveConfig } from "../core/config";
12
+ import { getBinDir, getConfigPath, getDefaultStashDir } from "../core/paths";
13
13
  import { ensureRg } from "../setup/ripgrep-install";
14
14
  export async function akmInit(options) {
15
15
  const stashDir = options?.dir ? path.resolve(options.dir) : getDefaultStashDir();
@@ -149,6 +149,9 @@ export function auditInstallCandidate(input) {
149
149
  };
150
150
  }
151
151
  export function formatInstallAuditFailure(ref, report) {
152
+ return formatInstallAuditFailureForAction(ref, report, "add");
153
+ }
154
+ export function formatInstallAuditFailureForAction(ref, report, action) {
152
155
  const lines = [`Security audit failed for ${ref}.`, formatInstallAuditSummary(report)];
153
156
  for (const finding of report.findings.slice(0, 5)) {
154
157
  lines.push(`- [${finding.severity}] ${finding.message}${finding.file ? ` (${finding.file})` : ""}`);
@@ -156,8 +159,9 @@ export function formatInstallAuditFailure(ref, report) {
156
159
  if (report.findings.length > 5) {
157
160
  lines.push(`- ${report.findings.length - 5} more finding(s) omitted`);
158
161
  }
162
+ const trustCommand = action === "update" ? `akm update ${ref} --trust` : `akm add ${ref} --trust`;
159
163
  lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`." +
160
- " Or pass --trust on a one-off 'akm add' to bypass this audit for this install only.");
164
+ ` Or pass --trust on a one-off '${trustCommand}' to bypass this audit for this ${action} only.`);
161
165
  return lines.join("\n");
162
166
  }
163
167
  export function formatInstallAuditSummary(report) {
@@ -7,7 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { isWithin, resolveStashDir } from "../core/common";
10
- import { loadConfig } from "../core/config";
10
+ import { getSources, loadConfig } from "../core/config";
11
11
  import { NotFoundError, UsageError } from "../core/errors";
12
12
  import { akmIndex } from "../indexer/indexer";
13
13
  import { removeLockEntry, upsertLockEntry } from "../integrations/lockfile";
@@ -16,7 +16,7 @@ import { parseGitRepoUrl, syncMirroredRepo } from "../sources/providers/git";
16
16
  import { syncFromRef } from "../sources/providers/sync-from-ref";
17
17
  import { ensureWebsiteMirror } from "../sources/website-ingest";
18
18
  import { listWikis, resolveWikisRoot } from "../wiki/wiki";
19
- import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
19
+ import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailureForAction, } from "./install-audit";
20
20
  import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./source-add";
21
21
  import { removeStash } from "./source-manage";
22
22
  export async function akmListSources(input) {
@@ -26,7 +26,7 @@ export async function akmListSources(input) {
26
26
  const sources = [];
27
27
  // Stash entries — each entry exposes its provider type as kind (spec §2.1).
28
28
  // Writable defaults: true for filesystem, false for git/npm/website (CLAUDE.md "Writes").
29
- for (const stash of config.sources ?? config.stashes ?? []) {
29
+ for (const stash of getSources(config)) {
30
30
  const kind = stash.type ?? "filesystem";
31
31
  if (kindFilter && !kindFilter.includes(kind))
32
32
  continue;
@@ -119,7 +119,7 @@ export async function akmRemove(input) {
119
119
  stashRoot: entry.stashRoot,
120
120
  },
121
121
  config: {
122
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
122
+ sourceCount: getSources(updatedConfig).length,
123
123
  installedKitCount: updatedConfig.installed?.length ?? 0,
124
124
  },
125
125
  index: {
@@ -150,7 +150,7 @@ export async function akmRemove(input) {
150
150
  stashRoot: removedEntry.path ?? "",
151
151
  },
152
152
  config: {
153
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
153
+ sourceCount: getSources(updatedConfig).length,
154
154
  installedKitCount: updatedConfig.installed?.length ?? 0,
155
155
  },
156
156
  index: {
@@ -161,6 +161,109 @@ export async function akmRemove(input) {
161
161
  },
162
162
  };
163
163
  }
164
+ // ── akmUpdate helpers ────────────────────────────────────────────────────────
165
+ /** Build a standard UpdateResponse summary block from the current config and index run. */
166
+ async function buildUpdateResponse(stashDir, target, all, processed, full = false) {
167
+ const index = await akmIndex({ stashDir, ...(full ? { full: true } : {}) });
168
+ const finalConfig = loadConfig();
169
+ return {
170
+ schemaVersion: 1,
171
+ stashDir,
172
+ target,
173
+ all,
174
+ processed,
175
+ config: {
176
+ sourceCount: getSources(finalConfig).length,
177
+ installedKitCount: finalConfig.installed?.length ?? 0,
178
+ },
179
+ index: {
180
+ mode: index.mode,
181
+ totalEntries: index.totalEntries,
182
+ directoriesScanned: index.directoriesScanned,
183
+ directoriesSkipped: index.directoriesSkipped,
184
+ },
185
+ };
186
+ }
187
+ /** Sync a git-mirrored source and return an UpdateResponse. */
188
+ async function updateGitSource(stashDir, target, all, gitSource) {
189
+ await syncMirroredRepo(gitSource, { force: true, writable: gitSource.writable === true });
190
+ return buildUpdateResponse(stashDir, target, all, [], true);
191
+ }
192
+ /** Re-crawl a website source and return an UpdateResponse. */
193
+ async function updateWebsiteSource(stashDir, target, all, websiteSource) {
194
+ // TODO: full incremental re-crawl with delta tracking (#19)
195
+ await ensureWebsiteMirror(websiteSource, { requireStashDir: true, force: true });
196
+ return buildUpdateResponse(stashDir, target, all, []);
197
+ }
198
+ /** Sync a single installed registry entry and return the processed record. */
199
+ async function updateRegistryEntry(entry, force, auditConfig) {
200
+ if (force && shouldCleanupCache(entry)) {
201
+ cleanupDirectoryBestEffort(entry.cacheDir);
202
+ }
203
+ const synced = await syncFromRef(entry.ref, { force });
204
+ // Mirror the post-sync audit hook from akmAdd so `akm update` can't
205
+ // silently land malicious content during refresh.
206
+ const registryLabels = deriveRegistryLabels({
207
+ source: synced.source,
208
+ ref: synced.ref,
209
+ artifactUrl: synced.artifactUrl,
210
+ });
211
+ enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
212
+ const audit = auditInstallCandidate({
213
+ rootDir: synced.extractedDir,
214
+ source: synced.source,
215
+ ref: synced.ref,
216
+ registryLabels,
217
+ config: auditConfig,
218
+ });
219
+ if (audit.blocked) {
220
+ throw new UsageError(formatInstallAuditFailureForAction(synced.ref, audit, "update"), "INVALID_FLAG_VALUE", `Re-run with \`akm update ${synced.ref} --trust\` only if you intentionally trust this source.`);
221
+ }
222
+ const installedEntry = {
223
+ id: synced.id,
224
+ source: synced.source,
225
+ ref: synced.ref,
226
+ artifactUrl: synced.artifactUrl,
227
+ resolvedVersion: synced.resolvedVersion,
228
+ resolvedRevision: synced.resolvedRevision,
229
+ stashRoot: synced.contentDir,
230
+ cacheDir: synced.cacheDir,
231
+ installedAt: synced.syncedAt,
232
+ writable: synced.writable ?? entry.writable,
233
+ ...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
234
+ };
235
+ upsertInstalledRegistryEntry(installedEntry);
236
+ await upsertLockEntry({
237
+ id: synced.id,
238
+ source: synced.source,
239
+ ref: synced.ref,
240
+ resolvedVersion: synced.resolvedVersion,
241
+ resolvedRevision: synced.resolvedRevision,
242
+ integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
243
+ });
244
+ if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
245
+ cleanupDirectoryBestEffort(entry.cacheDir);
246
+ }
247
+ const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
248
+ const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
249
+ return {
250
+ id: entry.id,
251
+ source: entry.source,
252
+ ref: entry.ref,
253
+ previous: {
254
+ resolvedVersion: entry.resolvedVersion,
255
+ resolvedRevision: entry.resolvedRevision,
256
+ cacheDir: entry.cacheDir,
257
+ },
258
+ installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
259
+ changed: {
260
+ version: versionChanged,
261
+ revision: revisionChanged,
262
+ any: versionChanged || revisionChanged,
263
+ },
264
+ };
265
+ }
266
+ // ── akmUpdate dispatcher ─────────────────────────────────────────────────────
164
267
  export async function akmUpdate(input) {
165
268
  const stashDir = input?.stashDir ?? resolveStashDir();
166
269
  const target = input?.target?.trim();
@@ -168,10 +271,10 @@ export async function akmUpdate(input) {
168
271
  const force = input?.force === true;
169
272
  const config = loadConfig();
170
273
  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[].
274
+ // Check if the target refers to a git or website source — those are stored
275
+ // in sources[] not installed[] and need a different update path.
173
276
  if (target && !all) {
174
- const stashes = config.sources ?? config.stashes ?? [];
277
+ const stashes = getSources(config);
175
278
  const isUrl = target.startsWith("http://") || target.startsWith("https://");
176
279
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
177
280
  const gitMatch = stashes.find((s) => {
@@ -195,28 +298,8 @@ export async function akmUpdate(input) {
195
298
  }
196
299
  return false;
197
300
  });
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
- }
301
+ if (gitMatch)
302
+ return updateGitSource(stashDir, target, all, gitMatch);
220
303
  const websiteMatch = stashes.find((s) => {
221
304
  if (s.type !== "website")
222
305
  return false;
@@ -228,119 +311,16 @@ export async function akmUpdate(input) {
228
311
  return true;
229
312
  return false;
230
313
  });
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
- }
314
+ if (websiteMatch)
315
+ return updateWebsiteSource(stashDir, target, all, websiteMatch);
254
316
  }
255
317
  const selectedEntries = selectTargets(installedEntries, target, all);
256
318
  const auditConfig = config;
257
319
  const processed = [];
258
320
  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
- });
321
+ processed.push(await updateRegistryEntry(entry, force, auditConfig));
324
322
  }
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
- };
323
+ return buildUpdateResponse(stashDir, target, all, processed);
344
324
  }
345
325
  function selectTargets(installed, target, all) {
346
326
  if (all && target) {
@@ -356,7 +336,7 @@ function selectTargets(installed, target, all) {
356
336
  return [found];
357
337
  // Check if target matches a stash source and give a helpful message
358
338
  const config = loadConfig();
359
- const stashes = config.sources ?? config.stashes ?? [];
339
+ const stashes = getSources(config);
360
340
  const isUrl = target.startsWith("http://") || target.startsWith("https://");
361
341
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
362
342
  const stashMatch = stashes.find((s) => {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Knowledge-command helpers extracted from `src/cli.ts`.
3
+ *
4
+ * Covers the shared pipeline for reading, naming, and writing markdown assets
5
+ * (knowledge and memory) from the CLI. Extracted to keep the CLI entry point
6
+ * focused on command definitions and routing.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { resolveAssetPathFromName } from "../core/asset-spec";
11
+ import { isHttpUrl, isWithin, tryReadStdinText } from "../core/common";
12
+ import { loadConfig } from "../core/config";
13
+ import { UsageError } from "../core/errors";
14
+ import { resolveWriteTarget, writeAssetToSource } from "../core/write-source";
15
+ import { fetchWebsiteMarkdownSnapshot } from "../sources/website-ingest";
16
+ const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
17
+ // ── Asset-name normalisation ─────────────────────────────────────────────────
18
+ /**
19
+ * Validate and normalise a markdown asset name supplied by the user.
20
+ *
21
+ * Strips the `.md` extension, rejects empty names, and guards against path
22
+ * traversal (`..` segments). The `fallback` is used when `name` is undefined.
23
+ */
24
+ export function normalizeMarkdownAssetName(name, fallback) {
25
+ const trimmed = (name ?? fallback)
26
+ .trim()
27
+ .replace(/\\/g, "/")
28
+ .replace(/^\/+|\/+$/g, "")
29
+ .replace(/\.md$/i, "");
30
+ if (!trimmed)
31
+ throw new UsageError("Asset name cannot be empty.");
32
+ const segments = trimmed.split("/");
33
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
34
+ throw new UsageError("Asset name must be a relative path without '.' or '..' segments.");
35
+ }
36
+ return trimmed;
37
+ }
38
+ function slugifyAssetName(value, fallbackPrefix) {
39
+ const slug = value
40
+ .toLowerCase()
41
+ .replace(/^[#>\-\s]+/, "")
42
+ .replace(/[^a-z0-9]+/g, "-")
43
+ .replace(/^-+|-+$/g, "")
44
+ .slice(0, MAX_CAPTURED_ASSET_SLUG_LENGTH);
45
+ return slug || `${fallbackPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
46
+ }
47
+ /**
48
+ * Derive a slug-style asset name from `content` and an optional `preferred`
49
+ * hint (e.g. a URL-derived page title or the source filename stem).
50
+ */
51
+ export function inferAssetName(content, fallbackPrefix, preferred) {
52
+ const firstNonEmptyLine = content
53
+ .split(/\r?\n/)
54
+ .map((line) => line.trim())
55
+ .find((line) => line.length > 0);
56
+ const basis = preferred?.trim() || firstNonEmptyLine || fallbackPrefix;
57
+ return slugifyAssetName(basis, fallbackPrefix);
58
+ }
59
+ // ── Content reading ──────────────────────────────────────────────────────────
60
+ /**
61
+ * Read knowledge content from a local file path or stdin (`"-"`).
62
+ *
63
+ * Returns the raw text and an optional `preferredName` derived from the
64
+ * source filename stem (used as a slug fallback when no `--name` flag was
65
+ * supplied).
66
+ */
67
+ export function readKnowledgeContent(source) {
68
+ if (source === "-") {
69
+ const content = tryReadStdinText();
70
+ if (!content?.trim()) {
71
+ throw new UsageError("No stdin content received. Pipe a document into stdin or pass a file path.");
72
+ }
73
+ return { content };
74
+ }
75
+ const resolvedSource = path.resolve(source);
76
+ let stat;
77
+ try {
78
+ stat = fs.statSync(resolvedSource);
79
+ }
80
+ catch {
81
+ throw new UsageError(`Knowledge source not found: "${source}". Pass a readable file path or "-" for stdin.`);
82
+ }
83
+ if (!stat.isFile()) {
84
+ throw new UsageError(`Knowledge source must be a file: "${source}".`);
85
+ }
86
+ return {
87
+ content: fs.readFileSync(resolvedSource, "utf8"),
88
+ preferredName: path.basename(resolvedSource, path.extname(resolvedSource)),
89
+ };
90
+ }
91
+ /**
92
+ * Read knowledge content from a local path, stdin (`"-"`), or a remote URL.
93
+ *
94
+ * URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
95
+ * to `readKnowledgeContent`.
96
+ */
97
+ export async function readKnowledgeInput(source) {
98
+ if (!isHttpUrl(source))
99
+ return readKnowledgeContent(source);
100
+ const snapshot = await fetchWebsiteMarkdownSnapshot(source);
101
+ return { content: snapshot.content, preferredName: snapshot.preferredName };
102
+ }
103
+ // ── Asset writing ────────────────────────────────────────────────────────────
104
+ /**
105
+ * Write a markdown asset (knowledge or memory) to the resolved write target.
106
+ *
107
+ * Resolves the write target via the v1 precedence chain (`--target` →
108
+ * `defaultWriteTarget` → working stash), validates the path is within the
109
+ * type root, enforces `--force` semantics, and delegates the actual write
110
+ * to `writeAssetToSource`.
111
+ */
112
+ export async function writeMarkdownAsset(options) {
113
+ const cfg = loadConfig();
114
+ const { source, config } = resolveWriteTarget(cfg, options.target);
115
+ const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
116
+ const normalizedName = normalizeMarkdownAssetName(options.name, inferAssetName(options.content, options.fallbackPrefix, options.preferredName));
117
+ // Pre-flight: existence + force semantics. The helper itself overwrites
118
+ // unconditionally; the CLI surfaces a friendlier UsageError before any
119
+ // disk activity when --force is absent.
120
+ const assetPath = resolveAssetPathFromName(options.type, typeRoot, normalizedName);
121
+ if (!isWithin(assetPath, typeRoot)) {
122
+ throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
123
+ }
124
+ if (fs.existsSync(assetPath) && !options.force) {
125
+ throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
126
+ }
127
+ const result = await writeAssetToSource(source, config, { type: options.type, name: normalizedName }, options.content);
128
+ return {
129
+ ref: result.ref,
130
+ path: result.path,
131
+ stashDir: source.path,
132
+ };
133
+ }
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import { BaseLinter } from "./base-linter";
3
+ /**
4
+ * Linter for `agents/` assets.
5
+ *
6
+ * Extra check beyond base:
7
+ * - `missing-name-or-type`: frontmatter exists but `name` or `type` field is
8
+ * absent. Not auto-fixable; detail includes a suggested slug.
9
+ */
10
+ export class AgentLinter extends BaseLinter {
11
+ types = ["agents"];
12
+ lint(ctx) {
13
+ const issues = this.runBaseChecks(ctx);
14
+ const missingFieldDetail = this.#checkMissingNameOrType(ctx.data, ctx.frontmatter);
15
+ if (missingFieldDetail) {
16
+ const slug = this.#suggestSlug(ctx.filePath);
17
+ issues.push({
18
+ file: ctx.relPath,
19
+ issue: "missing-name-or-type",
20
+ detail: `${missingFieldDetail}; suggested slug: ${slug}`,
21
+ fixed: false,
22
+ });
23
+ }
24
+ return issues;
25
+ }
26
+ #checkMissingNameOrType(data, frontmatterText) {
27
+ if (!frontmatterText)
28
+ return null;
29
+ const missingFields = [];
30
+ if (!("name" in data) || !data.name)
31
+ missingFields.push("name");
32
+ if (!("type" in data) || !data.type)
33
+ missingFields.push("type");
34
+ if (missingFields.length === 0)
35
+ return null;
36
+ return `missing fields: ${missingFields.join(", ")}`;
37
+ }
38
+ #suggestSlug(filePath) {
39
+ return path
40
+ .basename(filePath, ".md")
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9-]+/g, "-")
43
+ .replace(/-+/g, "-")
44
+ .replace(/^-|-$/g, "");
45
+ }
46
+ }