akm-cli 0.5.0 → 0.6.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 (118) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/README.md +9 -9
  3. package/dist/cli.js +379 -1448
  4. package/dist/{completions.js → commands/completions.js} +1 -1
  5. package/dist/{config-cli.js → commands/config-cli.js} +109 -11
  6. package/dist/commands/curate.js +263 -0
  7. package/dist/{info.js → commands/info.js} +17 -11
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +14 -2
  10. package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
  11. package/dist/commands/migration-help.js +141 -0
  12. package/dist/{registry-search.js → commands/registry-search.js} +68 -9
  13. package/dist/commands/remember.js +178 -0
  14. package/dist/{stash-search.js → commands/search.js} +28 -69
  15. package/dist/{self-update.js → commands/self-update.js} +3 -3
  16. package/dist/{stash-show.js → commands/show.js} +106 -81
  17. package/dist/{stash-add.js → commands/source-add.js} +133 -67
  18. package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
  19. package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
  20. package/dist/{vault.js → commands/vault.js} +43 -0
  21. package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
  22. package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
  23. package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
  24. package/dist/{common.js → core/common.js} +147 -50
  25. package/dist/{config.js → core/config.js} +288 -29
  26. package/dist/core/errors.js +90 -0
  27. package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
  28. package/dist/{paths.js → core/paths.js} +4 -4
  29. package/dist/core/write-source.js +280 -0
  30. package/dist/{local-search.js → indexer/db-search.js} +49 -32
  31. package/dist/{db.js → indexer/db.js} +210 -81
  32. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  33. package/dist/{indexer.js → indexer/indexer.js} +153 -30
  34. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  35. package/dist/{matchers.js → indexer/matchers.js} +4 -7
  36. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  37. package/dist/{search-source.js → indexer/search-source.js} +97 -55
  38. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  39. package/dist/{walker.js → indexer/walker.js} +1 -1
  40. package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
  41. package/dist/{llm.js → llm/client.js} +12 -48
  42. package/dist/llm/embedder.js +127 -0
  43. package/dist/llm/embedders/cache.js +47 -0
  44. package/dist/llm/embedders/local.js +152 -0
  45. package/dist/llm/embedders/remote.js +121 -0
  46. package/dist/llm/embedders/types.js +39 -0
  47. package/dist/llm/metadata-enhance.js +53 -0
  48. package/dist/output/cli-hints.js +301 -0
  49. package/dist/output/context.js +95 -0
  50. package/dist/{renderers.js → output/renderers.js} +57 -61
  51. package/dist/output/shapes.js +212 -0
  52. package/dist/output/text.js +520 -0
  53. package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
  54. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  55. package/dist/registry/factory.js +33 -0
  56. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  57. package/dist/registry/providers/index.js +11 -0
  58. package/dist/{providers → registry/providers}/skills-sh.js +60 -4
  59. package/dist/{providers → registry/providers}/static-index.js +126 -56
  60. package/dist/registry/providers/types.js +25 -0
  61. package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
  62. package/dist/{detect.js → setup/detect.js} +0 -27
  63. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  64. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  65. package/dist/{setup.js → setup/setup.js} +162 -129
  66. package/dist/setup/steps.js +45 -0
  67. package/dist/{kit-include.js → sources/include.js} +1 -1
  68. package/dist/sources/provider-factory.js +36 -0
  69. package/dist/sources/provider.js +21 -0
  70. package/dist/sources/providers/filesystem.js +35 -0
  71. package/dist/{stash-providers → sources/providers}/git.js +218 -28
  72. package/dist/{stash-providers → sources/providers}/index.js +4 -4
  73. package/dist/sources/providers/install-types.js +14 -0
  74. package/dist/sources/providers/npm.js +160 -0
  75. package/dist/sources/providers/provider-utils.js +173 -0
  76. package/dist/sources/providers/sync-from-ref.js +45 -0
  77. package/dist/sources/providers/tar-utils.js +154 -0
  78. package/dist/{stash-providers → sources/providers}/website.js +60 -20
  79. package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
  80. package/dist/{wiki.js → wiki/wiki.js} +18 -17
  81. package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
  82. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  83. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  84. package/dist/workflows/document-cache.js +20 -0
  85. package/dist/workflows/parser.js +379 -0
  86. package/dist/workflows/renderer.js +78 -0
  87. package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
  88. package/dist/workflows/schema.js +11 -0
  89. package/dist/workflows/validator.js +48 -0
  90. package/docs/README.md +30 -0
  91. package/docs/migration/release-notes/0.0.13.md +4 -0
  92. package/docs/migration/release-notes/0.1.0.md +6 -0
  93. package/docs/migration/release-notes/0.2.0.md +6 -0
  94. package/docs/migration/release-notes/0.3.0.md +5 -0
  95. package/docs/migration/release-notes/0.5.0.md +6 -0
  96. package/docs/migration/release-notes/0.6.0.md +75 -0
  97. package/docs/migration/release-notes/README.md +21 -0
  98. package/package.json +3 -2
  99. package/dist/embedder.js +0 -351
  100. package/dist/errors.js +0 -34
  101. package/dist/migration-help.js +0 -110
  102. package/dist/registry-factory.js +0 -19
  103. package/dist/registry-install.js +0 -532
  104. package/dist/ripgrep.js +0 -2
  105. package/dist/stash-provider-factory.js +0 -35
  106. package/dist/stash-provider.js +0 -1
  107. package/dist/stash-providers/filesystem.js +0 -41
  108. package/dist/stash-providers/openviking.js +0 -348
  109. package/dist/stash-providers/provider-utils.js +0 -11
  110. package/dist/stash-types.js +0 -1
  111. package/dist/workflow-markdown.js +0 -251
  112. /package/dist/{markdown.js → core/markdown.js} +0 -0
  113. /package/dist/{warn.js → core/warn.js} +0 -0
  114. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  115. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  116. /package/dist/{github.js → integrations/github.js} +0 -0
  117. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  118. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -1,7 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { filterNonEmptyStrings } from "./common";
