akm-cli 0.5.0 → 0.6.0-rc1

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 (74) hide show
  1. package/CHANGELOG.md +32 -5
  2. package/dist/asset-registry.js +29 -5
  3. package/dist/asset-spec.js +12 -5
  4. package/dist/cli-hints.js +300 -0
  5. package/dist/cli.js +218 -1357
  6. package/dist/common.js +147 -50
  7. package/dist/config.js +224 -13
  8. package/dist/create-provider-registry.js +1 -1
  9. package/dist/curate.js +258 -0
  10. package/dist/{local-search.js → db-search.js} +30 -19
  11. package/dist/db.js +168 -62
  12. package/dist/embedder.js +49 -273
  13. package/dist/embedders/cache.js +47 -0
  14. package/dist/embedders/local.js +152 -0
  15. package/dist/embedders/remote.js +121 -0
  16. package/dist/embedders/types.js +39 -0
  17. package/dist/errors.js +14 -3
  18. package/dist/frontmatter.js +61 -7
  19. package/dist/indexer.js +38 -7
  20. package/dist/info.js +2 -2
  21. package/dist/install-audit.js +16 -1
  22. package/dist/{installed-kits.js → installed-stashes.js} +48 -22
  23. package/dist/llm-client.js +92 -0
  24. package/dist/llm.js +14 -126
  25. package/dist/lockfile.js +28 -1
  26. package/dist/matchers.js +1 -1
  27. package/dist/metadata-enhance.js +53 -0
  28. package/dist/migration-help.js +75 -44
  29. package/dist/output-context.js +77 -0
  30. package/dist/output-shapes.js +198 -0
  31. package/dist/output-text.js +520 -0
  32. package/dist/paths.js +4 -4
  33. package/dist/providers/index.js +11 -0
  34. package/dist/providers/skills-sh.js +1 -1
  35. package/dist/providers/static-index.js +47 -45
  36. package/dist/registry-build-index.js +36 -29
  37. package/dist/registry-factory.js +2 -2
  38. package/dist/registry-resolve.js +8 -4
  39. package/dist/registry-search.js +62 -5
  40. package/dist/remember.js +172 -0
  41. package/dist/renderers.js +52 -0
  42. package/dist/search-source.js +73 -42
  43. package/dist/setup-steps.js +45 -0
  44. package/dist/setup.js +149 -76
  45. package/dist/stash-add.js +94 -38
  46. package/dist/stash-clone.js +4 -4
  47. package/dist/stash-provider-factory.js +2 -2
  48. package/dist/stash-provider.js +3 -1
  49. package/dist/stash-providers/filesystem.js +31 -1
  50. package/dist/stash-providers/git.js +209 -8
  51. package/dist/stash-providers/index.js +1 -0
  52. package/dist/stash-providers/npm.js +159 -0
  53. package/dist/stash-providers/provider-utils.js +162 -0
  54. package/dist/stash-providers/sync-from-ref.js +45 -0
  55. package/dist/stash-providers/tar-utils.js +151 -0
  56. package/dist/stash-providers/website.js +80 -4
  57. package/dist/stash-resolve.js +5 -5
  58. package/dist/stash-search.js +4 -4
  59. package/dist/stash-show.js +3 -3
  60. package/dist/wiki.js +6 -6
  61. package/dist/workflow-authoring.js +12 -4
  62. package/dist/workflow-markdown.js +9 -0
  63. package/dist/workflow-runs.js +12 -2
  64. package/docs/README.md +30 -0
  65. package/docs/migration/release-notes/0.0.13.md +4 -0
  66. package/docs/migration/release-notes/0.1.0.md +6 -0
  67. package/docs/migration/release-notes/0.2.0.md +6 -0
  68. package/docs/migration/release-notes/0.3.0.md +5 -0
  69. package/docs/migration/release-notes/0.5.0.md +6 -0
  70. package/docs/migration/release-notes/0.6.0.md +29 -0
  71. package/docs/migration/release-notes/README.md +21 -0
  72. package/package.json +3 -2
  73. package/dist/registry-install.js +0 -532
  74. /package/dist/{kit-include.js → stash-include.js} +0 -0
