akm-cli 0.5.0-rc2 → 0.5.0-rc4

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.
package/dist/cli.js CHANGED
@@ -320,6 +320,12 @@ function formatPlain(command, result, detail) {
320
320
  case "index": {
321
321
  const indexResult = result;
322
322
  let out = `Indexed ${indexResult.totalEntries ?? 0} entries from ${indexResult.directoriesScanned ?? 0} directories (mode: ${indexResult.mode ?? "unknown"})`;
323
+ const warnings = indexResult.warnings;
324
+ if (Array.isArray(warnings) && warnings.length > 0) {
325
+ out += `\nWarnings (${warnings.length}):`;
326
+ for (const message of warnings)
327
+ out += `\n - ${String(message)}`;
328
+ }
323
329
  const verification = indexResult.verification;
324
330
  if (verification?.ok === false && verification.message) {
325
331
  out += `\nVerification: ${String(verification.message)}`;
@@ -458,6 +464,8 @@ function formatPlain(command, result, detail) {
458
464
  const ver = typeof src.version === "string" ? ` v${src.version}` : "";
459
465
  const prov = typeof src.provider === "string" ? ` (${src.provider})` : "";
460
466
  const flags = [];
467
+ if (typeof src.wiki === "string")
468
+ flags.push(`wiki:${src.wiki}`);
461
469
  if (src.updatable === true)
462
470
  flags.push("updatable");
463
471
  if (src.writable === true)
@@ -472,6 +480,12 @@ function formatPlain(command, result, detail) {
472
480
  const scanned = index?.directoriesScanned ?? 0;
473
481
  const total = index?.totalEntries ?? 0;
474
482
  const lines = [`Installed ${r.ref} (${scanned} directories scanned, ${total} total assets indexed)`];
483
+ const warnings = index?.warnings;
484
+ if (Array.isArray(warnings) && warnings.length > 0) {
485
+ lines.push(`Warnings (${warnings.length}):`);
486
+ for (const message of warnings)
487
+ lines.push(` - ${String(message)}`);
488
+ }
475
489
  const installed = r.installed;
476
490
  const audit = installed?.audit;
477
491
  if (audit && typeof audit === "object") {
@@ -651,7 +665,7 @@ function formatSearchPlain(r, detail) {
651
665
  function formatWikiListPlain(r) {
652
666
  const wikis = Array.isArray(r.wikis) ? r.wikis : [];
653
667
  if (wikis.length === 0)
654
- return "No wikis. Create one with `akm wiki create <name>`.";
668
+ return "No wikis. Create one with `akm wiki create <name>` or register one with `akm wiki register <name> <path-or-repo>`.";
655
669
  const lines = ["NAME\tPAGES\tRAWS\tLAST-MODIFIED"];
656
670
  for (const w of wikis) {
657
671
  const name = typeof w.name === "string" ? w.name : "?";
@@ -1205,11 +1219,19 @@ const addCommand = defineCommand({
1205
1219
  if (shouldWarnOnPlainHttp(ref)) {
1206
1220
  warn("Warning: source URL uses plain HTTP (not HTTPS). For security, prefer https:// to protect against eavesdropping and tampering.");
1207
1221
  }
1208
- const websiteOptions = {};
1209
- if (args["max-pages"])
1210
- websiteOptions.maxPages = args["max-pages"];
1211
- if (args["max-depth"])
1212
- websiteOptions.maxDepth = args["max-depth"];
1222
+ const websiteOptions = buildWebsiteOptions(args);
1223
+ if (args.type === "wiki") {
1224
+ const { registerWikiSource } = await import("./stash-add");
1225
+ const result = await registerWikiSource({
1226
+ ref,
1227
+ name: args.name,
1228
+ options: Object.keys(websiteOptions).length > 0 ? websiteOptions : undefined,
1229
+ trustThisInstall: args.trust,
1230
+ writable: args.writable,
1231
+ });
1232
+ output("add", result);
1233
+ return;
1234
+ }
1213
1235
  const result = await akmAdd({
1214
1236
  ref,
1215
1237
  name: args.name,
@@ -1222,6 +1244,14 @@ const addCommand = defineCommand({
1222
1244
  });
1223
1245
  },
1224
1246
  });
1247
+ function buildWebsiteOptions(args) {
1248
+ const websiteOptions = {};
1249
+ if (typeof args["max-pages"] === "string" && args["max-pages"].length > 0)
1250
+ websiteOptions.maxPages = args["max-pages"];
1251
+ if (typeof args["max-depth"] === "string" && args["max-depth"].length > 0)
1252
+ websiteOptions.maxDepth = args["max-depth"];
1253
+ return websiteOptions;
1254
+ }
1225
1255
  const VALID_SOURCE_KINDS = new Set(["local", "managed", "remote"]);
1226
1256
  function parseKindFilter(raw) {
1227
1257
  if (!raw)
@@ -2464,6 +2494,41 @@ const wikiCreateCommand = defineCommand({
2464
2494
  });
2465
2495
  },
2466
2496
  });
2497
+ const wikiRegisterCommand = defineCommand({
2498
+ meta: {
2499
+ name: "register",
2500
+ description: "Register an existing directory or repo as a first-class wiki without copying or mutating it; refreshes source and wiki search state immediately",
2501
+ },
2502
+ args: {
2503
+ name: { type: "positional", description: "Wiki name (lowercase, digits, hyphens)", required: true },
2504
+ ref: { type: "positional", description: "Path or repo ref for the external wiki source", required: true },
2505
+ writable: {
2506
+ type: "boolean",
2507
+ description: "Mark a git-backed source as writable so changes can be pushed back",
2508
+ default: false,
2509
+ },
2510
+ trust: {
2511
+ type: "boolean",
2512
+ description: "Bypass install-audit blocking for this registration only",
2513
+ default: false,
2514
+ },
2515
+ "max-pages": { type: "string", description: "Maximum pages to crawl for website sources (default: 50)" },
2516
+ "max-depth": { type: "string", description: "Maximum crawl depth for website sources (default: 3)" },
2517
+ },
2518
+ run({ args }) {
2519
+ return runWithJsonErrors(async () => {
2520
+ const { registerWikiSource } = await import("./stash-add");
2521
+ const result = await registerWikiSource({
2522
+ ref: args.ref.trim(),
2523
+ name: args.name,
2524
+ options: Object.keys(buildWebsiteOptions(args)).length > 0 ? buildWebsiteOptions(args) : undefined,
2525
+ trustThisInstall: args.trust,
2526
+ writable: args.writable,
2527
+ });
2528
+ output("wiki-register", result);
2529
+ });
2530
+ },
2531
+ });
2467
2532
  const wikiListCommand = defineCommand({
2468
2533
  meta: { name: "list", description: "List wikis with page/raw counts and last-modified timestamps" },
2469
2534
  run() {
@@ -2492,7 +2557,7 @@ const wikiShowCommand = defineCommand({
2492
2557
  const wikiRemoveCommand = defineCommand({
2493
2558
  meta: {
2494
2559
  name: "remove",
2495
- description: "Remove a wiki. Preserves raw/ by default; pass --with-sources to also delete raw/",
2560
+ description: "Remove a wiki and refresh the index. Preserves raw/ by default; pass --with-sources to also delete raw/",
2496
2561
  },
2497
2562
  args: {
2498
2563
  name: { type: "positional", description: "Wiki name", required: true },
@@ -2514,8 +2579,10 @@ const wikiRemoveCommand = defineCommand({
2514
2579
  }
2515
2580
  const withSources = Boolean(args["with-sources"]);
2516
2581
  const { removeWiki } = await import("./wiki.js");
2582
+ const { akmIndex } = await import("./indexer");
2517
2583
  const stashDir = resolveStashDir();
2518
2584
  const result = removeWiki(stashDir, args.name, { withSources });
2585
+ await akmIndex({ stashDir });
2519
2586
  output("wiki-remove", result);
2520
2587
  });
2521
2588
  },
@@ -2540,7 +2607,7 @@ const wikiPagesCommand = defineCommand({
2540
2607
  const wikiSearchCommand = defineCommand({
2541
2608
  meta: {
2542
2609
  name: "search",
2543
- description: "Search wiki pages within a single wiki (scoped wrapper over `akm search --type wiki`; excludes raw/schema/index/log)",
2610
+ description: "Search wiki pages within a single wiki (scoped wrapper over `akm search --type wiki`; excludes raw/schema/index/log and returns canonical wiki refs)",
2544
2611
  },
2545
2612
  args: {
2546
2613
  name: { type: "positional", description: "Wiki name", required: true },
@@ -2549,12 +2616,9 @@ const wikiSearchCommand = defineCommand({
2549
2616
  },
2550
2617
  run({ args }) {
2551
2618
  return runWithJsonErrors(async () => {
2552
- const { searchInWiki } = await import("./wiki.js");
2619
+ const { resolveWikiSource, searchInWiki } = await import("./wiki.js");
2553
2620
  const stashDir = resolveStashDir();
2554
- const wikiDir = path.join(stashDir, "wikis", args.name);
2555
- if (!fs.existsSync(wikiDir)) {
2556
- throw new NotFoundError(`Wiki not found: ${args.name}`);
2557
- }
2621
+ resolveWikiSource(stashDir, args.name);
2558
2622
  const parsedLimit = args.limit ? Number(args.limit) : undefined;
2559
2623
  const limit = typeof parsedLimit === "number" && Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined;
2560
2624
  const response = await searchInWiki({ stashDir, wikiName: args.name, query: args.query, limit });
@@ -2633,6 +2697,7 @@ const wikiCommand = defineCommand({
2633
2697
  },
2634
2698
  subCommands: {
2635
2699
  create: wikiCreateCommand,
2700
+ register: wikiRegisterCommand,
2636
2701
  list: wikiListCommand,
2637
2702
  show: wikiShowCommand,
2638
2703
  remove: wikiRemoveCommand,
@@ -2695,7 +2760,18 @@ const main = defineCommand({
2695
2760
  });
2696
2761
  const CONFIG_SUBCOMMAND_SET = new Set(["path", "list", "get", "set", "unset"]);
2697
2762
  const VAULT_SUBCOMMAND_SET = new Set(["list", "show", "create", "set", "unset", "load"]);
2698
- const WIKI_SUBCOMMAND_SET = new Set(["create", "list", "show", "remove", "pages", "search", "stash", "lint", "ingest"]);
2763
+ const WIKI_SUBCOMMAND_SET = new Set([
2764
+ "create",
2765
+ "register",
2766
+ "list",
2767
+ "show",
2768
+ "remove",
2769
+ "pages",
2770
+ "search",
2771
+ "stash",
2772
+ "lint",
2773
+ "ingest",
2774
+ ]);
2699
2775
  const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);
2700
2776
  // citty reads process.argv directly and does not accept a custom argv array,
2701
2777
  // so we must replace process.argv with the normalized version before runMain.
@@ -2974,14 +3050,15 @@ ranking can learn from actual usage.
2974
3050
 
2975
3051
  ## Wikis
2976
3052
 
2977
- Multi-wiki knowledge bases (Karpathy-style). Each wiki is a directory at
2978
- \`<stashDir>/wikis/<name>/\` with \`schema.md\`, \`index.md\`, \`log.md\`, \`raw/\`,
2979
- and agent-authored pages. akm owns lifecycle + raw-slug + lint + index
2980
- regeneration; page edits use your native Read/Write/Edit tools.
3053
+ Multi-wiki knowledge bases (Karpathy-style). A stash-owned wiki lives at
3054
+ \`<stashDir>/wikis/<name>/\`; external directories or repos can also be registered
3055
+ as first-class wikis. akm owns lifecycle + raw-slug + lint + index regeneration
3056
+ for stash-owned wikis; page edits use your native Read/Write/Edit tools.
2981
3057
 
2982
3058
  \`\`\`sh
2983
3059
  akm wiki list # List wikis (name, pages, raws, last-modified)
2984
3060
  akm wiki create research # Scaffold a new wiki
3061
+ akm wiki register ics-docs ~/code/ics-documentation # Register an external wiki
2985
3062
  akm wiki show research # Path, description, counts, last 3 log entries
2986
3063
  akm wiki pages research # Page refs + descriptions (excludes schema/index/log/raw)
2987
3064
  akm wiki search research "attention" # Scoped search (equivalent to --type wiki --wiki research)
package/dist/indexer.js CHANGED
@@ -80,7 +80,7 @@ export async function akmIndex(options) {
80
80
  // doFullDelete=true merges the wipe into the same transaction as the
81
81
  // inserts so readers never see an empty database mid-rebuild.
82
82
  const doFullDelete = options?.full || !isIncremental;
83
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm } = await indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete);
83
+ const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings } = await indexEntries(db, allStashSources, isIncremental, builtAtMs, doFullDelete);
84
84
  onProgress({
85
85
  phase: "scan",
86
86
  message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
@@ -166,6 +166,7 @@ export async function akmIndex(options) {
166
166
  mode: isIncremental ? "incremental" : "full",
167
167
  directoriesScanned: scannedDirs,
168
168
  directoriesSkipped: skippedDirs,
169
+ ...(warnings.length > 0 ? { warnings } : {}),
169
170
  verification,
170
171
  timing: {
171
172
  totalMs: tEnd - t0,
@@ -185,6 +186,7 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
185
186
  let scannedDirs = 0;
186
187
  let skippedDirs = 0;
187
188
  let generatedCount = 0;
189
+ const warnings = [];
188
190
  const seenPaths = new Set();
189
191
  const dirsNeedingLlm = [];
190
192
  const dirRecords = [];
@@ -261,6 +263,8 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
261
263
  const uncoveredFiles = files.filter((f) => !coveredFiles.has(path.basename(f)));
262
264
  if (uncoveredFiles.length > 0) {
263
265
  const generated = await generateMetadataFlat(currentStashDir, uncoveredFiles);
266
+ if (generated.warnings?.length)
267
+ warnings.push(...generated.warnings);
264
268
  if (generated.entries.length > 0) {
265
269
  stash = { entries: [...stash.entries, ...generated.entries] };
266
270
  generatedCount += generated.entries.length;
@@ -269,6 +273,8 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
269
273
  }
270
274
  if (!stash) {
271
275
  const generated = await generateMetadataFlat(currentStashDir, files);
276
+ if (generated.warnings?.length)
277
+ warnings.push(...generated.warnings);
272
278
  if (generated.entries.length > 0) {
273
279
  stash = { entries: generated.entries };
274
280
  generatedCount += generated.entries.length;
@@ -351,7 +357,7 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
351
357
  }
352
358
  });
353
359
  insertTransaction();
354
- return { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm };
360
+ return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm };
355
361
  }
356
362
  async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
357
363
  if (!config.llm || dirsNeedingLlm.length === 0)
@@ -29,6 +29,7 @@ export async function akmListSources(input) {
29
29
  sources.push({
30
30
  name,
31
31
  kind,
32
+ wiki: stash.wikiName,
32
33
  path: stash.path,
33
34
  provider: isRemote ? stash.type : undefined,
34
35
  updatable: false,
@@ -44,6 +45,7 @@ export async function akmListSources(input) {
44
45
  sources.push({
45
46
  name: entry.id,
46
47
  kind,
48
+ wiki: entry.wikiName,
47
49
  path: entry.stashRoot,
48
50
  ref: entry.ref,
49
51
  version: entry.resolvedVersion,
@@ -29,6 +29,15 @@ export function buildLocalAction(type, ref) {
29
29
  const builder = ACTION_BUILDERS[type];
30
30
  return builder ? builder(ref) : `akm show ${ref}`;
31
31
  }
32
+ function resolveSearchHitRef(entry, refName, source) {
33
+ if (source?.wikiName) {
34
+ return makeAssetRef(entry.type, entry.name);
35
+ }
36
+ return makeAssetRef(entry.type, refName, source?.registryId);
37
+ }
38
+ function resolveSearchHitOrigin(source) {
39
+ return source?.wikiName ? null : (source?.registryId ?? null);
40
+ }
32
41
  // ── Main search entrypoint ───────────────────────────────────────────────────
33
42
  export async function searchLocal(input) {
34
43
  const { query, searchType, limit, stashDir, sources, config } = input;
@@ -393,7 +402,7 @@ async function tryVecScores(db, query, k, config) {
393
402
  }
394
403
  // ── Substring fallback (no index) ───────────────────────────────────────────
395
404
  async function substringSearch(query, searchType, limit, stashDir, sources, config) {
396
- const assets = await indexAssets(stashDir, searchType);
405
+ const assets = await indexAssets(stashDir, searchType, sources);
397
406
  const matched = assets.filter((asset) => !query || buildSearchText(asset.entry).includes(query));
398
407
  if (!query) {
399
408
  const sorted = matched.sort(compareAssets);
@@ -448,20 +457,21 @@ export async function buildDbHit(input) {
448
457
  const score = Math.round(input.score * 10000) / 10000;
449
458
  const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted);
450
459
  const source = findSourceForPath(input.path, input.sources);
460
+ const ref = resolveSearchHitRef(input.entry, refName, source);
451
461
  const editable = isEditable(input.path, input.config);
452
462
  const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
453
463
  const hit = {
454
464
  type: input.entry.type,
455
465
  name: input.entry.name,
456
466
  path: input.path,
457
- ref: makeAssetRef(input.entry.type, refName, source?.registryId),
458
- origin: source?.registryId ?? null,
467
+ ref,
468
+ origin: resolveSearchHitOrigin(source),
459
469
  editable,
460
470
  ...(!editable ? { editHint: buildEditHint(input.path, input.entry.type, refName, source?.registryId) } : {}),
461
471
  description: input.entry.description,
462
472
  tags: input.entry.tags,
463
473
  size: deriveSize(input.entry.fileSize),
464
- action: buildLocalAction(input.entry.type, makeAssetRef(input.entry.type, refName, source?.registryId)),
474
+ action: buildLocalAction(input.entry.type, ref),
465
475
  score,
466
476
  whyMatched,
467
477
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
@@ -523,7 +533,7 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
523
533
  async function assetToSearchHit(asset, stashDir, sources, config, score) {
524
534
  const source = findSourceForPath(asset.path, sources);
525
535
  const editable = isEditable(asset.path, config);
526
- const ref = makeAssetRef(asset.entry.type, asset.entry.name, source?.registryId);
536
+ const ref = resolveSearchHitRef(asset.entry, asset.entry.name, source);
527
537
  const fileSize = readFileSize(asset.path);
528
538
  const size = deriveSize(fileSize);
529
539
  const estimatedTokens = typeof fileSize === "number" ? Math.round(fileSize / 4) : undefined;
@@ -532,7 +542,7 @@ async function assetToSearchHit(asset, stashDir, sources, config, score) {
532
542
  name: asset.entry.name,
533
543
  path: asset.path,
534
544
  ref,
535
- origin: source?.registryId ?? null,
545
+ origin: resolveSearchHitOrigin(source),
536
546
  editable,
537
547
  ...(!editable
538
548
  ? { editHint: buildEditHint(asset.path, asset.entry.type, asset.entry.name, source?.registryId) }
@@ -568,7 +578,12 @@ function readFileSize(filePath) {
568
578
  return undefined;
569
579
  }
570
580
  }
571
- async function indexAssets(stashDir, type) {
581
+ async function indexAssets(stashDir, type, sources) {
582
+ const resolvedStashDir = realpathOrResolve(stashDir);
583
+ const source = sources?.find((entry) => realpathOrResolve(entry.path) === resolvedStashDir);
584
+ if (source?.wikiName) {
585
+ return indexWikiRootAssets(stashDir, source.wikiName, type);
586
+ }
572
587
  const assets = [];
573
588
  const filterType = type === "any" ? undefined : type;
574
589
  const fileContexts = walkStashFlat(stashDir);
@@ -626,6 +641,29 @@ async function indexAssets(stashDir, type) {
626
641
  }
627
642
  return assets;
628
643
  }
644
+ async function indexWikiRootAssets(wikiRoot, wikiName, type) {
645
+ if (type !== "any" && type !== "wiki")
646
+ return [];
647
+ const assets = [];
648
+ for (const ctx of walkStashFlat(wikiRoot)) {
649
+ if (ctx.ext !== ".md")
650
+ continue;
651
+ if (!shouldIndexStashFile(wikiRoot, ctx.absPath, { treatStashRootAsWikiRoot: true }))
652
+ continue;
653
+ const relNoExt = ctx.relPath.replace(/\.md$/, "");
654
+ assets.push({
655
+ entry: {
656
+ name: `${wikiName}/${relNoExt}`,
657
+ type: "wiki",
658
+ filename: ctx.fileName,
659
+ description: ctx.frontmatter()?.description,
660
+ source: "frontmatter",
661
+ },
662
+ path: ctx.absPath,
663
+ });
664
+ }
665
+ return assets;
666
+ }
629
667
  function compareAssets(a, b) {
630
668
  if (a.entry.type !== b.entry.type)
631
669
  return a.entry.type.localeCompare(b.entry.type);
@@ -659,3 +697,11 @@ function deduplicateAssetsByPath(assets) {
659
697
  return true;
660
698
  });
661
699
  }
700
+ function realpathOrResolve(targetPath) {
701
+ try {
702
+ return fs.realpathSync(targetPath);
703
+ }
704
+ catch {
705
+ return path.resolve(targetPath);
706
+ }
707
+ }
package/dist/metadata.js CHANGED
@@ -362,6 +362,7 @@ function mergeParameters(existing, additional) {
362
362
  // ── Metadata Generation ─────────────────────────────────────────────────────
363
363
  export async function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
364
364
  const entries = [];
365
+ const warnings = [];
365
366
  const pkgMeta = extractPackageMetadata(dirPath);
366
367
  for (const file of files) {
367
368
  const ext = path.extname(file).toLowerCase();
@@ -428,7 +429,13 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
428
429
  const renderer = await getRenderer(match.renderer);
429
430
  if (renderer?.extractMetadata) {
430
431
  const renderCtx = buildRenderContext(fileCtx, match, [typeRoot]);
431
- renderer.extractMetadata(entry, renderCtx);
432
+ try {
433
+ renderer.extractMetadata(entry, renderCtx);
434
+ }
435
+ catch (error) {
436
+ warnings.push(buildMetadataSkipWarning(file, assetType, error));
437
+ continue;
438
+ }
432
439
  }
433
440
  }
434
441
  // Priority 4: Filename heuristics (fallback)
@@ -447,7 +454,7 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
447
454
  entry.filename = path.basename(file);
448
455
  entries.push(entry);
449
456
  }
450
- return { entries };
457
+ return warnings.length > 0 ? { entries, warnings } : { entries };
451
458
  }
452
459
  /**
453
460
  * Generate metadata for files using the matcher system instead of a fixed asset type.
@@ -458,6 +465,7 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
458
465
  */
459
466
  export async function generateMetadataFlat(stashRoot, files) {
460
467
  const entries = [];
468
+ const warnings = [];
461
469
  const pkgMetaCache = new Map();
462
470
  for (const file of files) {
463
471
  if (!shouldIndexStashFile(stashRoot, file))
@@ -534,7 +542,13 @@ export async function generateMetadataFlat(stashRoot, files) {
534
542
  const renderer = await getRenderer(match.renderer);
535
543
  if (renderer?.extractMetadata) {
536
544
  const renderCtx = buildRenderContext(ctx, match, [stashRoot]);
537
- renderer.extractMetadata(entry, renderCtx);
545
+ try {
546
+ renderer.extractMetadata(entry, renderCtx);
547
+ }
548
+ catch (error) {
549
+ warnings.push(buildMetadataSkipWarning(file, assetType, error));
550
+ continue;
551
+ }
538
552
  }
539
553
  // Filename heuristics fallback
540
554
  if (!entry.description) {
@@ -550,7 +564,13 @@ export async function generateMetadataFlat(stashRoot, files) {
550
564
  entry.filename = path.basename(file);
551
565
  entries.push(entry);
552
566
  }
553
- return { entries };
567
+ return warnings.length > 0 ? { entries, warnings } : { entries };
568
+ }
569
+ function buildMetadataSkipWarning(filePath, assetType, error) {
570
+ const detail = error instanceof Error ? error.message : String(error);
571
+ const warning = `Skipped malformed ${assetType} asset at ${filePath}: ${detail}`;
572
+ warn(warning);
573
+ return warning;
554
574
  }
555
575
  function normalizeTerms(values) {
556
576
  const normalized = new Set();
package/dist/stash-add.js CHANGED
@@ -9,7 +9,7 @@ import { detectStashRoot, installRegistryRef, upsertInstalledRegistryEntry } fro
9
9
  import { parseRegistryRef } from "./registry-resolve";
10
10
  import { ensureWebsiteMirror, validateWebsiteInputUrl } from "./stash-providers/website";
11
11
  import { warn } from "./warn";
12
- import { validateWikiName } from "./wiki";
12
+ import { ensureWikiNameAvailable, validateWikiName } from "./wiki";
13
13
  const VALID_OVERRIDE_TYPES = new Set(["wiki"]);
14
14
  export async function akmAdd(input) {
15
15
  const ref = input.ref.trim();
@@ -47,6 +47,20 @@ export async function akmAdd(input) {
47
47
  }
48
48
  return addRegistryKit(ref, stashDir, input.trustThisInstall, input.writable, wikiName);
49
49
  }
50
+ export async function registerWikiSource(input) {
51
+ const stashDir = resolveStashDir();
52
+ const name = input.name ?? deriveWikiNameFromRef(input.ref);
53
+ validateWikiName(name);
54
+ ensureWikiNameAvailable(stashDir, name);
55
+ return akmAdd({
56
+ ref: input.ref,
57
+ name,
58
+ overrideType: "wiki",
59
+ options: input.options,
60
+ trustThisInstall: input.trustThisInstall,
61
+ writable: input.writable,
62
+ });
63
+ }
50
64
  /**
51
65
  * Add a local directory as a filesystem stash source.
52
66
  * Creates a stashes[] entry instead of an installed[] entry.
@@ -58,31 +72,36 @@ async function addLocalStashSource(ref, sourcePath, stashDir, wikiName) {
58
72
  // Check for duplicates in stashes[]
59
73
  const stashes = [...(config.stashes ?? [])];
60
74
  const existing = stashes.find((s) => s.type === "filesystem" && s.path && path.resolve(s.path) === resolvedPath);
75
+ let persistedEntry;
61
76
  if (!existing) {
62
- const entry = {
77
+ persistedEntry = {
63
78
  type: "filesystem",
64
79
  path: resolvedPath,
65
80
  name: wikiName ?? toReadableId(resolvedPath),
66
81
  ...(wikiName ? { wikiName } : {}),
67
82
  };
68
- stashes.push(entry);
83
+ stashes.push(persistedEntry);
69
84
  saveConfig({ ...config, stashes });
70
85
  }
71
- else if (wikiName && existing.wikiName !== wikiName) {
72
- existing.wikiName = wikiName;
73
- saveConfig({ ...config, stashes });
86
+ else {
87
+ if (wikiName && existing.wikiName !== wikiName) {
88
+ existing.wikiName = wikiName;
89
+ saveConfig({ ...config, stashes });
90
+ }
91
+ persistedEntry = existing;
74
92
  }
75
93
  const index = await akmIndex({ stashDir });
76
94
  const updatedConfig = loadConfig();
77
95
  return {
78
96
  schemaVersion: 1,
79
97
  stashDir,
80
- ref,
98
+ ref: wikiName ?? ref,
81
99
  stashSource: {
82
100
  type: "filesystem",
83
101
  path: resolvedPath,
84
- name: toReadableId(resolvedPath),
102
+ name: persistedEntry.name ?? toReadableId(resolvedPath),
85
103
  stashRoot: resolvedPath,
104
+ ...(persistedEntry.wikiName ? { wiki: persistedEntry.wikiName } : {}),
86
105
  },
87
106
  config: {
88
107
  stashCount: updatedConfig.stashes?.length ?? 0,
@@ -93,6 +112,7 @@ async function addLocalStashSource(ref, sourcePath, stashDir, wikiName) {
93
112
  totalEntries: index.totalEntries,
94
113
  directoriesScanned: index.directoriesScanned,
95
114
  directoriesSkipped: index.directoriesSkipped,
115
+ ...(index.warnings?.length ? { warnings: index.warnings } : {}),
96
116
  },
97
117
  };
98
118
  }
@@ -131,12 +151,13 @@ async function addWebsiteStashSource(ref, stashDir, name, options, wikiName) {
131
151
  return {
132
152
  schemaVersion: 1,
133
153
  stashDir,
134
- ref,
154
+ ref: wikiName ?? ref,
135
155
  stashSource: {
136
156
  type: "website",
137
157
  url: normalizedUrl,
138
158
  name: entry.name,
139
159
  stashRoot: cachePaths.stashDir,
160
+ ...(entry.wikiName ? { wiki: entry.wikiName } : {}),
140
161
  },
141
162
  config: {
142
163
  stashCount: updatedConfig.stashes?.length ?? 0,
@@ -147,6 +168,7 @@ async function addWebsiteStashSource(ref, stashDir, name, options, wikiName) {
147
168
  totalEntries: index.totalEntries,
148
169
  directoriesScanned: index.directoriesScanned,
149
170
  directoriesSkipped: index.directoriesSkipped,
171
+ ...(index.warnings?.length ? { warnings: index.warnings } : {}),
150
172
  },
151
173
  };
152
174
  }
@@ -213,6 +235,7 @@ async function addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiNam
213
235
  totalEntries: index.totalEntries,
214
236
  directoriesScanned: index.directoriesScanned,
215
237
  directoriesSkipped: index.directoriesSkipped,
238
+ ...(index.warnings?.length ? { warnings: index.warnings } : {}),
216
239
  },
217
240
  };
218
241
  }
@@ -22,11 +22,7 @@ import "./stash-providers/index";
22
22
  * `/`, e.g. `wiki:research`.
23
23
  */
24
24
  async function showWikiRoot(stashDir, wikiName) {
25
- const { showWiki, resolveWikiDir } = await import("./wiki.js");
26
- const wikiDir = resolveWikiDir(stashDir, wikiName);
27
- if (!fs.existsSync(wikiDir)) {
28
- throw new NotFoundError(`Wiki not found: ${wikiName}. Run \`akm wiki create ${wikiName}\` to create it.`);
29
- }
25
+ const { showWiki } = await import("./wiki.js");
30
26
  const result = showWiki(stashDir, wikiName);
31
27
  // Shape the WikiShowResult into a ShowResponse-compatible object.
32
28
  // The payload mirrors what `akm wiki show <name>` returns.
@@ -43,6 +39,44 @@ async function showWikiRoot(stashDir, wikiName) {
43
39
  recentLog: result.recentLog,
44
40
  };
45
41
  }
42
+ async function showWikiRootForSource(stashDir, source, wikiName) {
43
+ const { showWikiAtPath } = await import("./wiki.js");
44
+ if (source.wikiName === wikiName) {
45
+ const result = showWikiAtPath(wikiName, source.path);
46
+ return {
47
+ type: "wiki",
48
+ name: result.ref,
49
+ path: result.path,
50
+ ...(result.description ? { description: result.description } : {}),
51
+ origin: null,
52
+ editable: false,
53
+ pages: result.pages,
54
+ raws: result.raws,
55
+ ...(result.lastModified ? { lastModified: result.lastModified } : {}),
56
+ recentLog: result.recentLog,
57
+ };
58
+ }
59
+ return showWikiRoot(stashDir, wikiName);
60
+ }
61
+ function resolveRegisteredWikiAssetPath(wikiRoot, wikiName, assetName) {
62
+ const pageName = assetName === wikiName ? "" : assetName.slice(wikiName.length + 1);
63
+ if (!pageName) {
64
+ throw new NotFoundError(`Wiki page not found: wiki:${assetName}`);
65
+ }
66
+ const candidate = path.resolve(wikiRoot, `${pageName}.md`);
67
+ const resolvedRoot = fs.realpathSync(wikiRoot);
68
+ if (!candidate.startsWith(resolvedRoot + path.sep)) {
69
+ throw new UsageError("Ref resolves outside the stash root.");
70
+ }
71
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
72
+ throw new NotFoundError(`Stash asset not found for ref: wiki:${assetName}`);
73
+ }
74
+ const realTarget = fs.realpathSync(candidate);
75
+ if (!realTarget.startsWith(resolvedRoot + path.sep)) {
76
+ throw new UsageError("Ref resolves outside the stash root.");
77
+ }
78
+ return realTarget;
79
+ }
46
80
  /**
47
81
  * Unified show: tries local FTS5 index first, then remote providers.
48
82
  *
@@ -63,7 +97,7 @@ export async function akmShowUnified(input) {
63
97
  let lastError;
64
98
  for (const source of searchSources) {
65
99
  try {
66
- return await showWikiRoot(source.path, parsed.name);
100
+ return await showWikiRootForSource(allSources[0]?.path ?? source.path, source, parsed.name);
67
101
  }
68
102
  catch (err) {
69
103
  if (!(err instanceof NotFoundError))
@@ -145,8 +179,19 @@ export async function showLocal(input) {
145
179
  const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
146
180
  const allStashDirs = searchSources.map((s) => s.path);
147
181
  let assetPath;
182
+ const matchedSource = parsed.type === "wiki" ? searchSources.find((source) => parsed.name.startsWith(`${source.wikiName}/`)) : undefined;
148
183
  let lastError;
184
+ if (parsed.type === "wiki" && matchedSource?.wikiName) {
185
+ try {
186
+ assetPath = resolveRegisteredWikiAssetPath(matchedSource.path, matchedSource.wikiName, parsed.name);
187
+ }
188
+ catch (err) {
189
+ lastError = err instanceof Error ? err : new Error(String(err));
190
+ }
191
+ }
149
192
  for (const dir of allStashDirs) {
193
+ if (assetPath)
194
+ break;
150
195
  try {
151
196
  assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
152
197
  break;
@@ -165,14 +210,17 @@ export async function showLocal(input) {
165
210
  new NotFoundError(`Stash asset not found for ref: ${displayType}:${parsed.name}. ` +
166
211
  "Check the name with `akm search` or verify the asset exists in your stash."));
167
212
  }
168
- const source = findSourceForPath(assetPath, allSources);
213
+ const source = matchedSource ?? findSourceForPath(assetPath, allSources);
169
214
  const sourceStashDir = source?.path ?? allStashDirs[0];
170
215
  if (!sourceStashDir) {
171
216
  throw new UsageError(`Could not determine stash root for asset: ${displayType}:${parsed.name}. ` +
172
217
  "Run `akm init` to create the stash directory, or check `akm stash list` for configured paths.");
173
218
  }
174
219
  const fileCtx = buildFileContext(sourceStashDir, assetPath);
175
- const match = await runMatchers(fileCtx);
220
+ const forcedWikiMatch = parsed.type === "wiki" && source?.wikiName && parsed.name.startsWith(`${source.wikiName}/`)
221
+ ? { type: "wiki", specificity: 20, renderer: "wiki-md", meta: {} }
222
+ : undefined;
223
+ const match = forcedWikiMatch ?? (await runMatchers(fileCtx));
176
224
  if (!match) {
177
225
  throw new UsageError(`Could not display asset "${displayType}:${parsed.name}" — unsupported file type or unrecognized layout`);
178
226
  }
package/dist/wiki.js CHANGED
@@ -16,8 +16,10 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { parse as yamlParse } from "yaml";
18
18
  import { isWithin } from "./common";
19
+ import { loadUserConfig, saveConfig } from "./config";
19
20
  import { NotFoundError, UsageError } from "./errors";
20
21
  import { parseFrontmatter, parseFrontmatterBlock } from "./frontmatter";
22
+ import { resolveStashSources } from "./search-source";
21
23
  import { akmSearch } from "./stash-search";
22
24
  import { buildIndexMd, buildLogMd, buildSchemaMd } from "./templates/wiki-templates";
23
25
  // ── Constants ───────────────────────────────────────────────────────────────
@@ -58,6 +60,41 @@ export function extractWikiNameFromRef(ref) {
58
60
  const match = ref.match(/^wiki:([a-z0-9][a-z0-9-]*)(?:\/|$)/);
59
61
  return match?.[1];
60
62
  }
63
+ function wikiNotFoundMessage(name) {
64
+ return `Wiki not found: ${name}. Run \`akm wiki create ${name}\` to create it or \`akm wiki register ${name} <path-or-repo>\` to register an external wiki.`;
65
+ }
66
+ function registeredWikiSources(stashDir) {
67
+ return resolveStashSources(stashDir)
68
+ .filter((source) => typeof source.wikiName === "string")
69
+ .map((source) => ({
70
+ name: source.wikiName,
71
+ path: source.path,
72
+ mode: "external",
73
+ source,
74
+ }));
75
+ }
76
+ export function resolveWikiSource(stashDir, name) {
77
+ validateWikiName(name);
78
+ const wikiDir = resolveWikiDir(stashDir, name);
79
+ if (fs.existsSync(wikiDir)) {
80
+ return { name, path: wikiDir, mode: "stash" };
81
+ }
82
+ const external = registeredWikiSources(stashDir).find((source) => source.name === name);
83
+ if (external)
84
+ return external;
85
+ throw new NotFoundError(wikiNotFoundMessage(name));
86
+ }
87
+ export function ensureWikiNameAvailable(stashDir, name) {
88
+ validateWikiName(name);
89
+ const wikiDir = resolveWikiDir(stashDir, name);
90
+ if (fs.existsSync(wikiDir)) {
91
+ throw new UsageError(`Wiki already exists: ${name}.`);
92
+ }
93
+ const external = registeredWikiSources(stashDir).find((source) => source.name === name);
94
+ if (external) {
95
+ throw new UsageError(`Wiki already registered: ${name}.`);
96
+ }
97
+ }
61
98
  /**
62
99
  * Walk a wiki directory and bucket files into pages vs raws.
63
100
  *
@@ -158,25 +195,20 @@ function toIsoDate(ms) {
158
195
  */
159
196
  export function listWikis(stashDir) {
160
197
  const wikisRoot = resolveWikisRoot(stashDir);
161
- if (!fs.existsSync(wikisRoot))
162
- return [];
163
- let entries;
164
- try {
165
- entries = fs.readdirSync(wikisRoot, { withFileTypes: true });
166
- }
167
- catch {
168
- return [];
198
+ const summaries = new Map();
199
+ let entries = [];
200
+ if (fs.existsSync(wikisRoot)) {
201
+ try {
202
+ entries = fs.readdirSync(wikisRoot, { withFileTypes: true });
203
+ }
204
+ catch {
205
+ entries = [];
206
+ }
169
207
  }
170
- const summaries = [];
171
- for (const entry of entries) {
172
- if (!entry.isDirectory())
173
- continue;
174
- if (!WIKI_NAME_RE.test(entry.name))
175
- continue;
176
- const dir = path.join(wikisRoot, entry.name);
208
+ const summarize = (name, dir) => {
177
209
  const buckets = scanWikiFiles(dir);
178
210
  const summary = {
179
- name: entry.name,
211
+ name,
180
212
  path: dir,
181
213
  pages: buckets.pages.length,
182
214
  raws: buckets.raws.length,
@@ -186,10 +218,21 @@ export function listWikis(stashDir) {
186
218
  summary.description = description;
187
219
  if (buckets.lastModifiedMs !== undefined)
188
220
  summary.lastModified = toIsoDate(buckets.lastModifiedMs);
189
- summaries.push(summary);
221
+ summaries.set(name, summary);
222
+ };
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory())
225
+ continue;
226
+ if (!WIKI_NAME_RE.test(entry.name))
227
+ continue;
228
+ summarize(entry.name, path.join(wikisRoot, entry.name));
229
+ }
230
+ for (const source of registeredWikiSources(stashDir)) {
231
+ if (summaries.has(source.name))
232
+ continue;
233
+ summarize(source.name, source.path);
190
234
  }
191
- summaries.sort((a, b) => a.name.localeCompare(b.name));
192
- return summaries;
235
+ return Array.from(summaries.values()).sort((a, b) => a.name.localeCompare(b.name));
193
236
  }
194
237
  // ── Show ────────────────────────────────────────────────────────────────────
195
238
  /**
@@ -238,11 +281,7 @@ function readRecentLog(wikiDir, limit = 3) {
238
281
  // Newest-first convention: the top `limit` `##` blocks are the most recent.
239
282
  return sections.slice(0, limit);
240
283
  }
241
- export function showWiki(stashDir, name) {
242
- const wikiDir = resolveWikiDir(stashDir, name);
243
- if (!fs.existsSync(wikiDir)) {
244
- throw new NotFoundError(`Wiki not found: ${name}. Run \`akm wiki create ${name}\` to create it.`);
245
- }
284
+ export function showWikiAtPath(name, wikiDir) {
246
285
  const buckets = scanWikiFiles(wikiDir);
247
286
  const result = {
248
287
  name,
@@ -259,8 +298,15 @@ export function showWiki(stashDir, name) {
259
298
  result.lastModified = toIsoDate(buckets.lastModifiedMs);
260
299
  return result;
261
300
  }
301
+ export function showWiki(stashDir, name) {
302
+ return showWikiAtPath(name, resolveWikiSource(stashDir, name).path);
303
+ }
262
304
  // ── Create ──────────────────────────────────────────────────────────────────
263
305
  export function createWiki(stashDir, name) {
306
+ const existing = registeredWikiSources(stashDir).find((source) => source.name === name);
307
+ if (existing) {
308
+ throw new UsageError(`Wiki already registered: ${name}.`);
309
+ }
264
310
  const wikiDir = resolveWikiDir(stashDir, name);
265
311
  fs.mkdirSync(wikiDir, { recursive: true });
266
312
  const files = [
@@ -307,7 +353,25 @@ export function createWiki(stashDir, name) {
307
353
  * ignore that (e.g. idempotent cleanup) by catching.
308
354
  */
309
355
  export function removeWiki(stashDir, name, options = {}) {
310
- const wikiDir = resolveWikiDir(stashDir, name);
356
+ const resolved = resolveWikiSource(stashDir, name);
357
+ const wikiDir = resolved.path;
358
+ if (resolved.mode === "external") {
359
+ const config = loadUserConfig();
360
+ const stashes = (config.stashes ?? []).filter((entry) => entry.wikiName !== name);
361
+ const installed = (config.installed ?? []).filter((entry) => entry.wikiName !== name);
362
+ saveConfig({
363
+ ...config,
364
+ stashes: stashes.length > 0 ? stashes : undefined,
365
+ installed: installed.length > 0 ? installed : undefined,
366
+ });
367
+ return {
368
+ name,
369
+ path: wikiDir,
370
+ removed: [],
371
+ preservedRaw: false,
372
+ unregistered: true,
373
+ };
374
+ }
311
375
  if (!fs.existsSync(wikiDir)) {
312
376
  throw new NotFoundError(`Wiki not found: ${name}.`);
313
377
  }
@@ -420,10 +484,7 @@ function readPageFrontmatter(absPath) {
420
484
  * path, and frontmatter-derived fields for orientation.
421
485
  */
422
486
  export function listPages(stashDir, name) {
423
- const wikiDir = resolveWikiDir(stashDir, name);
424
- if (!fs.existsSync(wikiDir)) {
425
- throw new NotFoundError(`Wiki not found: ${name}.`);
426
- }
487
+ const wikiDir = resolveWikiSource(stashDir, name).path;
427
488
  const { pages } = scanWikiFiles(wikiDir);
428
489
  const result = [];
429
490
  for (const abs of pages) {
@@ -447,13 +508,22 @@ export function listPages(stashDir, name) {
447
508
  */
448
509
  export async function searchInWiki(input) {
449
510
  validateWikiName(input.wikiName);
450
- const wikiDir = resolveWikiDir(input.stashDir, input.wikiName);
451
511
  const response = await akmSearch({
452
512
  query: input.query,
453
513
  type: "wiki",
454
514
  limit: input.limit,
455
515
  source: "stash",
456
516
  });
517
+ let wikiDir;
518
+ try {
519
+ wikiDir = resolveWikiSource(input.stashDir, input.wikiName).path;
520
+ }
521
+ catch (err) {
522
+ if (err instanceof NotFoundError) {
523
+ return { ...response, hits: [], registryHits: undefined };
524
+ }
525
+ throw err;
526
+ }
457
527
  const rawDir = path.join(wikiDir, RAW_SUBDIR);
458
528
  const filtered = [];
459
529
  for (const hit of response.hits) {
@@ -564,10 +634,7 @@ function ensureTrailingNewline(value) {
564
634
  * job (see `akm wiki ingest <name>` for the workflow).
565
635
  */
566
636
  export function stashRaw(input) {
567
- const wikiDir = resolveWikiDir(input.stashDir, input.wikiName);
568
- if (!fs.existsSync(wikiDir)) {
569
- throw new NotFoundError(`Wiki not found: ${input.wikiName}. Run \`akm wiki create ${input.wikiName}\` first.`);
570
- }
637
+ const wikiDir = resolveWikiSource(input.stashDir, input.wikiName).path;
571
638
  const rawDir = path.join(wikiDir, RAW_SUBDIR);
572
639
  fs.mkdirSync(rawDir, { recursive: true });
573
640
  const baseSlug = slugifyForWiki(input.preferredName ?? deriveQueryFromSource(input.content) ?? "source");
@@ -598,10 +665,7 @@ export function stashRaw(input) {
598
665
  * - `stale-index`: `index.md` mtime is older than the newest page mtime
599
666
  */
600
667
  export function lintWiki(stashDir, name) {
601
- const wikiDir = resolveWikiDir(stashDir, name);
602
- if (!fs.existsSync(wikiDir)) {
603
- throw new NotFoundError(`Wiki not found: ${name}.`);
604
- }
668
+ const wikiDir = resolveWikiSource(stashDir, name).path;
605
669
  const pages = listPages(stashDir, name);
606
670
  const { raws, pagesLastModifiedMs } = scanWikiFiles(wikiDir);
607
671
  const pageRefs = new Set(pages.map((p) => p.ref));
@@ -822,10 +886,7 @@ export function regenerateAllWikiIndexes(stashDir) {
822
886
  * a verb here and in the printer stays colocated.
823
887
  */
824
888
  export function buildIngestWorkflow(stashDir, name) {
825
- const wikiDir = resolveWikiDir(stashDir, name);
826
- if (!fs.existsSync(wikiDir)) {
827
- throw new NotFoundError(`Wiki not found: ${name}. Run \`akm wiki create ${name}\` first.`);
828
- }
889
+ const wikiDir = resolveWikiSource(stashDir, name).path;
829
890
  const schemaPath = path.join(wikiDir, SCHEMA_MD);
830
891
  const workflow = `# Ingest workflow for wiki:${name}
831
892
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.5.0-rc2",
3
+ "version": "0.5.0-rc4",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Kit Manager) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [