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
@@ -6,34 +6,38 @@
6
6
  */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { resolveStashDir } from "./common";
10
- import { loadConfig } from "./config";
11
- import { NotFoundError, UsageError } from "./errors";
12
- import { akmIndex } from "./indexer";
13
- import { removeLockEntry, upsertLockEntry } from "./lockfile";
14
- import { installRegistryRef, removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./registry-install";
15
- import { parseRegistryRef } from "./registry-resolve";
16
- import { removeStash } from "./stash-source-manage";
9
+ import { resolveStashDir } from "../core/common";
10
+ import { loadConfig } from "../core/config";
11
+ import { NotFoundError, UsageError } from "../core/errors";
12
+ import { akmIndex } from "../indexer/indexer";
13
+ import { removeLockEntry, upsertLockEntry } from "../integrations/lockfile";
14
+ import { parseRegistryRef } from "../registry/resolve";
15
+ import { syncFromRef } from "../sources/providers/sync-from-ref";
16
+ import { ensureWebsiteMirror } from "../sources/providers/website";
17
+ import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
18
+ import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./source-add";
19
+ import { removeStash } from "./source-manage";
17
20
  export async function akmListSources(input) {
18
21
  const stashDir = input?.stashDir ?? resolveStashDir();
19
22
  const config = loadConfig();
20
23
  const kindFilter = input?.kind;
21
24
  const sources = [];
22
- // Stash entries local or remote sources
23
- for (const stash of config.stashes ?? []) {
24
- const isRemote = stash.url != null;
25
- const kind = isRemote ? "remote" : "local";
25
+ // Stash entries each entry exposes its provider type as kind (spec §2.1).
26
+ // Writable defaults: true for filesystem, false for git/npm/website (CLAUDE.md "Writes").
27
+ for (const stash of config.sources ?? config.stashes ?? []) {
28
+ const kind = stash.type ?? "filesystem";
26
29
  if (kindFilter && !kindFilter.includes(kind))
27
30
  continue;
31
+ const isFilesystem = kind === "filesystem";
32
+ const writableDefault = isFilesystem;
28
33
  const name = stash.name ?? stash.path ?? stash.url ?? "unknown";
29
34
  sources.push({
30
35
  name,
31
36
  kind,
32
37
  wiki: stash.wikiName,
33
38
  path: stash.path,
34
- provider: isRemote ? stash.type : undefined,
35
- updatable: false,
36
- writable: stash.writable === true,
39
+ provider: stash.url != null ? stash.type : undefined,
40
+ writable: stash.writable !== undefined ? stash.writable : writableDefault,
37
41
  status: { exists: stash.path ? directoryExists(stash.path) : true },
38
42
  });
39
43
  }
@@ -49,7 +53,6 @@ export async function akmListSources(input) {
49
53
  path: entry.stashRoot,
50
54
  ref: entry.ref,
51
55
  version: entry.resolvedVersion,
52
- updatable: true,
53
56
  writable: entry.writable === true,
54
57
  status: { exists: directoryExists(entry.stashRoot) },
55
58
  });
@@ -64,7 +67,7 @@ export async function akmListSources(input) {
64
67
  export async function akmRemove(input) {
65
68
  const target = input.target.trim();
66
69
  if (!target)
67
- throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/kit` or `akm remove ~/my-stash`).");
70
+ throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/stash` or `akm remove ~/my-stash`).");
68
71
  const stashDir = input.stashDir ?? resolveStashDir();
69
72
  const config = loadConfig();
70
73
  const installed = config.installed ?? [];
@@ -89,7 +92,7 @@ export async function akmRemove(input) {
89
92
  stashRoot: entry.stashRoot,
90
93
  },
91
94
  config: {
92
- stashCount: updatedConfig.stashes?.length ?? 0,
95
+ sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
93
96
  installedKitCount: updatedConfig.installed?.length ?? 0,
94
97
  },
95
98
  index: {
@@ -103,7 +106,7 @@ export async function akmRemove(input) {
103
106
  // Fall through to stashes[] (local/remote sources)
104
107
  const stashResult = removeStash(target);
105
108
  if (!stashResult.removed || !stashResult.entry) {
106
- throw new NotFoundError(`No matching source for target: ${target}`);
109
+ throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
107
110
  }
108
111
  const removedEntry = stashResult.entry;
109
112
  const index = await akmIndex({ stashDir });
@@ -120,7 +123,7 @@ export async function akmRemove(input) {
120
123
  stashRoot: removedEntry.path ?? "",
121
124
  },
122
125
  config: {
123
- stashCount: updatedConfig.stashes?.length ?? 0,
126
+ sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
124
127
  installedKitCount: updatedConfig.installed?.length ?? 0,
125
128
  },
126
129
  index: {
@@ -136,28 +139,102 @@ export async function akmUpdate(input) {
136
139
  const target = input?.target?.trim();
137
140
  const all = input?.all === true;
138
141
  const force = input?.force === true;
139
- const installedEntries = loadConfig().installed ?? [];
142
+ const config = loadConfig();
143
+ const installedEntries = config.installed ?? [];
144
+ // Check if the target refers to a website source — those are syncable via
145
+ // ensureWebsiteMirror and are stored in sources[] not installed[].
146
+ if (target && !all) {
147
+ const stashes = config.sources ?? config.stashes ?? [];
148
+ const isUrl = target.startsWith("http://") || target.startsWith("https://");
149
+ const resolvedPath = !isUrl ? path.resolve(target) : undefined;
150
+ const websiteMatch = stashes.find((s) => {
151
+ if (s.type !== "website")
152
+ return false;
153
+ if (isUrl && s.url === target)
154
+ return true;
155
+ if (s.name === target)
156
+ return true;
157
+ if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
158
+ return true;
159
+ return false;
160
+ });
161
+ if (websiteMatch) {
162
+ // TODO: full incremental re-crawl with delta tracking (#19)
163
+ await ensureWebsiteMirror(websiteMatch, { requireStashDir: true, force: true });
164
+ const index = await akmIndex({ stashDir });
165
+ const updatedConfig = loadConfig();
166
+ return {
167
+ schemaVersion: 1,
168
+ stashDir,
169
+ target,
170
+ all,
171
+ processed: [],
172
+ config: {
173
+ sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
174
+ installedKitCount: updatedConfig.installed?.length ?? 0,
175
+ },
176
+ index: {
177
+ mode: index.mode,
178
+ totalEntries: index.totalEntries,
179
+ directoriesScanned: index.directoriesScanned,
180
+ directoriesSkipped: index.directoriesSkipped,
181
+ },
182
+ };
183
+ }
184
+ }
140
185
  const selectedEntries = selectTargets(installedEntries, target, all);
186
+ const auditConfig = config;
141
187
  const processed = [];
142
188
  for (const entry of selectedEntries) {
143
189
  if (force && shouldCleanupCache(entry)) {
144
190
  cleanupDirectoryBestEffort(entry.cacheDir);
145
191
  }
146
- const installed = await installRegistryRef(entry.ref);
147
- upsertInstalledRegistryEntry(toInstalledEntry(installed));
192
+ const synced = await syncFromRef(entry.ref, { force });
193
+ // Mirror the post-sync audit hook from akmAdd so `akm update` can't
194
+ // silently land malicious content during refresh.
195
+ const registryLabels = deriveRegistryLabels({
196
+ source: synced.source,
197
+ ref: synced.ref,
198
+ artifactUrl: synced.artifactUrl,
199
+ });
200
+ enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
201
+ const audit = auditInstallCandidate({
202
+ rootDir: synced.extractedDir,
203
+ source: synced.source,
204
+ ref: synced.ref,
205
+ registryLabels,
206
+ config: auditConfig,
207
+ });
208
+ if (audit.blocked) {
209
+ throw new Error(formatInstallAuditFailure(synced.ref, audit));
210
+ }
211
+ const installedEntry = {
212
+ id: synced.id,
213
+ source: synced.source,
214
+ ref: synced.ref,
215
+ artifactUrl: synced.artifactUrl,
216
+ resolvedVersion: synced.resolvedVersion,
217
+ resolvedRevision: synced.resolvedRevision,
218
+ stashRoot: synced.contentDir,
219
+ cacheDir: synced.cacheDir,
220
+ installedAt: synced.syncedAt,
221
+ writable: synced.writable ?? entry.writable,
222
+ ...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
223
+ };
224
+ upsertInstalledRegistryEntry(installedEntry);
148
225
  await upsertLockEntry({
149
- id: installed.id,
150
- source: installed.source,
151
- ref: installed.ref,
152
- resolvedVersion: installed.resolvedVersion,
153
- resolvedRevision: installed.resolvedRevision,
154
- integrity: installed.integrity ?? (installed.source === "local" ? "local" : undefined),
226
+ id: synced.id,
227
+ source: synced.source,
228
+ ref: synced.ref,
229
+ resolvedVersion: synced.resolvedVersion,
230
+ resolvedRevision: synced.resolvedRevision,
231
+ integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
155
232
  });
156
- if (entry.cacheDir !== installed.cacheDir && shouldCleanupCache(entry)) {
233
+ if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
157
234
  cleanupDirectoryBestEffort(entry.cacheDir);
158
235
  }
159
- const versionChanged = (entry.resolvedVersion ?? "") !== (installed.resolvedVersion ?? "");
160
- const revisionChanged = (entry.resolvedRevision ?? "") !== (installed.resolvedRevision ?? "");
236
+ const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
237
+ const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
161
238
  processed.push({
162
239
  id: entry.id,
163
240
  source: entry.source,
@@ -167,7 +244,7 @@ export async function akmUpdate(input) {
167
244
  resolvedRevision: entry.resolvedRevision,
168
245
  cacheDir: entry.cacheDir,
169
246
  },
170
- installed: toInstallStatus(installed),
247
+ installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
171
248
  changed: {
172
249
  version: versionChanged,
173
250
  revision: revisionChanged,
@@ -176,7 +253,7 @@ export async function akmUpdate(input) {
176
253
  });
177
254
  }
178
255
  const index = await akmIndex({ stashDir });
179
- const config = loadConfig();
256
+ const finalConfig = loadConfig();
180
257
  return {
181
258
  schemaVersion: 1,
182
259
  stashDir,
@@ -184,8 +261,8 @@ export async function akmUpdate(input) {
184
261
  all,
185
262
  processed,
186
263
  config: {
187
- stashCount: config.stashes?.length ?? 0,
188
- installedKitCount: config.installed?.length ?? 0,
264
+ sourceCount: (finalConfig.sources ?? finalConfig.stashes ?? []).length,
265
+ installedKitCount: finalConfig.installed?.length ?? 0,
189
266
  },
190
267
  index: {
191
268
  mode: index.mode,
@@ -197,19 +274,19 @@ export async function akmUpdate(input) {
197
274
  }
198
275
  function selectTargets(installed, target, all) {
199
276
  if (all && target) {
200
- throw new UsageError("Specify either <target> or --all, not both.");
277
+ throw new UsageError("Specify either <target> or --all, not both.", "MISSING_OR_AMBIGUOUS_TARGET");
201
278
  }
202
279
  if (all)
203
280
  return installed;
204
281
  if (!target) {
205
- throw new UsageError("Either <target> or --all is required.");
282
+ throw new UsageError("Either <target> or --all is required.", "MISSING_OR_AMBIGUOUS_TARGET");
206
283
  }
207
284
  const found = tryResolveInstalledTarget(installed, target);
208
285
  if (found)
209
286
  return [found];
210
287
  // Check if target matches a stash source and give a helpful message
211
288
  const config = loadConfig();
212
- const stashes = config.stashes ?? [];
289
+ const stashes = config.sources ?? config.stashes ?? [];
213
290
  const isUrl = target.startsWith("http://") || target.startsWith("https://");
214
291
  const resolvedPath = !isUrl ? path.resolve(target) : undefined;
215
292
  const stashMatch = stashes.find((s) => {
@@ -222,10 +299,13 @@ function selectTargets(installed, target, all) {
222
299
  return false;
223
300
  });
224
301
  if (stashMatch) {
225
- if (stashMatch.url) {
226
- throw new UsageError(`"${target}" is a remote provider it queries live data and has nothing to update.`);
302
+ if (stashMatch.type === "website") {
303
+ // Website sources should be handled before reaching selectTargets.
304
+ // This path should not be reached; surface a clear message if it is.
305
+ throw new UsageError(`"${target}" is a website source — website caching not yet implemented for --all. ` +
306
+ `Run \`akm update ${target}\` to re-mirror this source individually.`, "TARGET_NOT_UPDATABLE");
227
307
  }
228
- throw new UsageError(`"${target}" is a local directory — it reflects your files in place. To refresh the search index, run: akm index`);
308
+ throw new UsageError(`"${target}" is a local directory — it reflects your files in place. To refresh the search index, run: akm index`, "TARGET_NOT_UPDATABLE");
229
309
  }
230
310
  throw new NotFoundError(`No matching source for target: ${target}`);
231
311
  }
@@ -250,14 +330,6 @@ function tryResolveInstalledTarget(installed, target) {
250
330
  }
251
331
  return undefined;
252
332
  }
253
- function toInstalledEntry(status) {
254
- // KitInstallStatus extends InstalledKitEntry; omit transient install-only fields.
255
- const { extractedDir: _extractedDir, audit: _audit, ...base } = status;
256
- return base;
257
- }
258
- function toInstallStatus(status) {
259
- return { ...status };
260
- }
261
333
  function cleanupDirectoryBestEffort(target) {
262
334
  try {
263
335
  fs.rmSync(target, { recursive: true, force: true });
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
4
+ const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
5
+ /**
6
+ * Directory containing per-version release notes. Resolved relative to
7
+ * `import.meta.dir` so the lookup works whether this module is running
8
+ * from source (`<repo>/src`) or from the published build (`<pkg>/dist`).
9
+ * The `docs/migration/release-notes/` directory is shipped via the
10
+ * `files[]` array in `package.json`.
11
+ */
12
+ function releaseNotesDir() {
13
+ return path.resolve(import.meta.dir, "../../docs/migration/release-notes");
14
+ }
15
+ function loadChangelog() {
16
+ try {
17
+ const changelogPath = path.resolve(import.meta.dir, "../../CHANGELOG.md");
18
+ if (fs.existsSync(changelogPath)) {
19
+ return fs.readFileSync(changelogPath, "utf8");
20
+ }
21
+ }
22
+ catch {
23
+ // fall through to bundled notes
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * Load the bundled migration note for a specific version, if one exists.
29
+ * Returns the file body verbatim (no transformations). Missing files,
30
+ * permission errors, and non-file entries all return `undefined` —
31
+ * callers are responsible for the fallback message.
32
+ */
33
+ function loadReleaseNote(version) {
34
+ if (!isSafeVersionComponent(version))
35
+ return undefined;
36
+ const notePath = path.join(releaseNotesDir(), `${version}.md`);
37
+ try {
38
+ if (!fs.existsSync(notePath))
39
+ return undefined;
40
+ const stat = fs.statSync(notePath);
41
+ if (!stat.isFile())
42
+ return undefined;
43
+ return fs.readFileSync(notePath, "utf8");
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /**
50
+ * Restrict version lookups to strings that are safe as a single path
51
+ * segment. Accepts typical semver forms (`0.6.0`, `0.6.0-rc1`,
52
+ * `0.6.0+build.5`) and rejects anything with slashes, `..`, or control
53
+ * characters that would let a crafted input escape the release-notes
54
+ * directory.
55
+ */
56
+ function isSafeVersionComponent(version) {
57
+ if (!version || version.length > 64)
58
+ return false;
59
+ return /^[A-Za-z0-9._+-]+$/.test(version) && !version.includes("..");
60
+ }
61
+ function normalizeRequestedVersion(input) {
62
+ const value = input.trim();
63
+ if (!value)
64
+ return value;
65
+ if (value.toLowerCase() === "latest")
66
+ return "latest";
67
+ const withoutV = value.replace(/^v/i, "");
68
+ return withoutV;
69
+ }
70
+ function versionCandidates(requested) {
71
+ if (requested === "latest")
72
+ return ["latest"];
73
+ const exact = requested;
74
+ const stable = requested.replace(/[-+].*$/, "");
75
+ return stable === exact ? [exact] : [exact, stable];
76
+ }
77
+ function resolveLatestVersion(changelog) {
78
+ for (const match of changelog.matchAll(/^## \[([^\]]+)\]/gm)) {
79
+ const version = match[1];
80
+ if (version !== "Unreleased")
81
+ return version;
82
+ }
83
+ return undefined;
84
+ }
85
+ function extractChangelogSection(changelog, version) {
86
+ const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|\\Z)`, "m");
87
+ const match = changelog.match(pattern);
88
+ if (!match)
89
+ return undefined;
90
+ return `## [${version}]\n${match[1].trim()}\n`;
91
+ }
92
+ function escapeRegexString(value) {
93
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ }
95
+ function fallbackGuide(version) {
96
+ const bundled = loadReleaseNote(version);
97
+ if (bundled)
98
+ return `${bundled.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
99
+ const available = listBundledReleaseVersions();
100
+ const availableLine = available.length ? `\nAvailable bundled notes: ${available.join(", ")}\n` : "";
101
+ return (`No dedicated migration note is bundled for akm v${version}.\n${availableLine}\n` +
102
+ `See the full changelog: ${CHANGELOG_URL}\n` +
103
+ `Longform migration guide: ${MIGRATION_DOC_URL}\n`);
104
+ }
105
+ export function renderMigrationHelp(versionInput, changelogText = loadChangelog()) {
106
+ const requested = normalizeRequestedVersion(versionInput);
107
+ if (!requested) {
108
+ return `Version is required.\n\nUsage: akm help migrate <version>\n`;
109
+ }
110
+ const resolvedLatest = changelogText ? resolveLatestVersion(changelogText) : undefined;
111
+ const candidates = requested === "latest" && resolvedLatest ? [resolvedLatest] : versionCandidates(requested);
112
+ if (changelogText) {
113
+ for (const candidate of candidates) {
114
+ const section = extractChangelogSection(changelogText, candidate);
115
+ if (section) {
116
+ const bundled = loadReleaseNote(candidate);
117
+ if (!bundled)
118
+ return `${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
119
+ return `${bundled.trim()}\n\nRelease notes\n-------------\n${section.trim()}\n\nFull changelog: ${CHANGELOG_URL}\n`;
120
+ }
121
+ }
122
+ }
123
+ const fallbackVersion = candidates.find((candidate) => candidate !== "latest") ?? requested;
124
+ return fallbackGuide(fallbackVersion);
125
+ }
126
+ /** Test-only helper — list every version with a bundled release note. */
127
+ export function listBundledReleaseVersions() {
128
+ try {
129
+ const dir = releaseNotesDir();
130
+ if (!fs.existsSync(dir))
131
+ return [];
132
+ return fs
133
+ .readdirSync(dir)
134
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
135
+ .map((name) => name.slice(0, -".md".length))
136
+ .sort();
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ }
@@ -1,9 +1,8 @@
1
- import { toErrorMessage } from "./common";
2
- import { DEFAULT_CONFIG, loadConfig } from "./config";
3
- import { resolveProviderFactory } from "./registry-factory";
1
+ import { toErrorMessage } from "../core/common";
2
+ import { DEFAULT_CONFIG, loadConfig } from "../core/config";
3
+ import { resolveProviderFactory } from "../registry/factory";
4
4
  // ── Eagerly import providers to trigger self-registration ───────────────────
5
- import "./providers/static-index";
6
- import "./providers/skills-sh";
5
+ import "../registry/providers/index";
7
6
  // ── Public API ──────────────────────────────────────────────────────────────
8
7
  export async function searchRegistry(query, options) {
9
8
  const trimmed = query.trim();
@@ -25,7 +24,8 @@ export async function searchRegistry(query, options) {
25
24
  // Merge results grouped by provider
26
25
  const allHits = [];
27
26
  const allAssetHits = [];
28
- for (const result of results) {
27
+ for (let i = 0; i < results.length; i++) {
28
+ const result = results[i];
29
29
  if (result.status === "rejected") {
30
30
  warnings.push(toErrorMessage(result.reason));
31
31
  continue;
@@ -33,9 +33,29 @@ export async function searchRegistry(query, options) {
33
33
  const value = result.value;
34
34
  if (!value)
35
35
  continue;
36
- allHits.push(...value.hits);
37
- if (value.assetHits)
38
- allAssetHits.push(...value.assetHits);
36
+ const registryLabel = entries[i].name ? `"${entries[i].name}"` : entries[i].url;
37
+ let dropped = 0;
38
+ for (const hit of value.hits) {
39
+ if (isCompleteHit(hit)) {
40
+ allHits.push(hit);
41
+ }
42
+ else {
43
+ dropped++;
44
+ }
45
+ }
46
+ if (value.assetHits) {
47
+ for (const hit of value.assetHits) {
48
+ if (isCompleteAssetHit(hit)) {
49
+ allAssetHits.push(hit);
50
+ }
51
+ else {
52
+ dropped++;
53
+ }
54
+ }
55
+ }
56
+ if (dropped > 0) {
57
+ warnings.push(`Registry ${registryLabel} returned ${dropped} incomplete hit(s); dropped from response.`);
58
+ }
39
59
  if (value.warnings)
40
60
  warnings.push(...value.warnings);
41
61
  }
@@ -97,3 +117,42 @@ function clampLimit(limit) {
97
117
  return 20;
98
118
  return Math.min(100, Math.max(1, Math.trunc(limit)));
99
119
  }
120
+ // A complete hit must have the fields downstream consumers (CLI rendering,
121
+ // `akm add`) rely on. Providers that return partial records would otherwise
122
+ // surface as `{}` in the JSON output.
123
+ function isCompleteHit(hit) {
124
+ if (!hit || typeof hit !== "object")
125
+ return false;
126
+ return (typeof hit.source === "string" &&
127
+ typeof hit.id === "string" &&
128
+ hit.id.length > 0 &&
129
+ typeof hit.title === "string" &&
130
+ hit.title.length > 0 &&
131
+ typeof hit.ref === "string" &&
132
+ hit.ref.length > 0 &&
133
+ typeof hit.installRef === "string" &&
134
+ hit.installRef.length > 0);
135
+ }
136
+ function isCompleteAssetHit(hit) {
137
+ if (!hit || typeof hit !== "object")
138
+ return false;
139
+ if (hit.type !== "registry-asset" ||
140
+ typeof hit.assetType !== "string" ||
141
+ hit.assetType.length === 0 ||
142
+ typeof hit.assetName !== "string" ||
143
+ hit.assetName.length === 0 ||
144
+ typeof hit.action !== "string") {
145
+ return false;
146
+ }
147
+ // `stash` is required by the consumer (output shaping + asset-action display);
148
+ // rejecting incomplete stashes here keeps malformed objects out of the JSON
149
+ // output. Flagged in PR #168 review (#9).
150
+ const stash = hit.stash;
151
+ if (!stash || typeof stash !== "object")
152
+ return false;
153
+ if (typeof stash.id !== "string" || stash.id.length === 0)
154
+ return false;
155
+ if (typeof stash.name !== "string" || stash.name.length === 0)
156
+ return false;
157
+ return true;
158
+ }