package/dist/common.js CHANGED
@@ -24,21 +24,17 @@ export function isAssetType(type) {
24
24
  * 2. stashDir field in config.json
25
25
  * 3. Platform default (~/akm or ~/Documents/akm on Windows)
26
26
  *
27
- * WARNING: May write to config file as a side effect when AKM_STASH_DIR is set.
28
- * Specifically, when AKM_STASH_DIR is set and `options.readOnly` is not true,
29
- * this function calls `persistStashDirToConfig()` which writes the resolved
30
- * path into config.json on disk.
27
+ * Pure read: never writes to disk. The legacy `readOnly` option is accepted
28
+ * (and ignored) for one release cycle so older callers continue to compile;
29
+ * it can be removed in the next minor bump.
31
30
  *
32
31
  * Throws if no valid stash directory is found.
33
32
  */
34
- export function resolveStashDir(options) {
33
+ export function resolveStashDir(_options) {
35
34
  // 1. Env var override (for CI, scripts, testing)
36
35
  const envDir = process.env.AKM_STASH_DIR?.trim();
37
36
  if (envDir) {
38
- const resolved = validateStashDir(envDir);
39
- if (!options?.readOnly)
40
- persistStashDirToConfig(resolved);
41
- return resolved;
37
+ return validateStashDir(envDir);
42
38
  }
43
39
  // 2. Config file stashDir field
44
40
  const configStashDir = readStashDirFromConfig();
@@ -50,7 +46,7 @@ export function resolveStashDir(options) {
50
46
  return defaultDir;
51
47
  }
52
48
  throw new ConfigError(`No stash directory found. Run "akm init" to create one at ${defaultDir}, ` +
53
- `or set stashDir in ${getConfigPath()}.`);
49
+ `or set stashDir in ${getConfigPath()}.`, "STASH_DIR_NOT_FOUND");
54
50
  }
55
51
  function validateStashDir(raw) {
56
52
  const stashDir = path.resolve(raw);
@@ -59,10 +55,10 @@ function validateStashDir(raw) {
59
55
  stat = fs.statSync(stashDir);
60
56
  }
61
57
  catch {
62
- throw new ConfigError(`Unable to read stash directory at "${stashDir}".`);
58
+ throw new ConfigError(`Unable to read stash directory at "${stashDir}".`, "STASH_DIR_UNREADABLE");
63
59
  }
64
60
  if (!stat.isDirectory()) {
65
- throw new ConfigError(`Stash path must point to a directory: "${stashDir}".`);
61
+ throw new ConfigError(`Stash path must point to a directory: "${stashDir}".`, "STASH_DIR_NOT_A_DIRECTORY");
66
62
  }
67
63
  return stashDir;
68
64
  }
@@ -92,42 +88,6 @@ function readStashDirFromConfig() {
92
88
  }
93
89
  return undefined;
94
90
  }
