akm-cli 0.5.0 → 0.6.0-rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/README.md +9 -9
  3. package/dist/cli.js +379 -1448
  4. package/dist/{completions.js → commands/completions.js} +1 -1
  5. package/dist/{config-cli.js → commands/config-cli.js} +109 -11
  6. package/dist/commands/curate.js +263 -0
  7. package/dist/{info.js → commands/info.js} +17 -11
  8. package/dist/{init.js → commands/init.js} +4 -4
  9. package/dist/{install-audit.js → commands/install-audit.js} +14 -2
  10. package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
  11. package/dist/commands/migration-help.js +141 -0
  12. package/dist/{registry-search.js → commands/registry-search.js} +68 -9
  13. package/dist/commands/remember.js +178 -0
  14. package/dist/{stash-search.js → commands/search.js} +28 -69
  15. package/dist/{self-update.js → commands/self-update.js} +3 -3
  16. package/dist/{stash-show.js → commands/show.js} +106 -81
  17. package/dist/{stash-add.js → commands/source-add.js} +133 -67
  18. package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
  19. package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
  20. package/dist/{vault.js → commands/vault.js} +43 -0
  21. package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
  22. package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
  23. package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
  24. package/dist/{common.js → core/common.js} +147 -50
  25. package/dist/{config.js → core/config.js} +288 -29
  26. package/dist/core/errors.js +90 -0
  27. package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
  28. package/dist/{paths.js → core/paths.js} +4 -4
  29. package/dist/core/write-source.js +280 -0
  30. package/dist/{local-search.js → indexer/db-search.js} +49 -32
  31. package/dist/{db.js → indexer/db.js} +210 -81
  32. package/dist/{file-context.js → indexer/file-context.js} +3 -3
  33. package/dist/{indexer.js → indexer/indexer.js} +153 -30
  34. package/dist/{manifest.js → indexer/manifest.js} +10 -10
  35. package/dist/{matchers.js → indexer/matchers.js} +4 -7
  36. package/dist/{metadata.js → indexer/metadata.js} +9 -5
  37. package/dist/{search-source.js → indexer/search-source.js} +97 -55
  38. package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
  39. package/dist/{walker.js → indexer/walker.js} +1 -1
  40. package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
  41. package/dist/{llm.js → llm/client.js} +12 -48
  42. package/dist/llm/embedder.js +127 -0
  43. package/dist/llm/embedders/cache.js +47 -0
  44. package/dist/llm/embedders/local.js +152 -0
  45. package/dist/llm/embedders/remote.js +121 -0
  46. package/dist/llm/embedders/types.js +39 -0
  47. package/dist/llm/metadata-enhance.js +53 -0
  48. package/dist/output/cli-hints.js +301 -0
  49. package/dist/output/context.js +95 -0
  50. package/dist/{renderers.js → output/renderers.js} +57 -61
  51. package/dist/output/shapes.js +212 -0
  52. package/dist/output/text.js +520 -0
  53. package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
  54. package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
  55. package/dist/registry/factory.js +33 -0
  56. package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
  57. package/dist/registry/providers/index.js +11 -0
  58. package/dist/{providers → registry/providers}/skills-sh.js +60 -4
  59. package/dist/{providers → registry/providers}/static-index.js +126 -56
  60. package/dist/registry/providers/types.js +25 -0
  61. package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
  62. package/dist/{detect.js → setup/detect.js} +0 -27
  63. package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
  64. package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
  65. package/dist/{setup.js → setup/setup.js} +162 -129
  66. package/dist/setup/steps.js +45 -0
  67. package/dist/{kit-include.js → sources/include.js} +1 -1
  68. package/dist/sources/provider-factory.js +36 -0
  69. package/dist/sources/provider.js +21 -0
  70. package/dist/sources/providers/filesystem.js +35 -0
  71. package/dist/{stash-providers → sources/providers}/git.js +218 -28
  72. package/dist/{stash-providers → sources/providers}/index.js +4 -4
  73. package/dist/sources/providers/install-types.js +14 -0
  74. package/dist/sources/providers/npm.js +160 -0
  75. package/dist/sources/providers/provider-utils.js +173 -0
  76. package/dist/sources/providers/sync-from-ref.js +45 -0
  77. package/dist/sources/providers/tar-utils.js +154 -0
  78. package/dist/{stash-providers → sources/providers}/website.js +60 -20
  79. package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
  80. package/dist/{wiki.js → wiki/wiki.js} +18 -17
  81. package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
  82. package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
  83. package/dist/{workflow-db.js → workflows/db.js} +1 -1
  84. package/dist/workflows/document-cache.js +20 -0
  85. package/dist/workflows/parser.js +379 -0
  86. package/dist/workflows/renderer.js +78 -0
  87. package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
  88. package/dist/workflows/schema.js +11 -0
  89. package/dist/workflows/validator.js +48 -0
  90. package/docs/README.md +30 -0
  91. package/docs/migration/release-notes/0.0.13.md +4 -0
  92. package/docs/migration/release-notes/0.1.0.md +6 -0
  93. package/docs/migration/release-notes/0.2.0.md +6 -0
  94. package/docs/migration/release-notes/0.3.0.md +5 -0
  95. package/docs/migration/release-notes/0.5.0.md +6 -0
  96. package/docs/migration/release-notes/0.6.0.md +75 -0
  97. package/docs/migration/release-notes/README.md +21 -0
  98. package/package.json +3 -2
  99. package/dist/embedder.js +0 -351
  100. package/dist/errors.js +0 -34
  101. package/dist/migration-help.js +0 -110
  102. package/dist/registry-factory.js +0 -19
  103. package/dist/registry-install.js +0 -532
  104. package/dist/ripgrep.js +0 -2
  105. package/dist/stash-provider-factory.js +0 -35
  106. package/dist/stash-provider.js +0 -1
  107. package/dist/stash-providers/filesystem.js +0 -41
  108. package/dist/stash-providers/openviking.js +0 -348
  109. package/dist/stash-providers/provider-utils.js +0 -11
  110. package/dist/stash-types.js +0 -1
  111. package/dist/workflow-markdown.js +0 -251
  112. /package/dist/{markdown.js → core/markdown.js} +0 -0
  113. /package/dist/{warn.js → core/warn.js} +0 -0
  114. /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
  115. /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
  116. /package/dist/{github.js → integrations/github.js} +0 -0
  117. /package/dist/{registry-provider.js → registry/types.js} +0 -0
  118. /package/dist/{registry-types.js → sources/types.js} +0 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Memory-specific helpers for `akm remember`.
