akm-cli 0.7.2 → 0.7.4

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.
@@ -14,7 +14,7 @@ import { resolveStashDir } from "../core/common";
14
14
  import { loadConfig } from "../core/config";
15
15
  import { getDbPath } from "../core/paths";
16
16
  import { warn } from "../core/warn";
17
- import { closeDatabase, getAllEntries, getEntryCount, getMeta, openDatabase } from "./db";
17
+ import { closeDatabase, getAllEntries, getEntryCount, getMeta, openExistingDatabase } from "./db";
18
18
  import { generateMetadataFlat, loadStashFile } from "./metadata";
19
19
  import { resolveSourceEntries } from "./search-source";
20
20
  import { walkStashFlat } from "./walker";
@@ -57,13 +57,12 @@ function toManifestEntry(entry, filePath, stashDir, registryId) {
57
57
  /**
58
58
  * Get the manifest from the database (fast path).
59
59
  */
60
- function getManifestFromDb(stashDir, config, sources, type) {
60
+ function getManifestFromDb(stashDir, _config, sources, type) {
61
61
  const dbPath = getDbPath();
62
62
  try {
63
63
  if (!fs.existsSync(dbPath))
64
64
  return null;
65
- const embeddingDim = config.embedding?.dimension;
66
- const db = openDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
65
+ const db = openExistingDatabase(dbPath);
67
66
  try {
68
67
  const entryCount = getEntryCount(db);
69
68
  const storedStashDir = getMeta(db, "stashDir");
@@ -113,29 +112,21 @@ async function getManifestFromWalker(sources, type) {
113
112
  dirGroups.set(ctx.parentDirAbs, [ctx.absPath]);
114
113
  }
115
114
  for (const [dirPath, files] of dirGroups) {
116
- // Try loading existing .stash.json first
117
- let stash = loadStashFile(dirPath);
118
- if (stash) {
119
- const coveredFiles = new Set(stash.entries.map((e) => e.filename).filter((e) => !!e));
120
- const uncoveredFiles = files.filter((f) => !coveredFiles.has(path.basename(f)));
121
- if (uncoveredFiles.length > 0) {
122
- const generated = await generateMetadataFlat(currentStashDir, uncoveredFiles);
123
- if (generated.entries.length > 0) {
124
- stash = { entries: [...stash.entries, ...generated.entries] };
125
- }
126
- }
127
- }
128
- else {
129
- const generated = await generateMetadataFlat(currentStashDir, files);
130
- if (generated.entries.length === 0)
131
- continue;
132
- stash = generated;
133
- }
115
+ const generated = await generateMetadataFlat(currentStashDir, files);
116
+ const legacyOverrides = loadStashFile(dirPath, { requireFilename: true });
117
+ const mergedEntries = legacyOverrides
118
+ ? generated.entries.map((entry) => mergeLegacyEntry(entry, legacyOverrides.entries))
119
+ : generated.entries;
120
+ const stash = mergedEntries.length > 0 ? { entries: mergedEntries } : legacyOverrides;
121
+ if (!stash || stash.entries.length === 0)
122
+ continue;
134
123
  const source = sources.find((s) => dirPath.startsWith(path.resolve(s.path) + path.sep));
135
124
  for (const stashEntry of stash.entries) {
136
125
  if (type && type !== "any" && stashEntry.type !== type)
137
126
  continue;
138
- const entryPath = stashEntry.filename ? path.join(dirPath, stashEntry.filename) : files[0] || dirPath;
127
+ if (!stashEntry.filename)
128
+ continue;
129
+ const entryPath = path.join(dirPath, stashEntry.filename);
139
130
  const manifestEntry = toManifestEntry(stashEntry, entryPath, currentStashDir, source?.registryId);
140
131
  if (manifestEntry)
141
132
  entries.push(manifestEntry);
@@ -144,6 +135,10 @@ async function getManifestFromWalker(sources, type) {
144
135
  }
145
136
  return entries;
146
137
  }
138
+ function mergeLegacyEntry(entry, legacyEntries) {
139
+ const legacy = legacyEntries.find((candidate) => candidate.filename === entry.filename);
140
+ return legacy ? { ...entry, ...legacy, filename: entry.filename } : entry;
141
+ }
147
142
  /**
148
143
  * Generate a compact manifest of all assets in the stash.
149
144
  *
@@ -49,7 +49,7 @@ export function isProposedQuality(quality) {
49
49
  export function stashFilePath(dirPath) {
50
50
  return path.join(dirPath, STASH_FILENAME);
51
51
  }
52
- export function loadStashFile(dirPath) {
52
+ export function loadStashFile(dirPath, options) {
53
53
  const filePath = stashFilePath(dirPath);
54
54
  if (!fs.existsSync(filePath))
55
55
  return null;
@@ -61,6 +61,8 @@ export function loadStashFile(dirPath) {
61
61
  for (const e of raw.entries) {
62
62
  const validated = validateStashEntry(e);
63
63
  if (validated) {
64
+ if (options?.requireFilename && !validated.filename)
65
+ continue;
64
66
  entries.push(validated);
65
67
  }
66
68
  else {
@@ -268,6 +270,69 @@ export function applyScopeFrontmatter(entry, fmData) {
268
270
  if (scope)
269
271
  entry.scope = scope;
270
272
  }
273
+ function normalizeIntent(value) {
274
+ if (typeof value !== "object" || value === null || Array.isArray(value))
275
+ return undefined;
276
+ const raw = value;
277
+ const intent = {};
278
+ const when = toStringOrUndefined(raw.when);
279
+ const input = toStringOrUndefined(raw.input);
280
+ const output = toStringOrUndefined(raw.output);
281
+ if (when)
282
+ intent.when = when;
283
+ if (input)
284
+ intent.input = input;
285
+ if (output)
286
+ intent.output = output;
287
+ return Object.keys(intent).length > 0 ? intent : undefined;
288
+ }
289
+ function normalizeStringListOrUndefined(value) {
290
+ return normalizeNonEmptyStringList(value);
291
+ }
292
+ export function applyCuratedFrontmatter(entry, fmData) {
293
+ const description = toStringOrUndefined(fmData.description);
294
+ if (description) {
295
+ entry.description = description;
296
+ entry.source = "frontmatter";
297
+ entry.confidence = 0.9;
298
+ }
299
+ const tags = normalizeStringListOrUndefined(fmData.tags);
300
+ if (tags)
301
+ entry.tags = normalizeTerms(tags);
302
+ const aliases = normalizeStringListOrUndefined(fmData.aliases);
303
+ if (aliases)
304
+ entry.aliases = normalizeTerms(aliases);
305
+ const searchHints = normalizeStringListOrUndefined(fmData.searchHints);
306
+ if (searchHints)
307
+ entry.searchHints = searchHints;
308
+ const usage = normalizeStringListOrUndefined(fmData.usage);
309
+ if (usage)
310
+ entry.usage = usage;
311
+ const examples = normalizeStringListOrUndefined(fmData.examples);
312
+ if (examples)
313
+ entry.examples = examples;
314
+ const run = toStringOrUndefined(fmData.run);
315
+ if (run)
316
+ entry.run = run;
317
+ const setup = toStringOrUndefined(fmData.setup);
318
+ if (setup)
319
+ entry.setup = setup;
320
+ const cwd = toStringOrUndefined(fmData.cwd);
321
+ if (cwd)
322
+ entry.cwd = cwd;
323
+ const quality = toStringOrUndefined(fmData.quality);
324
+ if (quality)
325
+ entry.quality = normalizeQuality(quality);
326
+ const intent = normalizeIntent(fmData.intent);
327
+ if (intent)
328
+ entry.intent = intent;
329
+ if (typeof fmData.scope === "object" && fmData.scope !== null && !Array.isArray(fmData.scope)) {
330
+ const normalizedScope = normalizeScopeObject(fmData.scope);
331
+ if (normalizedScope)
332
+ entry.scope = normalizedScope;
333
+ }
334
+ applyScopeFrontmatter(entry, fmData);
335
+ }
271
336
  function normalizeNonEmptyStringList(value) {
272
337
  if (typeof value === "string") {
273
338
  const trimmed = value.trim();
@@ -439,6 +504,183 @@ function mergeParameters(existing, additional) {
439
504
  }
440
505
  return merged;
441
506
  }
507
+ function splitCommentList(raw) {
508
+ return raw
509
+ .split(/[;,]/)
510
+ .map((item) => item.trim())
511
+ .filter((item) => item.length > 0);
512
+ }
513
+ function parseCommentScope(raw) {
514
+ const pairs = raw
515
+ .split(/[;,]/)
516
+ .map((item) => item.trim())
517
+ .filter((item) => item.length > 0);
518
+ if (pairs.length === 0)
519
+ return undefined;
520
+ const scopeRaw = {};
521
+ for (const pair of pairs) {
522
+ const [keyPart, ...valueParts] = pair.split("=");
523
+ const key = keyPart?.trim();
524
+ const value = valueParts.join("=").trim();
525
+ if (!key || !value)
526
+ continue;
527
+ if (SCOPE_KEYS.includes(key)) {
528
+ scopeRaw[key] = value;
529
+ }
530
+ }
531
+ return normalizeScopeObject(scopeRaw);
532
+ }
533
+ function parseIntentCommentLine(cleaned, metadata) {
534
+ const intentMatch = cleaned.match(/^@intent(?:\.(when|input|output))?\s+(.+)$/);
535
+ if (!intentMatch)
536
+ return false;
537
+ metadata.intent ??= {};
538
+ const value = intentMatch[2].trim();
539
+ const key = intentMatch[1];
540
+ if (key === "when")
541
+ metadata.intent.when = value;
542
+ else if (key === "input")
543
+ metadata.intent.input = value;
544
+ else if (key === "output")
545
+ metadata.intent.output = value;
546
+ else
547
+ metadata.intent.when ??= value;
548
+ return true;
549
+ }
550
+ export function extractCommentMetadata(filePath, content) {
551
+ if (content === undefined) {
552
+ try {
553
+ content = fs.readFileSync(filePath, "utf8");
554
+ }
555
+ catch {
556
+ return undefined;
557
+ }
558
+ }
559
+ const lines = content.split(/\r?\n/).slice(0, 50);
560
+ const metadata = {};
561
+ for (const line of lines) {
562
+ const trimmed = line.trim();
563
+ if (!/^(?:\/\/|#|\/?\*|;|--)/.test(trimmed) && !trimmed.startsWith("'"))
564
+ continue;
565
+ const cleaned = trimmed
566
+ .replace(/^(?:\/\/|##?|\/?\*\*?\/?|;|--|'?)\s*/, "")
567
+ .replace(/\*\/\s*$/, "")
568
+ .trim();
569
+ if (!cleaned)
570
+ continue;
571
+ if (parseIntentCommentLine(cleaned, metadata))
572
+ continue;
573
+ const descMatch = cleaned.match(/^@description\s+(.+)$/);
574
+ if (descMatch) {
575
+ metadata.description = descMatch[1].trim();
576
+ continue;
577
+ }
578
+ const tagsMatch = cleaned.match(/^@tags?\s+(.+)$/);
579
+ if (tagsMatch) {
580
+ metadata.tags = splitCommentList(tagsMatch[1]);
581
+ continue;
582
+ }
583
+ const aliasesMatch = cleaned.match(/^@aliases?\s+(.+)$/);
584
+ if (aliasesMatch) {
585
+ metadata.aliases = splitCommentList(aliasesMatch[1]);
586
+ continue;
587
+ }
588
+ const hintsMatch = cleaned.match(/^@searchHints?\s+(.+)$/);
589
+ if (hintsMatch) {
590
+ metadata.searchHints = splitCommentList(hintsMatch[1]);
591
+ continue;
592
+ }
593
+ const usageMatch = cleaned.match(/^@usage\s+(.+)$/);
594
+ if (usageMatch) {
595
+ metadata.usage = [...(metadata.usage ?? []), usageMatch[1].trim()];
596
+ continue;
597
+ }
598
+ const examplesMatch = cleaned.match(/^@examples?\s+(.+)$/);
599
+ if (examplesMatch) {
600
+ metadata.examples = [...(metadata.examples ?? []), examplesMatch[1].trim()];
601
+ continue;
602
+ }
603
+ const runMatch = cleaned.match(/^@run\s+(.+)$/);
604
+ if (runMatch) {
605
+ metadata.run = runMatch[1].trim();
606
+ continue;
607
+ }
608
+ const setupMatch = cleaned.match(/^@setup\s+(.+)$/);
609
+ if (setupMatch) {
610
+ metadata.setup = setupMatch[1].trim();
611
+ continue;
612
+ }
613
+ const cwdMatch = cleaned.match(/^@cwd\s+(.+)$/);
614
+ if (cwdMatch) {
615
+ metadata.cwd = cwdMatch[1].trim();
616
+ continue;
617
+ }
618
+ const scopeMatch = cleaned.match(/^@scope\s+(.+)$/);
619
+ if (scopeMatch) {
620
+ const scope = parseCommentScope(scopeMatch[1]);
621
+ if (scope)
622
+ metadata.scope = scope;
623
+ }
624
+ }
625
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
626
+ }
627
+ export function applyCommentMetadata(entry, metadata) {
628
+ if (!metadata)
629
+ return;
630
+ let usedCommentMetadata = false;
631
+ if (metadata.description && !entry.description) {
632
+ entry.description = metadata.description;
633
+ usedCommentMetadata = true;
634
+ }
635
+ if (metadata.tags?.length && (!entry.tags || entry.tags.length === 0)) {
636
+ entry.tags = normalizeTerms(metadata.tags);
637
+ usedCommentMetadata = true;
638
+ }
639
+ if (metadata.aliases?.length) {
640
+ entry.aliases = normalizeTerms(metadata.aliases);
641
+ usedCommentMetadata = true;
642
+ }
643
+ if (metadata.searchHints?.length) {
644
+ entry.searchHints = metadata.searchHints;
645
+ usedCommentMetadata = true;
646
+ }
647
+ if (metadata.usage?.length) {
648
+ entry.usage = metadata.usage;
649
+ usedCommentMetadata = true;
650
+ }
651
+ if (metadata.examples?.length) {
652
+ entry.examples = metadata.examples;
653
+ usedCommentMetadata = true;
654
+ }
655
+ if (metadata.intent && Object.keys(metadata.intent).length > 0) {
656
+ entry.intent = metadata.intent;
657
+ usedCommentMetadata = true;
658
+ }
659
+ if (metadata.run) {
660
+ entry.run = metadata.run;
661
+ usedCommentMetadata = true;
662
+ }
663
+ if (metadata.setup) {
664
+ entry.setup = metadata.setup;
665
+ usedCommentMetadata = true;
666
+ }
667
+ if (metadata.cwd) {
668
+ entry.cwd = metadata.cwd;
669
+ usedCommentMetadata = true;
670
+ }
671
+ if (metadata.scope) {
672
+ entry.scope = metadata.scope;
673
+ usedCommentMetadata = true;
674
+ }
675
+ if (usedCommentMetadata && entry.source !== "frontmatter" && entry.source !== "manual") {
676
+ entry.source = "comments";
677
+ entry.confidence = Math.max(entry.confidence ?? 0, 0.7);
678
+ }
679
+ }
680
+ function mergeAliases(existing, generated) {
681
+ const merged = normalizeTerms([...(existing ?? []), ...generated]);
682
+ return merged.length > 0 ? merged : undefined;
683
+ }
442
684
  // ── Metadata Generation ─────────────────────────────────────────────────────
443
685
  export async function generateMetadata(dirPath, assetType, files, typeRoot = dirPath) {
444
686
  const entries = [];
@@ -473,20 +715,13 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
473
715
  if (ext === ".md") {
474
716
  const content = fs.readFileSync(file, "utf8");
475
717
  const parsed = parseFrontmatter(content);
476
- const fm = toStringOrUndefined(parsed.data.description);
477
- if (fm) {
478
- entry.description = fm;
479
- entry.source = "frontmatter";
480
- entry.confidence = 0.9;
481
- }
718
+ applyCuratedFrontmatter(entry, parsed.data);
482
719
  // Extract parameters from frontmatter params: key
483
720
  const fmParams = extractFrontmatterParameters(parsed.data);
484
721
  if (fmParams)
485
722
  entry.parameters = fmParams;
486
723
  // Pass wiki-pattern frontmatter through onto the entry
487
724
  applyWikiFrontmatter(entry, parsed.data);
488
- // Pass canonical scope_* frontmatter through onto the entry
489
- applyScopeFrontmatter(entry, parsed.data);
490
725
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
491
726
  if (entry.type === "command") {
492
727
  const cmdParams = extractCommandParameters(parsed.content);
@@ -500,9 +735,11 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
500
735
  // and must never be parsed for @param or any other metadata that could
501
736
  // embed a value into the entry.
502
737
  if (ext !== ".md" && assetType !== "vault") {
503
- const scriptParams = extractScriptParameters(file);
738
+ const content = fs.readFileSync(file, "utf8");
739
+ const scriptParams = extractScriptParameters(file, content);
504
740
  if (scriptParams)
505
741
  entry.parameters = scriptParams;
742
+ applyCommentMetadata(entry, extractCommentMetadata(file, content));
506
743
  }
507
744
  // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
508
745
  const fileCtx = buildFileContext(typeRoot, file);
@@ -530,7 +767,7 @@ export async function generateMetadata(dirPath, assetType, files, typeRoot = dir
530
767
  entry.tags = extractTagsFromPath(file, dirPath);
531
768
  }
532
769
  entry.tags = normalizeTerms(entry.tags ?? []);
533
- entry.aliases = buildAliases(canonicalName, entry.tags);
770
+ entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
534
771
  // Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
535
772
  // Heuristic search hints are too noisy to be useful for search quality
536
773
  entry.filename = path.basename(file);
@@ -591,20 +828,13 @@ export async function generateMetadataFlat(stashRoot, files) {
591
828
  if (ext === ".md") {
592
829
  const content = ctx.content();
593
830
  const parsed = parseFrontmatter(content);
594
- const fm = toStringOrUndefined(parsed.data.description);
595
- if (fm) {
596
- entry.description = fm;
597
- entry.source = "frontmatter";
598
- entry.confidence = 0.9;
599
- }
831
+ applyCuratedFrontmatter(entry, parsed.data);
600
832
  // Extract parameters from frontmatter params: key
601
833
  const fmParams = extractFrontmatterParameters(parsed.data);
602
834
  if (fmParams)
603
835
  entry.parameters = fmParams;
604
836
  // Pass wiki-pattern frontmatter through onto the entry
605
837
  applyWikiFrontmatter(entry, parsed.data);
606
- // Pass canonical scope_* frontmatter through onto the entry
607
- applyScopeFrontmatter(entry, parsed.data);
608
838
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
609
839
  if (entry.type === "command") {
610
840
  const cmdParams = extractCommandParameters(parsed.content);
@@ -618,9 +848,11 @@ export async function generateMetadataFlat(stashRoot, files) {
618
848
  // and must never be parsed for @param or any other metadata that could
619
849
  // embed a value into the entry.
620
850
  if (ext !== ".md" && assetType !== "vault") {
621
- const scriptParams = extractScriptParameters(file, ctx.content());
851
+ const content = ctx.content();
852
+ const scriptParams = extractScriptParameters(file, content);
622
853
  if (scriptParams)
623
854
  entry.parameters = scriptParams;
855
+ applyCommentMetadata(entry, extractCommentMetadata(file, content));
624
856
  }
625
857
  // Renderer metadata extraction
626
858
  const renderer = await getRenderer(match.renderer);
@@ -644,7 +876,7 @@ export async function generateMetadataFlat(stashRoot, files) {
644
876
  entry.tags = extractTagsFromPath(file, dirPath);
645
877
  }
646
878
  entry.tags = normalizeTerms(entry.tags ?? []);
647
- entry.aliases = buildAliases(canonicalName, entry.tags);
879
+ entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
648
880
  entry.filename = path.basename(file);
649
881
  entries.push(entry);
650
882
  }
@@ -121,7 +121,8 @@ function resolveEntryContentDir(entry) {
121
121
  // that subdirectory. This is a content-layout convention, not a provider
122
122
  // capability — keep it here.
123
123
  if (GIT_STASH_TYPES.has(entry.type)) {
124
- return path.join(dir, "content");
124
+ const contentDir = path.join(dir, "content");
125
+ return isValidDirectory(contentDir) ? contentDir : dir;
125
126
  }
126
127
  return dir;
127
128
  }
@@ -222,8 +223,9 @@ function isValidDirectory(dir) {
222
223
  * `resolveSourceEntries()` so the content directories pass the
223
224
  * `isValidDirectory()` check.
224
225
  */
225
- export async function ensureSourceCaches(config) {
226
+ export async function ensureSourceCaches(config, options) {
226
227
  const cfg = config ?? loadConfig();
228
+ const force = options?.force === true;
227
229
  // Use sources[] (current key) with fallback to stashes[] (deprecated, one-release compat).
228
230
  const entries = cfg.sources ?? cfg.stashes ?? [];
229
231
  for (const entry of entries) {
@@ -232,7 +234,11 @@ export async function ensureSourceCaches(config) {
232
234
  try {
233
235
  const repo = parseGitRepoUrl(entry.url);
234
236
  const cachePaths = getCachePaths(repo.canonicalUrl);
235
- await ensureGitMirror(repo, cachePaths, { requireRepoDir: true, writable: entry.writable === true });
237
+ await ensureGitMirror(repo, cachePaths, {
238
+ requireRepoDir: true,
239
+ writable: entry.writable === true,
240
+ force,
241
+ });
236
242
  }
237
243
  catch (err) {
238
244
  warn(`Warning: failed to refresh git mirror for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
@@ -242,7 +248,7 @@ export async function ensureSourceCaches(config) {
242
248
  if (entry.type !== "website" || !entry.url || entry.enabled === false)
243
249
  continue;
244
250
  try {
245
- await ensureWebsiteMirror(entry, { requireStashDir: true });
251
+ await ensureWebsiteMirror(entry, { requireStashDir: true, force });
246
252
  }
247
253
  catch (err) {
248
254
  warn(`Warning: failed to refresh website stash for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
@@ -287,8 +287,9 @@ akm config path --all # Show all config paths
287
287
 
288
288
  \`\`\`sh
289
289
  akm init # Initialize working stash
290
- akm index # Rebuild search index
291
- akm index --full # Full reindex
290
+ akm index # Rebuild search index (no LLM enrichment)
291
+ akm index --full # Full reindex (no LLM enrichment)
292
+ akm index --enrich # Reindex with LLM inference/enrichment passes
292
293
  akm list # List all sources
293
294
  akm upgrade # Upgrade akm using its install method
294
295
  akm upgrade --check # Check for updates
@@ -13,7 +13,7 @@ import { hasErrnoCode } from "../core/common";
13
13
  import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
14
14
  import { extractFrontmatterOnly, extractLineRange, extractSection, formatToc, parseMarkdownToc, } from "../core/markdown";
15
15
  import { registerRenderer } from "../indexer/file-context";
16
- import { extractDescriptionFromComments, loadStashFile } from "../indexer/metadata";
16
+ import { extractCommentMetadata, extractDescriptionFromComments } from "../indexer/metadata";
17
17
  import { buildWorkflowAction, workflowMdRenderer } from "../workflows/renderer";
18
18
  // ── Interpreter auto-detection map ───────────────────────────────────────────
19
19
  const INTERPRETER_MAP = {
@@ -49,36 +49,12 @@ const SETUP_SIGNALS = {
49
49
  * `@run <value>`, `@setup <value>`, or `@cwd <value>`.
50
50
  */
51
51
  export function extractCommentTags(filePath) {
52
- let content;
53
- try {
54
- content = fs.readFileSync(filePath, "utf8");
55
- }
56
- catch {
57
- return {};
58
- }
59
- const lines = content.split(/\r?\n/, 50);
60
- const hints = {};
61
- for (const line of lines) {
62
- const trimmed = line.trim();
63
- // Match lines starting with comment markers: //, #, /*, *, ;, --
64
- if (!/^(?:\/\/|#|\/?\*|;|--)/.test(trimmed) && !trimmed.startsWith("'"))
65
- continue;
66
- // Strip comment prefix
67
- const cleaned = trimmed
68
- .replace(/^(?:\/\/|##?|\/?\*\*?\/?|;|--)\s*/, "")
69
- .replace(/\*\/\s*$/, "")
70
- .trim();
71
- const runMatch = cleaned.match(/^@run\s+(.+)/);
72
- if (runMatch)
73
- hints.run = runMatch[1].trim();
74
- const setupMatch = cleaned.match(/^@setup\s+(.+)/);
75
- if (setupMatch)
76
- hints.setup = setupMatch[1].trim();
77
- const cwdMatch = cleaned.match(/^@cwd\s+(.+)/);
78
- if (cwdMatch)
79
- hints.cwd = cwdMatch[1].trim();
80
- }
81
- return hints;
52
+ const metadata = extractCommentMetadata(filePath);
53
+ return {
54
+ run: metadata?.run,
55
+ setup: metadata?.setup,
56
+ cwd: metadata?.cwd,
57
+ };
82
58
  }
83
59
  // ── Auto-detection ───────────────────────────────────────────────────────────
84
60
  /**
@@ -118,9 +94,9 @@ export function detectExecHints(filePath) {
118
94
  * Resolve execution hints for a script asset.
119
95
  *
120
96
  * Resolution order (first non-empty value wins for each field):
121
- * 1. `.stash.json` fields (`run`/`setup`/`cwd`) take priority
122
- * 2. Script file header comments (`@run`/`@setup`/`@cwd`) second
123
- * 3. Auto-detection from extension + dependency files last
97
+ * 1. Indexed entry metadata (`run`/`setup`/`cwd`) when supplied by the caller
98
+ * 2. Script file header comments (`@run`/`@setup`/`@cwd`)
99
+ * 3. Auto-detection from extension + dependency files
124
100
  */
125
101
  export function resolveExecHints(stashEntry, filePath) {
126
102
  const stashHints = {
@@ -152,17 +128,6 @@ function deriveName(ctx) {
152
128
  return ext ? ctx.relPath.slice(0, -ext.length) : ctx.relPath;
153
129
  }
154
130
  export { buildWorkflowAction };
155
- /**
156
- * Load the matching StashEntry for a file path from the directory's .stash.json.
157
- */
158
- function findStashEntryForFile(filePath) {
159
- const dir = path.dirname(filePath);
160
- const stashFile = loadStashFile(dir);
161
- if (!stashFile)
162
- return undefined;
163
- const fileName = path.basename(filePath);
164
- return stashFile.entries.find((e) => e.filename === fileName);
165
- }
166
131
  function extractParameters(template) {
167
132
  const parameters = [];
168
133
  if (/\$ARGUMENTS\b/i.test(template)) {
@@ -182,18 +147,26 @@ function extractParameters(template) {
182
147
  }
183
148
  return parameters.length > 0 ? parameters : undefined;
184
149
  }
150
+ function readFrontmatterTags(value) {
151
+ if (!Array.isArray(value))
152
+ return undefined;
153
+ const tags = value.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
154
+ return tags.length > 0 ? tags : undefined;
155
+ }
185
156
  // ── 1. skill-md ──────────────────────────────────────────────────────────────
186
157
  const skillMdRenderer = {
187
158
  name: "skill-md",
188
159
  buildShowResponse(ctx) {
189
160
  const name = deriveName(ctx);
190
161
  const parsed = parseFrontmatter(ctx.content());
162
+ const tags = readFrontmatterTags(parsed.data.tags);
191
163
  return {
192
164
  type: "skill",
193
165
  name,
194
166
  path: ctx.absPath,
195
167
  action: "Read and follow the instructions below",
196
168
  description: toStringOrUndefined(parsed.data.description),
169
+ ...(tags ? { tags } : {}),
197
170
  content: parsed.content,
198
171
  };
199
172
  },
@@ -205,12 +178,14 @@ const commandMdRenderer = {
205
178
  const name = deriveName(ctx);
206
179
  const parsedMd = parseFrontmatter(ctx.content());
207
180
  const template = parsedMd.content;
181
+ const tags = readFrontmatterTags(parsedMd.data.tags);
208
182
  return {
209
183
  type: "command",
210
184
  name,
211
185
  path: ctx.absPath,
212
186
  action: "Fill $ARGUMENTS placeholders in the template, then dispatch",
213
187
  description: toStringOrUndefined(parsedMd.data.description),
188
+ ...(tags ? { tags } : {}),
214
189
  template,
215
190
  modelHint: typeof parsedMd.data.model === "string" ? parsedMd.data.model : undefined,
216
191
  agent: toStringOrUndefined(parsedMd.data.agent),
@@ -527,8 +502,7 @@ const scriptSourceRenderer = {
527
502
  const ext = path.extname(ctx.absPath).toLowerCase();
528
503
  // For extensions with a known interpreter, show exec hints
529
504
  if (INTERPRETER_MAP[ext]) {
530
- const stashEntry = findStashEntryForFile(ctx.absPath);
531
- const hints = resolveExecHints(stashEntry, ctx.absPath);
505
+ const hints = resolveExecHints(undefined, ctx.absPath);
532
506
  if (hints.run) {
533
507
  return {
534
508
  type: "script",
@@ -555,8 +529,7 @@ const scriptSourceRenderer = {
555
529
  if (!INTERPRETER_MAP[ext])
556
530
  return;
557
531
  try {
558
- const stashEntry = findStashEntryForFile(hit.path);
559
- const hints = resolveExecHints(stashEntry, hit.path);
532
+ const hints = resolveExecHints(undefined, hit.path);
560
533
  hit.run = hints.run;
561
534
  }
562
535
  catch (error) {
@@ -245,27 +245,22 @@ async function enumerateAssets(stashRoot) {
245
245
  }
246
246
  const entries = [];
247
247
  for (const [dirPath, files] of dirGroups) {
248
- let stash = loadStashFile(dirPath);
249
- if (stash) {
250
- const covered = new Set(stash.entries.map((entry) => entry.filename).filter((value) => !!value));
251
- const uncoveredFiles = files.filter((file) => !covered.has(path.basename(file)));
252
- if (uncoveredFiles.length > 0) {
253
- const generated = await generateMetadataFlat(stashRoot, uncoveredFiles);
254
- if (generated.entries.length > 0) {
255
- stash = { entries: [...stash.entries, ...generated.entries] };
256
- }
257
- }
258
- }
259
- else {
260
- const generated = await generateMetadataFlat(stashRoot, files);
261
- if (generated.entries.length === 0)
262
- continue;
263
- stash = generated;
264
- }
265
- entries.push(...stash.entries.map((entry) => attachFileSize(dirPath, entry)));
248
+ const generated = await generateMetadataFlat(stashRoot, files);
249
+ const legacyOverrides = loadStashFile(dirPath, { requireFilename: true });
250
+ const mergedEntries = legacyOverrides
251
+ ? generated.entries.map((entry) => mergeLegacyEntry(entry, legacyOverrides.entries))
252
+ : generated.entries;
253
+ const stash = mergedEntries.length > 0 ? { entries: mergedEntries } : legacyOverrides;
254
+ if (!stash || stash.entries.length === 0)
255
+ continue;
256
+ entries.push(...stash.entries.filter((entry) => !!entry.filename).map((entry) => attachFileSize(dirPath, entry)));
266
257
  }
267
258
  return entries.sort((a, b) => `${a.type}:${a.name}`.localeCompare(`${b.type}:${b.name}`));
268
259
  }
260
+ function mergeLegacyEntry(entry, legacyEntries) {
261
+ const legacy = legacyEntries.find((candidate) => candidate.filename === entry.filename);
262
+ return legacy ? { ...entry, ...legacy, filename: entry.filename } : entry;
263
+ }
269
264
  function attachFileSize(dirPath, entry) {
270
265
  if (typeof entry.fileSize === "number" || !entry.filename)
271
266
  return entry;