5
+ import { ConfigError } from "./errors";
4
6
  import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
7
+ import { warn } from "./warn";
5
8
  // ── Defaults ────────────────────────────────────────────────────────────────
6
9
  export const DEFAULT_CONFIG = {
7
10
  semanticSearchMode: "auto",
@@ -29,22 +32,56 @@ export function resetConfigCache() {
29
32
  cachedConfig = undefined;
30
33
  cachedUserConfig = undefined;
31
34
  }
35
+ function hashString(text) {
36
+ // Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
37
+ // content changes between config writes when filesystem mtime resolution is
38
+ // too coarse to reflect rapid back-to-back writes (common in tests).
39
+ let hash = 0x811c9dc5;
40
+ for (let i = 0; i < text.length; i++) {
41
+ hash ^= text.charCodeAt(i);
42
+ hash = Math.imul(hash, 0x01000193);
43
+ }
44
+ return (hash >>> 0).toString(16);
45
+ }
32
46
  export function loadUserConfig() {
33
47
  const configPath = getConfigPath();
34
48
  let stat;
35
49
  try {
36
50
  stat = fs.statSync(configPath);
37
- if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
38
- return cachedUserConfig.config;
39
- }
40
51
  }
41
52
  catch {
42
53
  cachedUserConfig = undefined;
43
54
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
44
55
  }
45
- const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfig(configPath));
56
+ // Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
57
+ // when tests write multiple times within the filesystem mtime resolution
58
+ // window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
59
+ // we never serve stale config.
60
+ let text;
61
+ try {
62
+ text = fs.readFileSync(configPath, "utf8");
63
+ }
64
+ catch {
65
+ cachedUserConfig = undefined;
66
+ return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
67
+ }
68
+ const contentHash = hashString(text);
69
+ if (cachedUserConfig &&
70
+ cachedUserConfig.path === configPath &&
71
+ cachedUserConfig.mtime === stat.mtimeMs &&
72
+ cachedUserConfig.size === stat.size &&
73
+ cachedUserConfig.contentHash === contentHash) {
74
+ return cachedUserConfig.config;
75
+ }
76
+ const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(configPath, text));
46
77
  const finalConfig = applyRuntimeEnvApiKeys(config);
47
- cachedUserConfig = { config: finalConfig, path: configPath, mtime: stat.mtimeMs };
78
+ cachedUserConfig = {
79
+ config: finalConfig,
80
+ path: configPath,
81
+ mtime: stat.mtimeMs,
82
+ size: stat.size,
83
+ contentHash,
84
+ };
48
85
  return finalConfig;
49
86
  }