3
+ *
4
+ * Extracted from `src/cli.ts` so the domain logic (frontmatter assembly,
5
+ * heuristic derivation, LLM enrichment) is testable in isolation and the
6
+ * CLI entry point stays focused on argument parsing + output routing.
7
+ */
8
+ import { stringify as yamlStringify } from "yaml";
9
+ import { toErrorMessage, tryReadStdinText } from "../core/common";
10
+ import { loadConfig } from "../core/config";
11
+ import { UsageError } from "../core/errors";
12
+ import { warn } from "../core/warn";
13
+ /**
14
+ * Parse a shorthand duration string to a number of milliseconds.
15
+ * Supports: `30d` (days), `12h` (hours), `6m` (months, approximated as 30d).
16
+ */
17
+ export function parseDuration(s) {
18
+ const match = s.trim().match(/^(\d+)([dhm])$/i);
19
+ if (!match)
20
+ throw new UsageError(`Invalid --expires format "${s}". Use shorthand like 30d, 12h, or 6m.`, "INVALID_FLAG_VALUE");
21
+ const n = Number(match[1]);
22
+ const unit = match[2].toLowerCase();
23
+ if (unit === "d")
24
+ return n * 24 * 60 * 60 * 1000;
25
+ if (unit === "h")
26
+ return n * 60 * 60 * 1000;
27
+ // 'm' = months, approximated as 30 days
28
+ return n * 30 * 24 * 60 * 60 * 1000;
29
+ }
30
+ /**
31
+ * Build a YAML frontmatter block from memory metadata.
32
+ *
33
+ * Uses `yaml.stringify` so values containing newlines, colons, or other
34
+ * YAML metacharacters are safely quoted. The previous implementation
35
+ * interpolated user input directly into `key: value` lines, which let a
36
+ * `description` containing `\n` + `tags: [x]` inject additional keys into
37
+ * the frontmatter — that is no longer possible here.
38
+ *
39
+ * Only includes fields that are present (non-empty).
40
+ */
41
+ export function buildMemoryFrontmatter(fields) {
42
+ const obj = {};
43
+ if (fields.description?.trim())
44
+ obj.description = fields.description;
45
+ if (fields.tags && fields.tags.length > 0)
46
+ obj.tags = fields.tags;
47
+ if (fields.source?.trim())
48
+ obj.source = fields.source;
49
+ if (fields.observed_at?.trim())
50
+ obj.observed_at = fields.observed_at;
51
+ if (fields.expires?.trim())
52
+ obj.expires = fields.expires;
53
+ if (fields.subjective)
54
+ obj.subjective = true;
55
+ // No fields populated → emit a bare delimiter pair so callers don't
56
+ // produce `---\n{}\n---` (the YAML serializer's empty-object form).
57
+ if (Object.keys(obj).length === 0)
58
+ return "---\n---";
59
+ const serialized = yamlStringify(obj).trimEnd();
60
+ return `---\n${serialized}\n---`;
61
+ }
62
+ /**
63
+ * Read memory content from the positional arg or stdin.
64
+ * Throws {@link UsageError} if neither is populated.
65
+ */
66
+ export function readMemoryContent(contentArg) {
67
+ const content = contentArg ?? tryReadStdinText();
68
+ if (!content?.trim()) {
69
+ throw new UsageError("Memory content is required. Pass quoted text or pipe markdown into stdin.");
70
+ }
71
+ return content;
72
+ }
73
+ /**
74
+ * Run heuristic analysis on memory body text. Returns derived metadata
75
+ * fields without modifying any files. Pure TS, zero network, zero latency.
76
+ */
77
+ export function runAutoHeuristics(body) {
78
+ const tags = [];
79
+ // Fenced code block present → tag "code"
80
+ if (/^```/m.test(body)) {
81
+ tags.push("code");
82
+ }
83
+ // First-person pronoun → subjective
84
+ const subjective = /\b(I|we|my|our)\b/.test(body) ? true : undefined;
85
+ // First URL-shaped token → source
86
+ const urlMatch = body.match(/https?:\/\/[^\s)>'"]+/);
87
+ const source = urlMatch ? urlMatch[0] : undefined;
88
+ // ISO date token or obvious relative date phrase → observed_at
89
+ const observed_at = detectObservedAt(body);
90
+ return { tags, source, observed_at, subjective };
91
+ }
92
+ const RELATIVE_DATE_OFFSETS = {
93
+ today: () => { },
94
+ yesterday: (d) => d.setDate(d.getDate() - 1),
95
+ "last week": (d) => d.setDate(d.getDate() - 7),
96
+ "last month": (d) => d.setMonth(d.getMonth() - 1),
97
+ };
98
+ function detectObservedAt(body) {
99
+ const isoMatch = body.match(/\b(\d{4}-\d{2}-\d{2})\b/);
100
+ if (isoMatch)
101
+ return isoMatch[1];
102
+ const relMatch = body.match(/\b(today|yesterday|last\s+week|last\s+month)\b/i);
103
+ if (!relMatch)
104
+ return undefined;
105
+ // Normalise the matched phrase: lowercase, collapse internal whitespace,
106
+ // so "last week" matches the lookup table key.
107
+ const phrase = relMatch[1].toLowerCase().replace(/\s+/g, " ");
108
+ const offset = RELATIVE_DATE_OFFSETS[phrase];
109
+ if (!offset)
110
+ return undefined;
111
+ const d = new Date();
112
+ offset(d);
113
+ return d.toISOString().slice(0, 10);
114
+ }
115
+ /** Hard timeout for the `--enrich` LLM call. Write-path must not block on a misbehaving endpoint. */
116
+ const LLM_ENRICH_TIMEOUT_MS = 10_000;
117
+ /**
118
+ * Attempt LLM enrichment of memory metadata. Returns merged metadata
119
+ * fields on success. On timeout, unreachable, or invalid JSON — returns
120
+ * empty result and emits a warning. Never throws; always resolves.
121
+ */
122
+ export async function runLlmEnrich(body) {
123
+ const config = loadConfig();
124
+ if (!config.llm) {
125
+ warn("Warning: --enrich requires an LLM to be configured. Run `akm config set llm` to configure one.");
126
+ return { tags: [] };
127
+ }
128
+ const llmConfig = config.llm;
129
+ const { chatCompletion, parseJsonResponse } = await import("../llm/client");
130
+ const prompt = `You are a memory tagger for a developer knowledge base.
131
+ Given the memory text below, return ONLY a JSON object with these fields:
132
+ - "tags": array of 1-5 short lowercase keyword tags
133
+ - "description": one-sentence summary (optional)
134
+ - "observed_at": ISO date (YYYY-MM-DD) if the text references a specific date (optional)
135
+
136
+ Memory text:
137
+ ${body.slice(0, 2000)}
138
+
139
+ Return ONLY the JSON object, no prose, no markdown fences.`;
140
+ try {
141
+ let timeoutHandle;
142
+ const result = await (async () => {
143
+ try {
144
+ return await Promise.race([
145
+ chatCompletion(llmConfig, [
146
+ { role: "system", content: "Return only valid JSON. No prose." },
147
+ { role: "user", content: prompt },
148
+ ], { maxTokens: 256, temperature: 0.1 }),
149
+ new Promise((_, reject) => {
150
+ timeoutHandle = setTimeout(() => reject(new Error("LLM enrichment timed out")), LLM_ENRICH_TIMEOUT_MS);
151
+ }),
152
+ ]);
153
+ }
154
+ finally {
155
+ if (timeoutHandle !== undefined) {
156
+ clearTimeout(timeoutHandle);
157
+ }
158
+ }
159
+ })();
160
+ const parsed = parseJsonResponse(result);
161
+ if (!parsed) {
162
+ warn("Warning: --enrich received invalid JSON from the LLM. Writing memory without enrichment.");
163
+ return { tags: [] };
164
+ }
165
+ const tags = Array.isArray(parsed.tags)
166
+ ? parsed.tags.filter((t) => typeof t === "string" && t.trim().length > 0)
167
+ : [];
168
+ const description = typeof parsed.description === "string" && parsed.description.trim() ? parsed.description.trim() : undefined;
169
+ const observed_at = typeof parsed.observed_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.observed_at.trim())
170
+ ? parsed.observed_at.trim()
171
+ : undefined;
172
+ return { tags, description, observed_at };
173
+ }
174
+ catch (err) {
175
+ warn(`Warning: --enrich failed (${toErrorMessage(err)}). Writing memory without enrichment.`);
176
+ return { tags: [] };
177
+ }
178
+ }
@@ -1,13 +1,23 @@
1
- import { loadConfig } from "./config";
2
- import { closeDatabase, openDatabase } from "./db";
3
- import { searchLocal } from "./local-search";
4
- import { resolveStashProviders } from "./stash-provider-factory";
5
- // Eagerly import stash providers to trigger self-registration
6
- import "./stash-providers/index";
7
- import { UsageError } from "./errors";
1
+ /**
2
+ * `akm search` entry point.
3
+ *
4
+ * Spec §6.1: search consults the local FTS5 index. There is one query path
5
+ * because there is one data store. Provider fan-out is gone.
6
+ *
7
+ * The orchestration here is thin: build the FTS query, optionally interleave
8
+ * a registry search behind `--source registry|both`, and log a usage event.
9
+ * Provider `search()` methods do not exist.
10
+ */
11
+ import { loadConfig } from "../core/config";
12
+ import { UsageError } from "../core/errors";
13
+ import { closeDatabase, openDatabase } from "../indexer/db";
14
+ import { searchLocal } from "../indexer/db-search";
15
+ import { resolveSourceEntries } from "../indexer/search-source";
16
+ // Eagerly import source providers to trigger self-registration before the
17
+ // indexer or path-resolution code runs.
18
+ import "../sources/providers/index";
19
+ import { insertUsageEvent } from "../indexer/usage-events";
8
20
  import { searchRegistry } from "./registry-search";
9
- import { resolveStashSources } from "./search-source";
10
- import { insertUsageEvent } from "./usage-events";
11
21
  const DEFAULT_LIMIT = 20;
12
22
  export async function akmSearch(input) {
13
23
  const t0 = Date.now();
@@ -17,7 +27,7 @@ export async function akmSearch(input) {
17
27
  const limit = normalizeLimit(input.limit);
18
28
  const source = parseSearchSource(input.source ?? "stash");
19
29
  const config = loadConfig();
20
- const sources = resolveStashSources(undefined, config);
30
+ const sources = resolveSourceEntries(undefined, config);
21
31
  if (sources.length === 0) {
22
32
  // stashDir: "" is a safe sentinel here — the response carries zero hits
23
33
  // and a warning, so no downstream code will try to use the empty path.
@@ -35,10 +45,6 @@ export async function akmSearch(input) {
35
45
  // Primary stash directory — used for DB path lookups and as the default
36
46
  // stash root. Safe because the empty-sources case is handled above.
37
47
  const stashDir = sources[0].path;
38
- // Resolve additional stash providers (e.g. OpenViking) from config.
39
- // Exclude filesystem (handled by resolveStashSources) and context-hub/github
40
- // (content now indexed through the unified FTS5 pipeline).
41
- const additionalStashProviders = resolveStashProviders(config).filter((p) => p.type !== "filesystem" && p.type !== "context-hub" && p.type !== "git");
42
48
  const localResult = source === "registry"
43
49
  ? undefined
44
50
  : await searchLocal({
@@ -49,35 +55,17 @@ export async function akmSearch(input) {
49
55
  sources,
50
56
  config,
51
57
  });
52
- // Pass original case to providers — FTS5 requires lowercase but remote providers handle case themselves
53
- const additionalStashResults = source === "registry" || additionalStashProviders.length === 0
54
- ? []
55
- : await Promise.all(additionalStashProviders.map(async (provider) => {
56
- try {
57
- return await provider.search({ query, type: searchType === "any" ? undefined : searchType, limit });
58
- }
59
- catch (err) {
60
- return {
61
- hits: [],
62
- warnings: [`Stash ${provider.name}: ${err instanceof Error ? err.message : String(err)}`],
63
- };
64
- }
65
- }));
66
- // Merge stash hits from all providers
67
- const additionalHits = additionalStashResults.flatMap((r) => r.hits);
68
- const additionalWarnings = additionalStashResults.flatMap((r) => r.warnings ?? []);
69
58
  const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
70
59
  if (source === "stash") {
71
- const allStashHits = mergeStashHits(localResult?.hits ?? [], additionalHits, limit);
72
- const localWarnings = [...(localResult?.warnings ?? []), ...additionalWarnings];
73
- const hasResults = allStashHits.length > 0;
60
+ const localHits = localResult?.hits ?? [];
61
+ const hasResults = localHits.length > 0;
74
62
  const response = {
75
63
  schemaVersion: 1,
76
64
  stashDir,
77
65
  source,
78
- hits: allStashHits,
66
+ hits: localHits,
79
67
  tip: hasResults ? undefined : localResult?.tip,
80
- warnings: localWarnings.length > 0 ? localWarnings : undefined,
68
+ warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
81
69
  timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
82
70
  };
83
71
  logSearchEvent(query, response);
@@ -116,14 +104,14 @@ export async function akmSearch(input) {
116
104
  return response;
117
105
  }
118
106
  // source === "both"
119
- const allStashHits = mergeStashHits(localResult?.hits ?? [], additionalHits, limit * 2);
120
- const warnings = [...(localResult?.warnings ?? []), ...additionalWarnings, ...(registryResult?.warnings ?? [])];
107
+ const allStashHits = (localResult?.hits ?? []).slice(0, limit);
108
+ const warnings = [...(localResult?.warnings ?? []), ...(registryResult?.warnings ?? [])];
121
109
  const hasResults = allStashHits.length > 0 || registryHits.length > 0;
122
110
  const response = {
123
111
  schemaVersion: 1,
124
112
  stashDir,
125
113
  source,
126
- hits: allStashHits.slice(0, limit),
114
+ hits: allStashHits,
127
115
  registryHits,
128
116
  tip: hasResults ? undefined : "No matching stash assets or registry entries were found.",
129
117
  warnings: warnings.length ? warnings : undefined,
@@ -184,35 +172,6 @@ function logSearchEvent(query, response, existingDb) {
184
172
  }
185
173
  }
186
174
  // ── Helpers ──────────────────────────────────────────────────────────────────
187
- /**
188
- * Merge local and additional stash hits into a single ranked list.
189
- *
190
- * Provider hits (e.g. OpenViking) keep their original scores and compete
191
- * fairly alongside local hits. Duplicates are resolved in favour of the
192
- * local version.
193
- *
194
- * 1. Build set of local hit keys for dedup.
195
- * 2. Filter provider hits that aren't duplicates.
196
- * 3. Combine local + non-duplicate provider hits.
197
- * 4. Sort by score descending.
198
- * 5. Slice to limit.
199
- */
200
- export function mergeStashHits(localHits, additionalHits, limit) {
201
- if (additionalHits.length === 0)
202
- return localHits.slice(0, limit);
203
- // Track local hits by a dedup key (path > ref > name)
204
- const localKeys = new Set();
205
- for (const h of localHits) {
206
- localKeys.add(h.path ?? h.ref ?? h.name);
207
- }
208
- // Keep non-duplicate provider hits with their original scores
209
- const providerOnly = additionalHits.filter((h) => {
210
- const key = h.path ?? h.ref ?? h.name;
211
- return !localKeys.has(key);
212
- });
213
- // Combine and sort by score descending
214
- return [...localHits, ...providerOnly].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, limit);
215
- }
216
175
  function normalizeLimit(limit) {
217
176
  if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
218
177
  return DEFAULT_LIMIT;
@@ -227,7 +186,7 @@ export function parseSearchSource(source) {
227
186
  return "stash";
228
187
  if (typeof source === "undefined")
229
188
  return "stash";
230
- throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`);
189
+ throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`, "INVALID_SOURCE_VALUE");
231
190
  }
232
191
  /**
233
192
  * Merge stash hits and registry hits via simple concatenation.
@@ -2,8 +2,8 @@ import * as childProcess from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { fetchWithRetry, IS_WINDOWS } from "./common";
6
- import { githubHeaders } from "./github";
5
+ import { fetchWithRetry, IS_WINDOWS } from "../core/common";
6
+ import { githubHeaders } from "../integrations/github";
7
7
  const REPO = "itlackey/akm";
8
8
  const DEFAULT_PACKAGE_NAME = "akm-cli";
9
9
  const NODE_MODULES_SEGMENT = "/node_modules/";
@@ -303,7 +303,7 @@ function normalizePathSeparators(value) {
303
303
  }
304
304
  function getInstalledPackageName() {
305
305
  try {
306
- const pkgPath = path.resolve(import.meta.dir ?? __dirname, "../package.json");
306
+ const pkgPath = path.resolve(import.meta.dir ?? __dirname, "../../package.json");
307
307
  if (fs.existsSync(pkgPath)) {
308
308
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
309
309
  if (typeof pkg.name === "string" && pkg.name.trim()) {
@@ -1,31 +1,47 @@
1
+ /**
2
+ * `akm show` — entry point.
3
+ *
4
+ * Spec §6.2:
5
+ *
6
+ * show(ref) → indexer.lookup(ref) → readFile(entry.filePath)
7
+ *
8
+ * The richer presentation logic (matchers, renderers, wiki-root handling,
9
+ * edit-hints, summary-detail truncation) lives below in this file. The flow:
10
+ *
11
+ * 1. Special-case wiki-root refs (`wiki:<name>` with no page path).
12
+ * 2. Ask `indexer.lookup(ref)` for the row in the FTS index.
13
+ * 3. Fall back to the on-disk type-dir resolver only when the index has
14
+ * no matching row — covers the "indexed yet?" gap when the user has
15
+ * just added a file and not run `akm index`.
16
+ * 4. Render the file via the matcher/renderer pipeline.
17
+ *
18
+ * Step (2) is the v1 spec change: reading is the indexer's job. Step (3) is a
19
+ * pragmatic safety net (NOT remote provider fallback, which the spec
20
+ * forbids — "Show: Local FTS5 index only. No remote provider fallback.").
21
+ */
1
22
  import fs from "node:fs";
2
23
  import path from "node:path";
3
- import { loadConfig } from "./config";
4
- import { closeDatabase, openDatabase } from "./db";
5
- import { NotFoundError, UsageError } from "./errors";
6
- import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "./file-context";
7
- import { parseFrontmatter, toStringOrUndefined } from "./frontmatter";
8
- import { loadStashFile } from "./metadata";
9
- import { resolveSourcesForOrigin } from "./origin-resolve";
10
- import { buildEditHint, findSourceForPath, isEditable, resolveStashSources } from "./search-source";
11
- import { resolveStashProviders } from "./stash-provider-factory";
12
- import { parseAssetRef } from "./stash-ref";
13
- import { resolveAssetPath } from "./stash-resolve";
14
- import { insertUsageEvent } from "./usage-events";
15
- // Eagerly import stash providers to trigger self-registration
16
- import "./stash-providers/index";
24
+ import { parseAssetRef } from "../core/asset-ref";
25
+ import { loadConfig } from "../core/config";
26
+ import { NotFoundError, UsageError } from "../core/errors";
27
+ import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
28
+ import { closeDatabase, openDatabase } from "../indexer/db";
29
+ import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
30
+ import { lookup } from "../indexer/indexer";
31
+ import { loadStashFile } from "../indexer/metadata";
32
+ import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries } from "../indexer/search-source";
33
+ import { resolveSourcesForOrigin } from "../registry/origin-resolve";
34
+ // Eagerly import source providers to trigger self-registration.
35
+ import "../sources/providers/index";
36
+ import { insertUsageEvent } from "../indexer/usage-events";
37
+ import { resolveAssetPath } from "../sources/resolve";
17
38
  /**
18
39
  * Show a wiki root (no page path) — returns the same payload as
19
40
  * `akm wiki show <name>`.
20
- *
21
- * Called when `parseAssetRef` yields `type === "wiki"` and the name has no
22
- * `/`, e.g. `wiki:research`.
23
41
  */
24
42
  async function showWikiRoot(stashDir, wikiName) {
25
- const { showWiki } = await import("./wiki.js");
43
+ const { showWiki } = await import("../wiki/wiki.js");
26
44
  const result = showWiki(stashDir, wikiName);
27
- // Shape the WikiShowResult into a ShowResponse-compatible object.
28
- // The payload mirrors what `akm wiki show <name>` returns.
29
45
  return {
30
46
  type: "wiki",
31
47
  name: result.ref,
@@ -40,7 +56,7 @@ async function showWikiRoot(stashDir, wikiName) {
40
56
  };
41
57
  }
42
58
  async function showWikiRootForSource(stashDir, source, wikiName) {
43
- const { showWikiAtPath } = await import("./wiki.js");
59
+ const { showWikiAtPath } = await import("../wiki/wiki.js");
44
60
  if (source.wikiName === wikiName) {
45
61
  const result = showWikiAtPath(wikiName, source.path);
46
62
  return {
@@ -66,19 +82,21 @@ function resolveRegisteredWikiAssetPath(wikiRoot, wikiName, assetName) {
66
82
  const candidate = path.resolve(wikiRoot, `${pageName}.md`);
67
83
  const resolvedRoot = fs.realpathSync(wikiRoot);
68
84
  if (!candidate.startsWith(resolvedRoot + path.sep)) {
69
- throw new UsageError("Ref resolves outside the stash root.");
85
+ throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
70
86
  }
71
87
  if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
72
88
  throw new NotFoundError(`Stash asset not found for ref: wiki:${assetName}`);
73
89
  }
74
90
  const realTarget = fs.realpathSync(candidate);
75
91
  if (!realTarget.startsWith(resolvedRoot + path.sep)) {
76
- throw new UsageError("Ref resolves outside the stash root.");
92
+ throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
77
93
  }
78
94
  return realTarget;
79
95
  }
80
96
  /**
81
- * Unified show: tries local FTS5 index first, then remote providers.
97
+ * Unified show: queries the local FTS5 index, then falls back to on-disk
98
+ * type-dir resolution if the index has no row. Spec §6.2; no remote provider
99
+ * fallback.
82
100
  *
83
101
  * When `detail` is `"summary"`, the response omits content/template/prompt and
84
102
  * returns only compact metadata (name, type, description, tags, parameters).
@@ -92,7 +110,7 @@ export async function akmShowUnified(input) {
92
110
  {
93
111
  const parsed = parseAssetRef(ref);
94
112
  if (parsed.type === "wiki" && !parsed.name.includes("/")) {
95
- const allSources = resolveStashSources();
113
+ const allSources = resolveSourceEntries();
96
114
  const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
97
115
  let lastError;
98
116
  for (const source of searchSources) {
@@ -109,43 +127,11 @@ export async function akmShowUnified(input) {
109
127
  new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
110
128
  }
111
129
  }
112
- // 1. Try local filesystem first (FTS5 index lookup)
113
- let localError;
114
- try {
115
- const result = await showLocal(input);
116
- logShowEvent(ref);
117
- return result;
118
- }
119
- catch (err) {
120
- // Only fall through to remote providers on NotFoundError
121
- if (!(err instanceof NotFoundError))
122
- throw err;
123
- localError = err;
124
- }
125
- // 2. Try remote providers (e.g. OpenViking)
126
- const config = loadConfig();
127
- const providers = resolveStashProviders(config).filter((p) => p.type !== "filesystem" && p.canShow(ref));
128
- for (const provider of providers) {
129
- try {
130
- const response = await provider.show(ref, input.view);
131
- logShowEvent(ref);
132
- if (input.detail === "summary") {
133
- return buildSummaryResponse(response);
134
- }
135
- return response;
136
- }
137
- catch (err) {
138
- if (!(err instanceof NotFoundError))
139
- throw err;
140
- }
141
- }
142
- // Nothing found anywhere — rethrow the original local error with its specific message
143
- throw localError;
130
+ // Try local filesystem (FTS5 index lookup, then on-disk fallback)
131
+ const result = await showLocal(input);
132
+ logShowEvent(ref);
133
+ return result;
144
134
  }
145
- /**
146
- * Fire-and-forget: log a show event to the usage_events table.
147
- * Never blocks the caller; errors are silently ignored.
148
- */
149
135
  function logShowEvent(ref, existingDb) {
150
136
  try {
151
137
  const db = existingDb ?? openDatabase();
@@ -170,14 +156,48 @@ function logShowEvent(ref, existingDb) {
170
156
  /* fire-and-forget */
171
157
  }
172
158
  }
159
+ /**
160
+ * Resolve an asset path to a file via:
161
+ * 1. `indexer.lookup(ref)` — the spec's primary path (§6.2).
162
+ * 2. On-disk type-dir traversal — fallback for files not yet indexed.
163
+ *
164
+ * Returns `undefined` if neither path finds a match.
165
+ */
166
+ async function resolvePathViaIndexThenDisk(parsed, searchSourceDirs) {
167
+ // Step 1: indexer
168
+ try {
169
+ const entry = await lookup(parsed);
170
+ if (entry) {
171
+ return { assetPath: entry.filePath };
172
+ }
173
+ }
174
+ catch (err) {
175
+ // Index unavailable (e.g. DB doesn't exist yet) — fall back to disk walk.
176
+ if (!(err instanceof NotFoundError)) {
177
+ // continue to disk fallback
178
+ }
179
+ }
180
+ // Step 2: on-disk type-dir traversal
181
+ let lastError;
182
+ for (const dir of searchSourceDirs) {
183
+ try {
184
+ const assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
185
+ return { assetPath, lastError };
186
+ }
187
+ catch (err) {
188
+ lastError = err instanceof Error ? err : new Error(String(err));
189
+ }
190
+ }
191
+ return lastError ? { assetPath: "", lastError } : undefined;
192
+ }
173
193
  /** @internal Use akmShowUnified() for all external callers. */
174
194
  export async function showLocal(input) {
175
195
  const parsed = parseAssetRef(input.ref);
176
196
  const displayType = parsed.type;
177
197
  const config = loadConfig();
178
- const allSources = resolveStashSources(input.stashDir);
198
+ const allSources = resolveSourceEntries(input.stashDir);
179
199
  const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
180
- const allStashDirs = searchSources.map((s) => s.path);
200
+ const allSourceDirs = searchSources.map((s) => s.path);
181
201
  let assetPath;
182
202
  const matchedSource = parsed.type === "wiki" ? searchSources.find((source) => parsed.name.startsWith(`${source.wikiName}/`)) : undefined;
183
203
  let lastError;
@@ -189,21 +209,19 @@ export async function showLocal(input) {
189
209
  lastError = err instanceof Error ? err : new Error(String(err));
190
210
  }
191
211
  }
192
- for (const dir of allStashDirs) {
193
- if (assetPath)
194
- break;
195
- try {
196
- assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
197
- break;
212
+ if (!assetPath) {
213
+ const resolved = await resolvePathViaIndexThenDisk(parsed, allSourceDirs);
214
+ if (resolved?.assetPath) {
215
+ assetPath = resolved.assetPath;
198
216
  }
199
- catch (err) {
200
- lastError = err instanceof Error ? err : new Error(String(err));
217
+ else if (resolved?.lastError) {
218
+ lastError = resolved.lastError;
201
219
  }
202
220
  }
203
221
  if (!assetPath && parsed.origin && searchSources.length === 0) {
204
222
  const installCmd = `akm add ${parsed.origin}`;
205
223
  throw new NotFoundError(`Stash asset not found for ref: ${displayType}:${parsed.name}. ` +
206
- `Kit "${parsed.origin}" is not installed. Run: ${installCmd}`);
224
+ `Stash "${parsed.origin}" is not installed. Run: ${installCmd}`);
207
225
  }
208
226
  if (!assetPath) {
209
227
  throw (lastError ??
@@ -211,7 +229,7 @@ export async function showLocal(input) {
211
229
  "Check the name with `akm search` or verify the asset exists in your stash."));
212
230
  }
213
231
  const source = matchedSource ?? findSourceForPath(assetPath, allSources);
214
- const sourceStashDir = source?.path ?? allStashDirs[0];
232
+ const sourceStashDir = source?.path ?? allSourceDirs[0];
215
233
  if (!sourceStashDir) {
216
234
  throw new UsageError(`Could not determine stash root for asset: ${displayType}:${parsed.name}. ` +
217
235
  "Run `akm init` to create the stash directory, or check `akm stash list` for configured paths.");
@@ -229,7 +247,7 @@ export async function showLocal(input) {
229
247
  if (!renderer) {
230
248
  throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
231
249
  }
232
- const renderCtx = buildRenderContext(fileCtx, match, allStashDirs, source?.registryId);
250
+ const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, source?.registryId);
233
251
  const response = renderer.buildShowResponse(renderCtx);
234
252
  const editable = isEditable(assetPath, config);
235
253
  const fullResponse = {
@@ -243,6 +261,20 @@ export async function showLocal(input) {
243
261
  }
244
262
  return fullResponse;
245
263
  }
264
+ /**
265
+ * Minimal `show`: ref → indexer lookup → file contents. Used by callers that
266
+ * just need the raw file (e.g. clone, write-source) and don't want the full
267
+ * renderer graph. Spec §6.2's literal flow.
268
+ */
269
+ export async function showByRef(ref) {
270
+ const parsed = parseAssetRef(ref);
271
+ const entry = await lookup(parsed);
272
+ if (!entry) {
273
+ throw new NotFoundError(`Asset not found for ref: ${parsed.type}:${parsed.name}`);
274
+ }
275
+ const body = await fs.promises.readFile(entry.filePath, "utf8");
276
+ return { filePath: entry.filePath, body };
277
+ }
246
278
  /**
247
279
  * Build a compact summary response from a full ShowResponse.
248
280
  *
@@ -250,24 +282,17 @@ export async function showLocal(input) {
250
282
  * type, name, path, description, tags, parameters, action.
251
283
  * Enriches description and tags from frontmatter or .stash.json when available.
252
284
  *
253
- * Enrichment via frontmatter and .stash.json is only performed when `assetPath`
254
- * is supplied (local assets). Remote provider responses (e.g. OpenViking) rely
255
- * on the provider having already populated description and tags.
256
- *
257
285
  * The resulting JSON should be under 200 tokens.
258
286
  */
259
287
  function buildSummaryResponse(full, assetPath) {
260
- // Try to enrich metadata from .stash.json if description or tags are missing
261
288
  let description = full.description;
262
289
  let tags = full.tags;
263
290
  if (assetPath) {
264
- // Try frontmatter extraction from content fields
265
291
  const textContent = full.content ?? full.template ?? full.prompt;
266
292
  if (textContent && !description) {
267
293
  const parsed = parseFrontmatter(textContent);
268
294
  description = toStringOrUndefined(parsed.data.description);
269
295
  }
270
- // Try .stash.json for richer metadata (tags especially)
271
296
  const dir = path.dirname(assetPath);
272
297
  const stashFile = loadStashFile(dir);
273
298
  if (stashFile) {