95
- /**
96
- * Persist stashDir to config.json if not already set, so users can
97
- * transition away from relying on the AKM_STASH_DIR env var.
98
- *
99
- * WARNING: This function writes to disk (config.json). It is called as a side
100
- * effect of `resolveStashDir()` when AKM_STASH_DIR is set and `readOnly` is
101
- * not true. Callers that must not touch the filesystem should pass
102
- * `{ readOnly: true }` to `resolveStashDir()`.
103
- */
104
- function persistStashDirToConfig(stashDir) {
105
- try {
106
- const configPath = getConfigPath();
107
- let raw = {};
108
- try {
109
- const text = fs.readFileSync(configPath, "utf8");
110
- const parsed = JSON.parse(text);
111
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
112
- raw = parsed;
113
- }
114
- }
115
- catch {
116
- // No existing config or invalid — start fresh
117
- }
118
- if (!raw.stashDir) {
119
- raw.stashDir = stashDir;
120
- const dir = path.dirname(configPath);
121
- fs.mkdirSync(dir, { recursive: true });
122
- const tmpPath = `${configPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
123
- fs.writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
124
- fs.renameSync(tmpPath, configPath);
125
- }
126
- }
127
- catch {
128
- // Non-fatal: best-effort persistence
129
- }
130
- }
131
91
  export function toPosix(input) {
132
92
  return input.replace(/\\/g, "/");
133
93
  }
@@ -144,12 +104,39 @@ export function isWithin(candidate, root) {
144
104
  const rel = path.relative(normalizedRoot, normalizedCandidate);
145
105
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
146
106
  }
107
+ /**
108
+ * Resolve symlinks on `p`, walking up to the closest existing ancestor when
109
+ * `p` itself does not exist. This ensures that comparisons between an
110
+ * existing directory and a not-yet-created child path inside it are
111
+ * consistent even when the directory hierarchy contains symlinks (e.g.
112
+ * macOS /tmp → /private/tmp, or a HOME that is itself a symlink).
113
+ */
147
114
  function safeRealpath(p) {
115
+ const resolved = path.resolve(p);
148
116
  try {
149
- return fs.realpathSync(path.resolve(p));
117
+ return fs.realpathSync(resolved);
150
118
  }
151
119
  catch {
152
- return path.resolve(p);
120
+ // Path doesn't exist — resolve symlinks on the nearest existing ancestor
121
+ // and reconstruct the full path from there.
122
+ const suffix = [];
123
+ let current = resolved;
124
+ for (;;) {
125
+ const parent = path.dirname(current);
126
+ if (parent === current) {
127
+ // Reached filesystem root without finding an existing entry.
128
+ return resolved;
129
+ }
130
+ suffix.unshift(path.basename(current));
131
+ current = parent;
132
+ try {
133
+ const realParent = fs.realpathSync(current);
134
+ return path.join(realParent, ...suffix);
135
+ }
136
+ catch {
137
+ // parent also doesn't exist; keep walking up
138
+ }
139
+ }
153
140
  }
154
141
  }
155
142
  function normalizeFsPathForComparison(value) {
@@ -207,6 +194,116 @@ export async function fetchWithRetry(url, init, options) {
207
194
  function shouldRetry(status) {
208
195
  return status === 429 || status >= 500;
209
196
  }
197
+ /**
198
+ * Read stdin as UTF-8 text if something is piped in. Returns `undefined`
199
+ * when stdin is a TTY (no pipe) or when the piped content is empty.
200
+ */
201
+ export function tryReadStdinText() {
202
+ if (process.stdin.isTTY)
203
+ return undefined;
204
+ const input = fs.readFileSync(0, "utf8");
205
+ return input.length > 0 ? input : undefined;
206
+ }
207
+ /**
208
+ * Default byte cap for untrusted network responses (10 MB).
209
+ *
210
+ * Applies to website scraping, registry index fetches, and any other
211
+ * response that is read into memory from a source the CLI does not fully
212
+ * control. A compromised or malicious endpoint that streams an unbounded
213
+ * response would otherwise exhaust RAM — this cap ensures the process
214
+ * aborts with a clean error instead of crashing.
215
+ */
216
+ export const DEFAULT_RESPONSE_BYTE_CAP = 10 * 1024 * 1024;
217
+ /**
218
+ * Thrown by {@link readBodyWithByteCap} and its helpers when a response
219
+ * body exceeds the caller's byte cap. Callers can catch this specifically
220
+ * to surface a targeted error to the user.
221
+ */
222
+ export class ResponseTooLargeError extends Error {
223
+ url;
224
+ maxBytes;
225
+ observedBytes;
226
+ constructor(url, maxBytes, observedBytes) {
227
+ const observed = observedBytes === null ? "unknown" : `${observedBytes} bytes`;
228
+ super(`Response body exceeded ${maxBytes} bytes (observed: ${observed}): ${url}`);
229
+ this.name = "ResponseTooLargeError";
230
+ this.url = url;
231
+ this.maxBytes = maxBytes;
232
+ this.observedBytes = observedBytes;
233
+ }
234
+ }
235
+ /**
236
+ * Read a Response body as a UTF-8 string with a byte-count cap.
237
+ *
238
+ * Streams the body so we abort as soon as the cap is exceeded, without
239
+ * buffering the full response first. If the server sent a
240
+ * `Content-Length` larger than the cap, we refuse before reading any
241
+ * bytes. `response.body` is consumed and cancelled on cap breach.
242
+ *
243
+ * `maxBytes` defaults to {@link DEFAULT_RESPONSE_BYTE_CAP} (10 MB).
244
+ */
245
+ export async function readBodyWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP) {
246
+ const url = response.url || "(unknown URL)";
247
+ const contentLengthHeader = response.headers.get("content-length");
248
+ if (contentLengthHeader) {
249
+ const declared = Number(contentLengthHeader);
250
+ if (Number.isFinite(declared) && declared > maxBytes) {
251
+ // Don't even start reading.
252
+ await response.body?.cancel?.().catch(() => undefined);
253
+ throw new ResponseTooLargeError(url, maxBytes, declared);
254
+ }
255
+ }
256
+ const body = response.body;
257
+ if (!body) {
258
+ // No streaming body available (e.g., some mock environments). Fall
259
+ // back to text() but still enforce the cap post-hoc.
260
+ const text = await response.text();
261
+ if (text.length > maxBytes)
262
+ throw new ResponseTooLargeError(url, maxBytes, text.length);
263
+ return text;
264
+ }
265
+ const reader = body.getReader();
266
+ const chunks = [];
267
+ let total = 0;
268
+ try {
269
+ while (true) {
270
+ const { done, value } = await reader.read();
271
+ if (done)
272
+ break;
273
+ if (!value)
274
+ continue;
275
+ total += value.byteLength;
276
+ if (total > maxBytes) {
277
+ await reader.cancel().catch(() => undefined);
278
+ throw new ResponseTooLargeError(url, maxBytes, total);
279
+ }
280
+ chunks.push(value);
281
+ }
282
+ }
283
+ finally {
284
+ reader.releaseLock?.();
285
+ }
286
+ if (chunks.length === 0)
287
+ return "";
288
+ if (chunks.length === 1)
289
+ return new TextDecoder().decode(chunks[0]);
290
+ const combined = new Uint8Array(total);
291
+ let offset = 0;
292
+ for (const chunk of chunks) {
293
+ combined.set(chunk, offset);
294
+ offset += chunk.byteLength;
295
+ }
296
+ return new TextDecoder().decode(combined);
297
+ }
298
+ /**
299
+ * Parse a Response body as JSON with a byte-count cap. A cheap wrapper
300
+ * around {@link readBodyWithByteCap}; prefer this for registry index
301
+ * fetches, GitHub API responses, and any other untrusted JSON source.
302
+ */
303
+ export async function jsonWithByteCap(response, maxBytes = DEFAULT_RESPONSE_BYTE_CAP) {
304
+ const text = await readBodyWithByteCap(response, maxBytes);
305
+ return JSON.parse(text);
306
+ }
210
307
  function parseRetryAfter(response) {
211
308
  const header = response.headers.get("retry-after");
212
309
  if (!header)
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
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";
@@ -29,22 +30,56 @@ export function resetConfigCache() {
29
30
  cachedConfig = undefined;
30
31
  cachedUserConfig = undefined;
31
32
  }
33
+ function hashString(text) {
34
+ // Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
35
+ // content changes between config writes when filesystem mtime resolution is
36
+ // too coarse to reflect rapid back-to-back writes (common in tests).
37
+ let hash = 0x811c9dc5;
38
+ for (let i = 0; i < text.length; i++) {
39
+ hash ^= text.charCodeAt(i);
40
+ hash = Math.imul(hash, 0x01000193);
41
+ }
42
+ return (hash >>> 0).toString(16);
43
+ }
32
44
  export function loadUserConfig() {
33
45
  const configPath = getConfigPath();
34
46
  let stat;
35
47
  try {
36
48
  stat = fs.statSync(configPath);
37
- if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
38
- return cachedUserConfig.config;
39
- }
40
49
  }
41
50
  catch {
42
51
  cachedUserConfig = undefined;
43
52
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
44
53
  }
45
- const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfig(configPath));
54
+ // Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
55
+ // when tests write multiple times within the filesystem mtime resolution
56
+ // window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
57
+ // we never serve stale config.
58
+ let text;
59
+ try {
60
+ text = fs.readFileSync(configPath, "utf8");
61
+ }
62
+ catch {
63
+ cachedUserConfig = undefined;
64
+ return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
65
+ }
66
+ const contentHash = hashString(text);
67
+ if (cachedUserConfig &&
68
+ cachedUserConfig.path === configPath &&
69
+ cachedUserConfig.mtime === stat.mtimeMs &&
70
+ cachedUserConfig.size === stat.size &&
71
+ cachedUserConfig.contentHash === contentHash) {
72
+ return cachedUserConfig.config;
73
+ }
74
+ const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(configPath, text));
46
75
  const finalConfig = applyRuntimeEnvApiKeys(config);
47
- cachedUserConfig = { config: finalConfig, path: configPath, mtime: stat.mtimeMs };
76
+ cachedUserConfig = {
77
+ config: finalConfig,
78
+ path: configPath,
79
+ mtime: stat.mtimeMs,
80
+ size: stat.size,
81
+ contentHash,
82
+ };
48
83
  return finalConfig;
49
84
  }
50
85
  export function loadConfig() {
@@ -66,6 +101,7 @@ export function loadConfig() {
66
101
  }
67
102
  export function saveConfig(config) {
68
103
  cachedConfig = undefined;
104
+ cachedUserConfig = undefined;
69
105
  const configPath = getConfigPath();
70
106
  const dir = path.dirname(configPath);
71
107
  fs.mkdirSync(dir, { recursive: true });
@@ -175,7 +211,13 @@ function pickKnownKeys(raw) {
175
211
  const registries = parseRegistriesConfig(raw.registries);
176
212
  if (registries)
177
213
  config.registries = registries;
178
- if (typeof raw.disableGlobalStashes === "boolean") {
214
+ // Prefer the new `stashInheritance` field; fall back to the legacy boolean
215
+ // `disableGlobalStashes` so existing config files keep working unchanged.
216
+ if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
217
+ config.stashInheritance = raw.stashInheritance;
218
+ }
219
+ else if (typeof raw.disableGlobalStashes === "boolean") {
220
+ config.stashInheritance = raw.disableGlobalStashes ? "replace" : "merge";
179
221
  config.disableGlobalStashes = raw.disableGlobalStashes;
180
222
  }
181
223
  const stashes = parseStashesConfig(raw.stashes);
@@ -197,6 +239,19 @@ function readNormalizedConfig(configPath) {
197
239
  const expanded = raw ? expandEnvVars(raw) : undefined;
198
240
  return expanded ? pickKnownKeys(expanded) : undefined;
199
241
  }
242
+ function readNormalizedConfigFromText(_configPath, text) {
243
+ let raw;
244
+ try {
245
+ raw = JSON.parse(stripJsonComments(text));
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
251
+ return undefined;
252
+ const expanded = expandEnvVars(raw);
253
+ return pickKnownKeys(expanded);
254
+ }
200
255
  function parseOutputConfig(value) {
201
256
  if (typeof value !== "object" || value === null || Array.isArray(value))
202
257
  return undefined;
@@ -428,11 +483,11 @@ function parseInstalledEntries(value) {
428
483
  if (!Array.isArray(value))
429
484
  return undefined;
430
485
  const entries = value
431
- .map((entry) => parseInstalledKitEntry(entry))
486
+ .map((entry) => parseInstalledStashEntry(entry))
432
487
  .filter((entry) => entry !== undefined);
433
488
  return entries.length > 0 ? entries : undefined;
434
489
  }
435
- function parseInstalledKitEntry(value) {
490
+ function parseInstalledStashEntry(value) {
436
491
  if (typeof value !== "object" || value === null || Array.isArray(value))
437
492
  return undefined;
438
493
  const obj = value;
@@ -470,6 +525,14 @@ function parseInstalledKitEntry(value) {
470
525
  function asNonEmptyString(value) {
471
526
  return typeof value === "string" && value ? value : undefined;
472
527
  }
528
+ /**
529
+ * Validate a legacy lockfile/installed-entry source string.
530
+ *
531
+ * Restricted to the four kinds that the install pipeline produces
532
+ * (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
533
+ * wider, but persisted `installed[]` entries should never carry the runtime
534
+ * provider kinds (`"filesystem" | "website" | "openviking"`).
535
+ */
473
536
  function asKitSource(value) {
474
537
  if (value === "npm" || value === "github" || value === "git" || value === "local")
475
538
  return value;
@@ -550,13 +613,24 @@ function parseInstallAuditAllowedFinding(value) {
550
613
  finding.reason = reason;
551
614
  return finding;
552
615
  }
616
+ /**
617
+ * Legacy stash type aliases that are normalized to canonical types at
618
+ * config-load time. Both "context-hub" and "github" were never distinct
619
+ * provider types — they were always git stashes — so we normalize them in
620
+ * memory to "git" without rewriting `config.json` on disk.
621
+ */
622
+ const STASH_TYPE_ALIASES = {
623
+ "context-hub": "git",
624
+ github: "git",
625
+ };
553
626
  function parseStashConfigEntry(value) {
554
627
  if (typeof value !== "object" || value === null || Array.isArray(value))
555
628
  return undefined;
556
629
  const obj = value;
557
- const type = asNonEmptyString(obj.type);
558
- if (!type)
630
+ const rawType = asNonEmptyString(obj.type);
631
+ if (!rawType)
559
632
  return undefined;
633
+ const type = STASH_TYPE_ALIASES[rawType] ?? rawType;
560
634
  const entry = { type };
561
635
  const entryPath = asNonEmptyString(obj.path);
562
636
  if (entryPath)
@@ -571,6 +645,8 @@ function parseStashConfigEntry(value) {
571
645
  entry.enabled = obj.enabled;
572
646
  if (typeof obj.writable === "boolean")
573
647
  entry.writable = obj.writable;
648
+ if (typeof obj.primary === "boolean")
649
+ entry.primary = obj.primary;
574
650
  if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
575
651
  entry.options = obj.options;
576
652
  }
@@ -579,6 +655,124 @@ function parseStashConfigEntry(value) {
579
655
  entry.wikiName = wikiName;
580
656
  return entry;
581
657
  }
658
+ // ── StashEntry runtime construction ─────────────────────────────────────────
659
+ /**
660
+ * Synthesize a stable identifier when a {@link StashConfigEntry} omits its
661
+ * `name`. Uses a short hash of the discriminating fields so two equivalent
662
+ * entries collapse to the same generated name.
663
+ */
664
+ function deriveStashEntryName(entry) {
665
+ if (entry.name)
666
+ return entry.name;
667
+ const seed = JSON.stringify({
668
+ type: entry.type,
669
+ path: entry.path ?? null,
670
+ url: entry.url ?? null,
671
+ });
672
+ const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
673
+ return `${entry.type}-${hash}`;
674
+ }
675
+ /**
676
+ * Convert a persisted {@link StashConfigEntry} into the runtime
677
+ * {@link StashSource} discriminated union. Returns `undefined` when the
678
+ * entry is missing the fields its provider type requires (e.g. a
679
+ * `filesystem` entry with no `path`); callers should drop or warn for those.
680
+ *
681
+ * Unknown provider types fall back to `{ type: "filesystem", path: ... }` so
682
+ * legacy aliases (`"context-hub"`, `"github"` for git) still produce a usable
683
+ * runtime value when a path/url is supplied.
684
+ */
685
+ export function parseStashEntrySource(entry) {
686
+ switch (entry.type) {
687
+ case "filesystem":
688
+ return entry.path ? { type: "filesystem", path: entry.path } : undefined;
689
+ case "git":
690
+ case "context-hub":
691
+ case "github":
692
+ // Note: a configured `github` provider entry historically meant "git
693
+ // repo over the GitHub web URL", not the registry-install `github:` ref.
694
+ return entry.url ? { type: "git", url: entry.url } : undefined;
695
+ case "website":
696
+ return entry.url
697
+ ? {
698
+ type: "website",
699
+ url: entry.url,
700
+ ...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
701
+ }
702
+ : undefined;
703
+ case "openviking":
704
+ return entry.url ? { type: "openviking", url: entry.url } : undefined;
705
+ case "npm":
706
+ // Persisted `npm` stash entries are unusual but supported for symmetry.
707
+ return entry.path ? { type: "npm", package: entry.path } : undefined;
708
+ default:
709
+ // Unknown provider — best-effort fallback so callers still get something.
710
+ return entry.path ? { type: "filesystem", path: entry.path } : undefined;
711
+ }
712
+ }
713
+ /**
714
+ * Build the full ordered list of runtime {@link StashEntry} values from a
715
+ * loaded {@link AkmConfig}. Order is the canonical iteration order:
716
+ *
717
+ * 1. The entry marked `primary: true` (or, as a backwards-compat shim,
718
+ * a synthetic filesystem entry built from the top-level `stashDir`).
719
+ * 2. Remaining `stashes[]` entries in declared order.
720
+ * 3. Legacy `installed[]` entries, mapped into runtime entries.
721
+ *
722
+ * Entries with `enabled: false` are still emitted — callers decide whether
723
+ * to honour the flag (mirrors how `installed[]` entries have always been
724
+ * unconditional). Entries that fail {@link parseStashEntrySource} are
725
+ * dropped silently.
726
+ */
727
+ export function resolveStashEntries(config) {
728
+ const entries = [];
729
+ const stashes = config.stashes ?? [];
730
+ // (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
731
+ let primary = stashes.find((entry) => entry.primary === true);
732
+ if (!primary && config.stashDir) {
733
+ primary = { type: "filesystem", path: config.stashDir, primary: true };
734
+ }
735
+ if (primary) {
736
+ const runtime = toStashEntry(primary, true);
737
+ if (runtime)
738
+ entries.push(runtime);
739
+ }
740
+ // (2) Declared stashes (skip the primary entry — already added).
741
+ for (const entry of stashes) {
742
+ if (entry === primary)
743
+ continue;
744
+ const runtime = toStashEntry(entry, false);
745
+ if (runtime)
746
+ entries.push(runtime);
747
+ }
748
+ // (3) Legacy installed[] entries.
749
+ for (const installed of config.installed ?? []) {
750
+ entries.push({
751
+ name: installed.id,
752
+ type: "filesystem",
753
+ source: { type: "filesystem", path: installed.stashRoot },
754
+ enabled: true,
755
+ writable: installed.writable,
756
+ ...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
757
+ });
758
+ }
759
+ return entries;
760
+ }
761
+ function toStashEntry(persisted, isPrimary) {
762
+ const source = parseStashEntrySource(persisted);
763
+ if (!source)
764
+ return undefined;
765
+ return {
766
+ name: deriveStashEntryName(persisted),
767
+ type: persisted.type,
768
+ source,
769
+ ...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
770
+ ...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
771
+ ...(isPrimary || persisted.primary ? { primary: true } : {}),
772
+ ...(persisted.options ? { options: persisted.options } : {}),
773
+ ...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
774
+ };
775
+ }
582
776
  function parseRegistryConfigEntry(value) {
583
777
  if (typeof value !== "object" || value === null || Array.isArray(value))
584
778
  return undefined;
@@ -621,7 +815,8 @@ function mergeInstallAuditConfig(base, override) {
621
815
  * Scalar fields follow normal override semantics. Known nested objects are
622
816
  * deep-merged so project config files can override individual fields without
623
817
  * clobbering sibling settings. `stashes` are additive by default, but a later
624
- * layer can set `disableGlobalStashes: true` to drop inherited stashes first.
818
+ * layer can set `stashInheritance: "replace"` (or the legacy
819
+ * `disableGlobalStashes: true`) to drop inherited stashes first.
625
820
  */
626
821
  function mergeLoadedConfig(base, override) {
627
822
  if (!override)
@@ -642,7 +837,11 @@ function mergeLoadedConfig(base, override) {
642
837
  if (base.security && override.security) {
643
838
  merged.security = mergeSecurityConfig(base.security, override.security);
644
839
  }
645
- if (override.disableGlobalStashes) {
840
+ // The new `stashInheritance` field wins; fall back to the legacy
841
+ // `disableGlobalStashes` boolean so old config files behave identically.
842
+ const replaceStashes = override.stashInheritance === "replace" ||
843
+ (override.stashInheritance === undefined && override.disableGlobalStashes === true);
844
+ if (replaceStashes) {
646
845
  merged.stashes = [...(override.stashes ?? [])];
647
846
  }
648
847
  else if (override.stashes) {
@@ -713,7 +912,19 @@ function isFile(filePath) {
713
912
  }
714
913
  function getFileSignatureToken(filePath) {
715
914
  try {
716
- return String(fs.statSync(filePath).mtimeMs);
915
+ const stat = fs.statSync(filePath);
916
+ // mtimeMs alone is unreliable on filesystems with low-resolution mtime
917
+ // (HFS+, some network FS, or very fast back-to-back writes in tests).
918
+ // Combine mtime + size + content hash so the signature actually changes
919
+ // when content does.
920
+ let contentHash = "";
921
+ try {
922
+ contentHash = hashString(fs.readFileSync(filePath, "utf8"));
923
+ }
924
+ catch {
925
+ // ignore — fall back to stat-only signature
926
+ }
927
+ return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
717
928
  }
718
929
  catch {
719
930
  return "missing";
@@ -2,7 +2,7 @@
2
2
  * Generic factory-map utility.
3
3
  *
4
4
  * Creates a lightweight registry that maps string keys to factory functions.
5
- * Both registry-factory.ts (kit discovery) and stash-provider-factory.ts
5
+ * Both registry-factory.ts (stash discovery) and stash-provider-factory.ts
6
6
  * (stash source providers) are built on this utility.
7
7
  */
8
8
  export function createProviderRegistry() {