50
87
  export function loadConfig() {
@@ -66,6 +103,7 @@ export function loadConfig() {
66
103
  }
67
104
  export function saveConfig(config) {
68
105
  cachedConfig = undefined;
106
+ cachedUserConfig = undefined;
69
107
  const configPath = getConfigPath();
70
108
  const dir = path.dirname(configPath);
71
109
  fs.mkdirSync(dir, { recursive: true });
@@ -150,16 +188,16 @@ function pickKnownKeys(raw) {
150
188
  const legacySemanticSearch = raw.semanticSearch;
151
189
  config.semanticSearchMode = legacySemanticSearch ? "auto" : "off";
152
190
  }
153
- // Migrate legacy searchPaths into stashes
191
+ // Migrate legacy searchPaths into sources
154
192
  if (Array.isArray(raw.searchPaths)) {
155
193
  const legacyPaths = raw.searchPaths.filter((d) => typeof d === "string");
156
194
  if (legacyPaths.length > 0) {
157
- const existing = config.stashes ?? [];
195
+ const existing = config.sources ?? [];
158
196
  const migrated = legacyPaths
159
197
  .filter((p) => !existing.some((s) => s.type === "filesystem" && s.path === p))
160
198
  .map((p) => ({ type: "filesystem", path: p }));
161
199
  if (migrated.length > 0) {
162
- config.stashes = [...existing, ...migrated];
200
+ config.sources = [...existing, ...migrated];
163
201
  }
164
202
  }
165
203
  }
@@ -175,12 +213,32 @@ function pickKnownKeys(raw) {
175
213
  const registries = parseRegistriesConfig(raw.registries);
176
214
  if (registries)
177
215
  config.registries = registries;
178
- if (typeof raw.disableGlobalStashes === "boolean") {
216
+ // Prefer the new `stashInheritance` field; fall back to the legacy boolean
217
+ // `disableGlobalStashes` so existing config files keep working unchanged.
218
+ if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
219
+ config.stashInheritance = raw.stashInheritance;
220
+ }
221
+ else if (typeof raw.disableGlobalStashes === "boolean") {
222
+ config.stashInheritance = raw.disableGlobalStashes ? "replace" : "merge";
179
223
  config.disableGlobalStashes = raw.disableGlobalStashes;
180
224
  }
181
- const stashes = parseStashesConfig(raw.stashes);
182
- if (stashes)
183
- config.stashes = stashes;
225
+ // Load `sources` (new key) first, then fall back to legacy `stashes` key.
226
+ const sources = parseStashesConfig(raw.sources);
227
+ if (sources) {
228
+ config.sources = sources;
229
+ }
230
+ else {
231
+ const legacyStashes = parseStashesConfig(raw.stashes);
232
+ if (legacyStashes) {
233
+ // Backwards-compat migration: `stashes[]` → `sources[]` in-memory.
234
+ // Emit a one-time deprecation warning and carry the value forward as
235
+ // `sources`. The renamed key is persisted on the next `akm config` write.
236
+ warn('Config key "stashes" is deprecated; rename it to "sources" in your config file ' +
237
+ `(edit it directly at ${_getConfigPath()}). ` +
238
+ "Your configuration has been loaded successfully — no manual action is required right now.");
239
+ config.sources = legacyStashes;
240
+ }
241
+ }
184
242
  const security = parseSecurityConfig(raw.security);
185
243
  if (security)
186
244
  config.security = security;
@@ -190,6 +248,18 @@ function pickKnownKeys(raw) {
190
248
  if (typeof raw.writable === "boolean") {
191
249
  config.writable = raw.writable;
192
250
  }
251
+ if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
252
+ config.defaultWriteTarget = raw.defaultWriteTarget.trim();
253
+ }
254
+ if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
255
+ const searchRaw = raw.search;
256
+ const searchConfig = {};
257
+ if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
258
+ searchConfig.minScore = searchRaw.minScore;
259
+ }
260
+ if (Object.keys(searchConfig).length > 0)
261
+ config.search = searchConfig;
262
+ }
193
263
  return config;
194
264
  }
195
265
  function readNormalizedConfig(configPath) {
@@ -197,6 +267,19 @@ function readNormalizedConfig(configPath) {
197
267
  const expanded = raw ? expandEnvVars(raw) : undefined;
198
268
  return expanded ? pickKnownKeys(expanded) : undefined;
199
269
  }
270
+ function readNormalizedConfigFromText(_configPath, text) {
271
+ let raw;
272
+ try {
273
+ raw = JSON.parse(stripJsonComments(text));
274
+ }
275
+ catch {
276
+ return undefined;
277
+ }
278
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
279
+ return undefined;
280
+ const expanded = expandEnvVars(raw);
281
+ return pickKnownKeys(expanded);
282
+ }
200
283
  function parseOutputConfig(value) {
201
284
  if (typeof value !== "object" || value === null || Array.isArray(value))
202
285
  return undefined;
@@ -380,11 +463,10 @@ function parseLlmConfig(value) {
380
463
  console.warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
381
464
  return undefined;
382
465
  }
383
- if (typeof obj.model !== "string" || !obj.model)
384
- return undefined;
466
+ const model = typeof obj.model === "string" ? obj.model : "";
385
467
  const result = {
386
468
  endpoint: obj.endpoint,
387
- model: obj.model,
469
+ model,
388
470
  };
389
471
  if (typeof obj.provider === "string" && obj.provider) {
390
472
  result.provider = obj.provider;
@@ -428,11 +510,11 @@ function parseInstalledEntries(value) {
428
510
  if (!Array.isArray(value))
429
511
  return undefined;
430
512
  const entries = value
431
- .map((entry) => parseInstalledKitEntry(entry))
513
+ .map((entry) => parseInstalledStashEntry(entry))
432
514
  .filter((entry) => entry !== undefined);
433
515
  return entries.length > 0 ? entries : undefined;
434
516
  }
435
- function parseInstalledKitEntry(value) {
517
+ function parseInstalledStashEntry(value) {
436
518
  if (typeof value !== "object" || value === null || Array.isArray(value))
437
519
  return undefined;
438
520
  const obj = value;
@@ -470,6 +552,14 @@ function parseInstalledKitEntry(value) {
470
552
  function asNonEmptyString(value) {
471
553
  return typeof value === "string" && value ? value : undefined;
472
554
  }
555
+ /**
556
+ * Validate a legacy lockfile/installed-entry source string.
557
+ *
558
+ * Restricted to the four kinds that the install pipeline produces
559
+ * (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
560
+ * wider, but persisted `installed[]` entries should never carry the runtime
561
+ * provider kinds (`"filesystem" | "website"`).
562
+ */
473
563
  function asKitSource(value) {
474
564
  if (value === "npm" || value === "github" || value === "git" || value === "local")
475
565
  return value;
@@ -489,7 +579,7 @@ function parseStashesConfig(value) {
489
579
  if (!Array.isArray(value))
490
580
  return undefined;
491
581
  const entries = value
492
- .map((entry) => parseStashConfigEntry(entry))
582
+ .map((entry) => parseSourceConfigEntry(entry))
493
583
  .filter((entry) => entry !== undefined);
494
584
  return entries;
495
585
  }
@@ -550,13 +640,28 @@ function parseInstallAuditAllowedFinding(value) {
550
640
  finding.reason = reason;
551
641
  return finding;
552
642
  }
553
- function parseStashConfigEntry(value) {
643
+ /**
644
+ * Legacy stash type aliases that are normalized to canonical types at
645
+ * config-load time. Both "context-hub" and "github" were never distinct
646
+ * provider types — they were always git stashes — so we normalize them in
647
+ * memory to "git" without rewriting `config.json` on disk.
648
+ */
649
+ const STASH_TYPE_ALIASES = {
650
+ "context-hub": "git",
651
+ github: "git",
652
+ };
653
+ function parseSourceConfigEntry(value) {
554
654
  if (typeof value !== "object" || value === null || Array.isArray(value))
555
655
  return undefined;
556
656
  const obj = value;
557
- const type = asNonEmptyString(obj.type);
558
- if (!type)
657
+ const rawType = asNonEmptyString(obj.type);
658
+ if (!rawType)
559
659
  return undefined;
660
+ if (rawType === "openviking") {
661
+ const name = asNonEmptyString(obj.name) ?? "unnamed";
662
+ throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${_getConfigPath()} to remove the openviking entry.`);
663
+ }
664
+ const type = STASH_TYPE_ALIASES[rawType] ?? rawType;
560
665
  const entry = { type };
561
666
  const entryPath = asNonEmptyString(obj.path);
562
667
  if (entryPath)
@@ -571,6 +676,16 @@ function parseStashConfigEntry(value) {
571
676
  entry.enabled = obj.enabled;
572
677
  if (typeof obj.writable === "boolean")
573
678
  entry.writable = obj.writable;
679
+ if (typeof obj.primary === "boolean")
680
+ entry.primary = obj.primary;
681
+ // Locked decision 4 (§6 v1 implementation plan): reject writable: true on
682
+ // website / npm sources at config load. The next sync() would clobber
683
+ // writes — allowing this is a footgun, not a feature. Throw early so the
684
+ // user sees the problem at `akm` startup, not when they try to write.
685
+ if (entry.writable === true && (type === "website" || type === "npm")) {
686
+ const label = entry.name ? ` "${entry.name}"` : "";
687
+ throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
688
+ }
574
689
  if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
575
690
  entry.options = obj.options;
576
691
  }
@@ -579,6 +694,124 @@ function parseStashConfigEntry(value) {
579
694
  entry.wikiName = wikiName;
580
695
  return entry;
581
696
  }
697
+ // ── ConfiguredSource runtime construction ─────────────────────────────────────────
698
+ /**
699
+ * Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
700
+ * `name`. Uses a short hash of the discriminating fields so two equivalent
701
+ * entries collapse to the same generated name.
702
+ */
703
+ function deriveStashEntryName(entry) {
704
+ if (entry.name)
705
+ return entry.name;
706
+ const seed = JSON.stringify({
707
+ type: entry.type,
708
+ path: entry.path ?? null,
709
+ url: entry.url ?? null,
710
+ });
711
+ const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
712
+ return `${entry.type}-${hash}`;
713
+ }
714
+ /**
715
+ * Convert a persisted {@link SourceConfigEntry} into the runtime
716
+ * {@link SourceSpec} discriminated union. Returns `undefined` when the
717
+ * entry is missing the fields its provider type requires (e.g. a
718
+ * `filesystem` entry with no `path`); callers should drop or warn for those.
719
+ *
720
+ * Unknown provider types fall back to `{ type: "filesystem", path: ... }` so
721
+ * legacy aliases (`"context-hub"`, `"github"` for git) still produce a usable
722
+ * runtime value when a path/url is supplied.
723
+ */
724
+ export function parseSourceSpec(entry) {
725
+ switch (entry.type) {
726
+ case "filesystem":
727
+ return entry.path ? { type: "filesystem", path: entry.path } : undefined;
728
+ case "git":
729
+ case "context-hub":
730
+ case "github":
731
+ // Note: a configured `github` provider entry historically meant "git
732
+ // repo over the GitHub web URL", not the registry-install `github:` ref.
733
+ return entry.url ? { type: "git", url: entry.url } : undefined;
734
+ case "website":
735
+ return entry.url
736
+ ? {
737
+ type: "website",
738
+ url: entry.url,
739
+ ...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
740
+ }
741
+ : undefined;
742
+ case "npm":
743
+ // Persisted `npm` stash entries are unusual but supported for symmetry.
744
+ return entry.path ? { type: "npm", package: entry.path } : undefined;
745
+ default:
746
+ // Unknown provider — best-effort fallback so callers still get something.
747
+ return entry.path ? { type: "filesystem", path: entry.path } : undefined;
748
+ }
749
+ }
750
+ /**
751
+ * Build the full ordered list of runtime {@link ConfiguredSource} values from a
752
+ * loaded {@link AkmConfig}. Order is the canonical iteration order:
753
+ *
754
+ * 1. The entry marked `primary: true` (or, as a backwards-compat shim,
755
+ * a synthetic filesystem entry built from the top-level `stashDir`).
756
+ * 2. Remaining `sources[]` entries in declared order.
757
+ * 3. Legacy `installed[]` entries, mapped into runtime entries.
758
+ *
759
+ * Entries with `enabled: false` are still emitted — callers decide whether
760
+ * to honour the flag (mirrors how `installed[]` entries have always been
761
+ * unconditional). Entries that fail {@link parseSourceSpec} are
762
+ * dropped silently.
763
+ */
764
+ export function resolveConfiguredSources(config) {
765
+ const entries = [];
766
+ // `sources` is the canonical key. `stashes` is the legacy key that the loader
767
+ // migrates in-memory; only one of the two should be set at runtime.
768
+ const stashes = config.sources ?? config.stashes ?? [];
769
+ // (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
770
+ let primary = stashes.find((entry) => entry.primary === true);
771
+ if (!primary && config.stashDir) {
772
+ primary = { type: "filesystem", path: config.stashDir, primary: true };
773
+ }
774
+ if (primary) {
775
+ const runtime = toConfiguredSource(primary, true);
776
+ if (runtime)
777
+ entries.push(runtime);
778
+ }
779
+ // (2) Declared stashes (skip the primary entry — already added).
780
+ for (const entry of stashes) {
781
+ if (entry === primary)
782
+ continue;
783
+ const runtime = toConfiguredSource(entry, false);
784
+ if (runtime)
785
+ entries.push(runtime);
786
+ }
787
+ // (3) Legacy installed[] entries.
788
+ for (const installed of config.installed ?? []) {
789
+ entries.push({
790
+ name: installed.id,
791
+ type: "filesystem",
792
+ source: { type: "filesystem", path: installed.stashRoot },
793
+ enabled: true,
794
+ writable: installed.writable,
795
+ ...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
796
+ });
797
+ }
798
+ return entries;
799
+ }
800
+ function toConfiguredSource(persisted, isPrimary) {
801
+ const source = parseSourceSpec(persisted);
802
+ if (!source)
803
+ return undefined;
804
+ return {
805
+ name: deriveStashEntryName(persisted),
806
+ type: persisted.type,
807
+ source,
808
+ ...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
809
+ ...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
810
+ ...(isPrimary || persisted.primary ? { primary: true } : {}),
811
+ ...(persisted.options ? { options: persisted.options } : {}),
812
+ ...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
813
+ };
814
+ }
582
815
  function parseRegistryConfigEntry(value) {
583
816
  if (typeof value !== "object" || value === null || Array.isArray(value))
584
817
  return undefined;
@@ -621,7 +854,8 @@ function mergeInstallAuditConfig(base, override) {
621
854
  * Scalar fields follow normal override semantics. Known nested objects are
622
855
  * deep-merged so project config files can override individual fields without
623
856
  * clobbering sibling settings. `stashes` are additive by default, but a later
624
- * layer can set `disableGlobalStashes: true` to drop inherited stashes first.
857
+ * layer can set `stashInheritance: "replace"` (or the legacy
858
+ * `disableGlobalStashes: true`) to drop inherited stashes first.
625
859
  */
626
860
  function mergeLoadedConfig(base, override) {
627
861
  if (!override)
@@ -642,12 +876,25 @@ function mergeLoadedConfig(base, override) {
642
876
  if (base.security && override.security) {
643
877
  merged.security = mergeSecurityConfig(base.security, override.security);
644
878
  }
645
- if (override.disableGlobalStashes) {
646
- merged.stashes = [...(override.stashes ?? [])];
647
- }
648
- else if (override.stashes) {
649
- merged.stashes = [...(base.stashes ?? []), ...override.stashes];
650
- }
879
+ // The new `stashInheritance` field wins; fall back to the legacy
880
+ // `disableGlobalStashes` boolean so old config files behave identically.
881
+ const replaceStashes = override.stashInheritance === "replace" ||
882
+ (override.stashInheritance === undefined && override.disableGlobalStashes === true);
883
+ // Merge `sources` (canonical key). Legacy `stashes` key is handled via the
884
+ // pickKnownKeys migration which promotes it to `sources` at load time.
885
+ const overrideSources = override.sources ?? override.stashes ?? [];
886
+ const baseSources = base.sources ?? base.stashes ?? [];
887
+ if (replaceStashes) {
888
+ merged.sources = [...overrideSources];
889
+ }
890
+ else if (overrideSources.length > 0) {
891
+ merged.sources = [...baseSources, ...overrideSources];
892
+ }
893
+ else if (baseSources.length > 0) {
894
+ merged.sources = [...baseSources];
895
+ }
896
+ // Clear deprecated stashes field on the merged result — sources is canonical.
897
+ delete merged.stashes;
651
898
  return merged;
652
899
  }
653
900
  function applyRuntimeEnvApiKeys(config) {
@@ -713,7 +960,19 @@ function isFile(filePath) {
713
960
  }
714
961
  function getFileSignatureToken(filePath) {
715
962
  try {
716
- return String(fs.statSync(filePath).mtimeMs);
963
+ const stat = fs.statSync(filePath);
964
+ // mtimeMs alone is unreliable on filesystems with low-resolution mtime
965
+ // (HFS+, some network FS, or very fast back-to-back writes in tests).
966
+ // Combine mtime + size + content hash so the signature actually changes
967
+ // when content does.
968
+ let contentHash = "";
969
+ try {
970
+ contentHash = hashString(fs.readFileSync(filePath, "utf8"));
971
+ }
972
+ catch {
973
+ // ignore — fall back to stat-only signature
974
+ }
975
+ return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
717
976
  }
718
977
  catch {
719
978
  return "missing";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Typed error classes for structured exit code classification.
3
+ *
4
+ * - ConfigError -> exit 78 (configuration / environment problems)
5
+ * - UsageError -> exit 2 (bad CLI arguments or invalid input)
6
+ * - NotFoundError -> exit 1 (requested resource missing)
7
+ *
8
+ * Each error carries a machine-readable `code` field. Codes are stable
9
+ * identifiers safe to consume from scripts and JSON output. Existing throw
10
+ * sites without an explicit code receive a default code per error class so
11
+ * older call sites continue to compile and behave unchanged.
12
+ *
13
+ * Each error also exposes a `hint()` method returning an actionable hint
14
+ * string (or `undefined`). Hints can be supplied at construction time or
15
+ * derived from the error `code` via the per-class default mapping below.
16
+ * The CLI surfaces this via `error.hint()` rather than message-regex parsing.
17
+ */
18
+ /**
19
+ * Default hint for each ConfigError code. Keep these short, actionable, and
20
+ * imperative. Returning undefined means "no canned hint".
21
+ */
22
+ const CONFIG_HINTS = {
23
+ STASH_DIR_NOT_FOUND: "Run `akm init` to create the default stash, or set stashDir in your config.",
24
+ STASH_DIR_NOT_A_DIRECTORY: "The configured stashDir exists but isn't a directory. Update stashDir to point at a folder.",
25
+ STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update stashDir.",
26
+ EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
27
+ LLM_NOT_CONFIGURED: 'Run `akm config set llm \'{"endpoint":"...","model":"..."}\'` to configure the LLM.',
28
+ };
29
+ /** Default hint for each UsageError code. */
30
+ const USAGE_HINTS = {
31
+ INVALID_SOURCE_VALUE: "Pick one of: stash, registry, both.",
32
+ INVALID_FORMAT_VALUE: "Pick one of: json, jsonl, text, yaml.",
33
+ INVALID_DETAIL_VALUE: "Pick one of: brief, normal, full, summary, agent.",
34
+ INVALID_JSON_CONFIG_VALUE: 'Quote JSON values in your shell, for example: akm config set embedding \'{"endpoint":"http://localhost:11434/v1/embeddings","model":"nomic-embed-text"}\'.',
35
+ MISSING_OR_AMBIGUOUS_TARGET: "Use `akm update --all` or pass a target like `akm update npm:@scope/pkg` (not both).",
36
+ TARGET_NOT_UPDATABLE: "Run `akm list` to view your sources, then retry with one of those values.",
37
+ MISSING_REQUIRED_ARGUMENT: "Refs use the form type:name, e.g. `akm show skill:deploy` or `akm show knowledge:guide.md`.",
38
+ };
39
+ /** Default hint for each NotFoundError code. */
40
+ const NOT_FOUND_HINTS = {
41
+ SOURCE_NOT_FOUND: "Run `akm list` to view your sources, then retry with one of those values.",
42
+ };
43
+ /** Raised when configuration or environment is invalid or missing. */
44
+ export class ConfigError extends Error {
45
+ code;
46
+ _hint;
47
+ constructor(msg, code = "INVALID_CONFIG_FILE", hint) {
48
+ super(msg);
49
+ this.name = "ConfigError";
50
+ this.code = code;
51
+ this._hint = hint;
52
+ // Fixes `instanceof` checks under ES5 transpilation targets.
53
+ Object.setPrototypeOf(this, new.target.prototype);
54
+ }
55
+ hint() {
56
+ return this._hint ?? CONFIG_HINTS[this.code];
57
+ }
58
+ }
59
+ /** Raised when the user supplies invalid arguments or input. */
60
+ export class UsageError extends Error {
61
+ code;
62
+ _hint;
63
+ constructor(msg, code = "INVALID_FLAG_VALUE", hint) {
64
+ super(msg);
65
+ this.name = "UsageError";
66
+ this.code = code;
67
+ this._hint = hint;
68
+ // Fixes `instanceof` checks under ES5 transpilation targets.
69
+ Object.setPrototypeOf(this, new.target.prototype);
70
+ }
71
+ hint() {
72
+ return this._hint ?? USAGE_HINTS[this.code];
73
+ }
74
+ }
75
+ /** Raised when a requested resource (asset, entry, file) is not found. */
76
+ export class NotFoundError extends Error {
77
+ code;
78
+ _hint;
79
+ constructor(msg, code = "ASSET_NOT_FOUND", hint) {
80
+ super(msg);
81
+ this.name = "NotFoundError";
82
+ this.code = code;
83
+ this._hint = hint;
84
+ // Fixes `instanceof` checks under ES5 transpilation targets.
85
+ Object.setPrototypeOf(this, new.target.prototype);
86
+ }
87
+ hint() {
88
+ return this._hint ?? NOT_FOUND_HINTS[this.code];
89
+ }
90
+ }
@@ -11,13 +11,13 @@
11
11
  *
12
12
  * **Limitations**: This is a hand-rolled YAML-subset parser with intentional
13
13
  * constraints for simplicity and safety:
14
- * - **No list support**: YAML block sequences (`- item`) and flow arrays
15
- * (`[a, b, c]`) are silently ignored. List-valued frontmatter keys will
16
- * produce an empty string or be skipped. Callers must NOT rely on list-
17
- * valued frontmatter.
14
+ * - **Top-level values**: string, boolean, and number scalars are supported,
15
+ * as well as top-level list-valued keys using YAML block sequences
16
+ * (`- item`) or flow arrays (`[a, b, c]`).
17
+ * - **List item types**: list items must be scalar values and may be strings,
18
+ * booleans, or numbers.
18
19
  * - **No nested objects beyond one level**: Only a single level of indented
19
20
  * key-value pairs is supported.
20
- * - **Scalar values only**: string, boolean, and number scalars are supported.
21
21
  */
22
22
  export function parseFrontmatter(raw) {
23
23
  const parsedBlock = parseFrontmatterBlock(raw);
@@ -26,28 +26,75 @@ export function parseFrontmatter(raw) {
26
26
  }
27
27
  const data = {};
28
28
  let currentKey = null;
29
+ /** "scalar" | "list" | "object" | "pending" — "pending" means empty value, mode determined by next line */
30
+ let mode = "scalar";
29
31
  let nested = null;
32
+ let currentList = null;
33
+ const flushPending = () => {
34
+ // Called when we start a new top-level key and the previous key was still "pending".
35
+ // An empty-value key followed by another top-level key means it was an empty scalar.
36
+ if (mode === "pending" && currentKey !== null) {
37
+ data[currentKey] = "";
38
+ }
39
+ };
30
40
  for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
41
+ // Block-sequence item: "- value" or " - value" (optional 2-space indent)
42
+ // Only match when the current key is in list or pending mode.
43
+ const seqItem = line.match(/^(?: {2})?- (.*)$/);
44
+ if (seqItem && currentKey !== null && (mode === "list" || mode === "pending")) {
45
+ if (mode === "pending") {
46
+ // First block-sequence item after an empty-value key — switch to list mode
47
+ currentList = [];
48
+ data[currentKey] = currentList;
49
+ mode = "list";
50
+ }
51
+ currentList.push(parseYamlScalar(seqItem[1].trim()));
52
+ continue;
53
+ }
54
+ // Indented nested key-value (object under a key with empty value)
31
55
  const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
32
- if (indented && currentKey && nested) {
56
+ if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
57
+ if (mode === "pending") {
58
+ // First indented k-v after an empty-value key — switch to object mode
59
+ nested = {};
60
+ data[currentKey] = nested;
61
+ mode = "object";
62
+ }
33
63
  nested[indented[1]] = parseYamlScalar(indented[2].trim());
34
64
  continue;
35
65
  }
66
+ // Top-level key (possibly with inline value)
36
67
  const top = line.match(/^(\w[\w-]*):\s*(.*)$/);
37
68
  if (!top) {
38
69
  continue;
39
70
  }
71
+ // Starting a new top-level key — flush any pending empty-value key
72
+ flushPending();
40
73
  currentKey = top[1];
41
74
  const value = top[2].trim();
42
75
  if (value === "") {
43
- nested = {};
44
- data[currentKey] = nested;
76
+ // Defer mode decision until we see the next line
77
+ mode = "pending";
78
+ nested = null;
79
+ currentList = null;
80
+ // Don't store anything yet — flushPending will set "" if no continuation
81
+ }
82
+ else if (value.startsWith("[") && value.endsWith("]")) {
83
+ // Inline flow array: tags: [ops, networking]
84
+ mode = "list";
85
+ nested = null;
86
+ currentList = parseFlowArray(value);
87
+ data[currentKey] = currentList;
45
88
  }
46
89
  else {
90
+ mode = "scalar";
47
91
  nested = null;
92
+ currentList = null;
48
93
  data[currentKey] = parseYamlScalar(value);
49
94
  }
50
95
  }
96
+ // Flush the last key if it was still pending (empty value, no continuation)
97
+ flushPending();
51
98
  return {
52
99
  data,
53
100
  content: parsedBlock.content,
@@ -55,6 +102,15 @@ export function parseFrontmatter(raw) {
55
102
  bodyStartLine: parsedBlock.bodyStartLine,
56
103
  };
57
104
  }
105
+ /**
106
+ * Parse a YAML flow array string like `[a, b, c]` into an array of scalars.
107
+ */
108
+ function parseFlowArray(value) {
109
+ const inner = value.slice(1, -1).trim();
110
+ if (!inner)
111
+ return [];
112
+ return inner.split(",").map((item) => parseYamlScalar(item.trim()));
113
+ }
58
114
  export function parseFrontmatterBlock(raw) {
59
115
  // Handle both LF and CRLF line endings throughout.
60
116
  // The closing --- may be preceded by \r\n; capture and strip trailing \r