akm-cli 0.0.16 → 0.0.18

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.
@@ -23,6 +23,8 @@ export function parseConfigValue(key, value) {
23
23
  return { embedding: parseEmbeddingConnectionValue(value) };
24
24
  case "llm":
25
25
  return { llm: parseLlmConnectionValue(value) };
26
+ case "registries":
27
+ return { registries: parseRegistriesValue(value) };
26
28
  case "output.format":
27
29
  return { output: { format: parseOutputFormat(value) } };
28
30
  case "output.detail":
@@ -43,6 +45,8 @@ export function getConfigValue(config, key) {
43
45
  return config.embedding ?? null;
44
46
  case "llm":
45
47
  return config.llm ?? null;
48
+ case "registries":
49
+ return config.registries ?? DEFAULT_CONFIG.registries ?? [];
46
50
  case "output.format":
47
51
  return config.output?.format ?? null;
48
52
  case "output.detail":
@@ -58,6 +62,7 @@ export function setConfigValue(config, key, rawValue) {
58
62
  case "searchPaths":
59
63
  case "embedding":
60
64
  case "llm":
65
+ case "registries":
61
66
  case "output.format":
62
67
  case "output.detail":
63
68
  return mergeConfigValue(config, parseConfigValue(key, rawValue));
@@ -73,6 +78,8 @@ export function unsetConfigValue(config, key) {
73
78
  return { ...config, embedding: undefined };
74
79
  case "llm":
75
80
  return { ...config, llm: undefined };
81
+ case "registries":
82
+ return { ...config, registries: undefined };
76
83
  case "output.format":
77
84
  return { ...config, output: mergeOutputConfig(config.output, { format: undefined }) };
78
85
  case "output.detail":
@@ -89,6 +96,7 @@ export function listConfig(config) {
89
96
  stashDir: config.stashDir ?? null,
90
97
  embedding: config.embedding ?? null,
91
98
  llm: config.llm ?? null,
99
+ registries: config.registries ?? DEFAULT_CONFIG.registries ?? [],
92
100
  };
93
101
  }
94
102
  function mergeConfigValue(config, partial) {
@@ -115,6 +123,41 @@ function parseOutputDetail(value) {
115
123
  return value;
116
124
  throw new UsageError(`Invalid value for output.detail: expected one of brief|normal|full`);
117
125
  }
126
+ function parseRegistriesValue(value) {
127
+ if (value === "null" || value === "")
128
+ return undefined;
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(value);
132
+ }
133
+ catch {
134
+ throw new UsageError(`Invalid value for registries: expected JSON array of {url, name?, enabled?, provider?, options?} objects` +
135
+ ` (e.g. '[{"url":"https://example.com/index.json","name":"my-registry"}]')`);
136
+ }
137
+ if (!Array.isArray(parsed)) {
138
+ throw new UsageError(`Invalid value for registries: expected a JSON array`);
139
+ }
140
+ return parsed.map((entry, i) => {
141
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
142
+ throw new UsageError(`Invalid value for registries[${i}]: expected an object with a "url" field`);
143
+ }
144
+ const obj = entry;
145
+ if (typeof obj.url !== "string" || !obj.url) {
146
+ throw new UsageError(`Invalid value for registries[${i}]: "url" is required`);
147
+ }
148
+ const result = { url: obj.url };
149
+ if (typeof obj.name === "string" && obj.name)
150
+ result.name = obj.name;
151
+ if (typeof obj.enabled === "boolean")
152
+ result.enabled = obj.enabled;
153
+ if (typeof obj.provider === "string" && obj.provider)
154
+ result.provider = obj.provider;
155
+ if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
156
+ result.options = obj.options;
157
+ }
158
+ return result;
159
+ });
160
+ }
118
161
  function parseEmbeddingConnectionValue(value) {
119
162
  if (value === "null" || value === "")
120
163
  return undefined;
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "
5
5
  export const DEFAULT_CONFIG = {
6
6
  semanticSearch: true,
7
7
  searchPaths: [],
8
+ registries: [{ url: "https://raw.githubusercontent.com/itlackey/akm-registry/main/index.json", name: "official" }],
8
9
  output: {
9
10
  format: "json",
10
11
  detail: "brief",
@@ -113,27 +114,18 @@ function pickKnownKeys(raw) {
113
114
  if (Array.isArray(raw.searchPaths)) {
114
115
  config.searchPaths = raw.searchPaths.filter((d) => typeof d === "string");
115
116
  }
116
- // Backward compat: merge legacy mountedStashDirs into searchPaths
117
- if (Array.isArray(raw.mountedStashDirs)) {
118
- const legacy = raw.mountedStashDirs.filter((d) => typeof d === "string");
119
- const existing = new Set(config.searchPaths);
120
- for (const d of legacy) {
121
- if (!existing.has(d))
122
- config.searchPaths.push(d);
123
- }
124
- }
125
117
  const embedding = parseEmbeddingConfig(raw.embedding);
126
118
  if (embedding)
127
119
  config.embedding = embedding;
128
120
  const llm = parseLlmConfig(raw.llm);
129
121
  if (llm)
130
122
  config.llm = llm;
131
- const registry = parseRegistryConfig(raw.registry);
132
- if (registry)
133
- config.registry = registry;
134
- if (Array.isArray(raw.registryUrls)) {
135
- config.registryUrls = raw.registryUrls.filter((u) => typeof u === "string" && u.startsWith("http"));
136
- }
123
+ const installed = parseInstalledEntries(raw.installed);
124
+ if (installed)
125
+ config.installed = installed;
126
+ const registries = parseRegistriesConfig(raw.registries);
127
+ if (registries)
128
+ config.registries = registries;
137
129
  const output = parseOutputConfig(raw.output);
138
130
  if (output)
139
131
  config.output = output;
@@ -273,23 +265,20 @@ function parseLlmConfig(value) {
273
265
  }
274
266
  return result;
275
267
  }
276
- function parseRegistryConfig(value) {
277
- if (typeof value !== "object" || value === null || Array.isArray(value))
268
+ function parseInstalledEntries(value) {
269
+ if (!Array.isArray(value))
278
270
  return undefined;
279
- const obj = value;
280
- if (!Array.isArray(obj.installed))
281
- return undefined;
282
- const installed = obj.installed
283
- .map((entry) => parseRegistryInstalledEntry(entry))
271
+ const entries = value
272
+ .map((entry) => parseInstalledKitEntry(entry))
284
273
  .filter((entry) => entry !== undefined);
285
- return { installed };
274
+ return entries.length > 0 ? entries : undefined;
286
275
  }
287
- function parseRegistryInstalledEntry(value) {
276
+ function parseInstalledKitEntry(value) {
288
277
  if (typeof value !== "object" || value === null || Array.isArray(value))
289
278
  return undefined;
290
279
  const obj = value;
291
280
  const id = asNonEmptyString(obj.id);
292
- const source = asRegistrySource(obj.source);
281
+ const source = asKitSource(obj.source);
293
282
  const ref = asNonEmptyString(obj.ref);
294
283
  const artifactUrl = asNonEmptyString(obj.artifactUrl);
295
284
  const stashRoot = asNonEmptyString(obj.stashRoot);
@@ -317,8 +306,39 @@ function parseRegistryInstalledEntry(value) {
317
306
  function asNonEmptyString(value) {
318
307
  return typeof value === "string" && value ? value : undefined;
319
308
  }
320
- function asRegistrySource(value) {
309
+ function asKitSource(value) {
321
310
  if (value === "npm" || value === "github" || value === "git" || value === "local")
322
311
  return value;
323
312
  return undefined;
324
313
  }
314
+ function parseRegistriesConfig(value) {
315
+ if (!Array.isArray(value))
316
+ return undefined;
317
+ const entries = value
318
+ .map((entry) => parseRegistryConfigEntry(entry))
319
+ .filter((entry) => entry !== undefined);
320
+ // Return the array even if empty — an explicit empty array means "no registries"
321
+ // which overrides the default. Only return undefined if the field was not an array.
322
+ return entries;
323
+ }
324
+ function parseRegistryConfigEntry(value) {
325
+ if (typeof value !== "object" || value === null || Array.isArray(value))
326
+ return undefined;
327
+ const obj = value;
328
+ const url = asNonEmptyString(obj.url);
329
+ if (!url || !url.startsWith("http"))
330
+ return undefined;
331
+ const entry = { url };
332
+ const name = asNonEmptyString(obj.name);
333
+ if (name)
334
+ entry.name = name;
335
+ if (typeof obj.enabled === "boolean")
336
+ entry.enabled = obj.enabled;
337
+ const provider = asNonEmptyString(obj.provider);
338
+ if (provider)
339
+ entry.provider = provider;
340
+ if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
341
+ entry.options = obj.options;
342
+ }
343
+ return entry;
344
+ }
@@ -22,8 +22,8 @@ export function buildFileContext(stashRoot, absPath) {
22
22
  const parentDirAbs = path.dirname(absPath);
23
23
  const parentDir = path.basename(parentDirAbs);
24
24
  // Compute ancestor directory segments from the POSIX relPath's directory portion.
25
- // For "tools/azure/deploy/run.sh" the dir portion is "tools/azure/deploy"
26
- // which splits into ["tools", "azure", "deploy"].
25
+ // For "scripts/azure/deploy/run.sh" the dir portion is "scripts/azure/deploy"
26
+ // which splits into ["scripts", "azure", "deploy"].
27
27
  const relDir = toPosix(path.dirname(relPath));
28
28
  const ancestorDirs = relDir === "." ? [] : relDir.split("/").filter((seg) => seg.length > 0);
29
29
  // Lazy caches
@@ -69,41 +69,16 @@ const matchers = [];
69
69
  /** Renderer lookup by name. */
70
70
  const renderers = new Map();
71
71
  let builtinsInitialized = false;
72
- /** Pluggable initializer set via `setBuiltinRegistrar`. */
73
- let builtinRegistrar = null;
74
- /**
75
- * Set the function that registers built-in matchers and renderers.
76
- *
77
- * This breaks the static import cycle: `file-context.ts` does not need to
78
- * import `matchers.ts` or `renderers.ts` at the top level. Instead, a
79
- * one-time call from outside (e.g. the test file or CLI entry) provides the
80
- * registration callback.
81
- *
82
- * If no registrar is set by the time `ensureBuiltinsRegistered` runs, it
83
- * falls back to a dynamic `require()` for backward compatibility.
84
- */
85
- export function setBuiltinRegistrar(fn) {
86
- builtinRegistrar = fn;
87
- }
88
72
  /**
89
73
  * Ensure that built-in matchers and renderers are registered.
90
74
  * Called lazily on first use of runMatchers/getRenderer.
91
- *
92
- * Uses the registrar set via `setBuiltinRegistrar`, or falls back to
93
- * a direct import of the registration modules. The dynamic import
94
- * avoids a static circular dependency between file-context, renderers,
95
- * and asset-spec.
96
75
  */
97
76
  function ensureBuiltinsRegistered() {
98
77
  if (builtinsInitialized)
99
78
  return;
100
79
  builtinsInitialized = true;
101
- if (builtinRegistrar) {
102
- builtinRegistrar();
103
- return;
104
- }
105
80
  // Lazy inline require avoids a top-level static import cycle.
106
- // These are only evaluated once and only when no explicit registrar was set.
81
+ // These are only evaluated once.
107
82
  // eslint-disable-next-line @typescript-eslint/no-require-imports
108
83
  const { registerBuiltinMatchers } = require("./matchers");
109
84
  // eslint-disable-next-line @typescript-eslint/no-require-imports
package/dist/indexer.js CHANGED
@@ -1,9 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { deriveCanonicalAssetName, TYPE_DIRS } from "./asset-spec";
4
3
  import { resolveStashDir } from "./common";
5
4
  import { closeDatabase, DB_VERSION, deleteEntriesByDir, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, warnIfVecMissing, } from "./db";
6
- import { buildFileContext } from "./file-context";
7
5
  import { generateMetadataFlat, loadStashFile } from "./metadata";
8
6
  import { getDbPath } from "./paths";
9
7
  import { walkStashFlat } from "./walker";
@@ -128,10 +126,6 @@ function indexEntries(db, allStashDirs, _stashDir, isIncremental, builtAtMs) {
128
126
  // Try loading existing .stash.json (user metadata overrides)
129
127
  let stash = loadStashFile(dirPath);
130
128
  if (stash) {
131
- // Re-derive canonical names for generated entries using the matcher system.
132
- // This fixes legacy .stash.json files that may have stale names (e.g. "SKILL"
133
- // instead of the directory-based canonical name "code-review").
134
- fixGeneratedEntryNames(stash, files, currentStashDir);
135
129
  // Check for files on disk that aren't covered by existing .stash.json entries.
136
130
  const coveredFiles = new Set(stash.entries.map((e) => (e.filename ? path.basename(e.filename) : "")).filter((e) => !!e));
137
131
  const uncoveredFiles = files.filter((f) => !coveredFiles.has(path.basename(f)));
@@ -226,30 +220,6 @@ function attachFileSize(entry, entryPath) {
226
220
  }
227
221
  }
228
222
  /** Set of all known type directory names */
229
- const TYPE_DIR_NAMES = new Set(Object.values(TYPE_DIRS));
230
- /**
231
- * Re-derive canonical names for generated skill .stash.json entries.
232
- *
233
- * Legacy .stash.json files for skills may have name "SKILL" (derived from the
234
- * filename SKILL.md) instead of the canonical directory name (e.g. "code-review").
235
- * This fixes those names using the matcher system.
236
- */
237
- function fixGeneratedEntryNames(stash, files, stashRoot) {
238
- const fileByBaseName = new Map(files.map((f) => [path.basename(f), f]));
239
- for (const entry of stash.entries) {
240
- if (entry.quality !== "generated" || entry.type !== "skill")
241
- continue;
242
- const filePath = entry.filename ? fileByBaseName.get(path.basename(entry.filename)) : undefined;
243
- if (!filePath)
244
- continue;
245
- const firstAncestor = buildFileContext(stashRoot, filePath).ancestorDirs[0];
246
- const effectiveRoot = firstAncestor && TYPE_DIR_NAMES.has(firstAncestor) ? path.join(stashRoot, firstAncestor) : stashRoot;
247
- const canonicalName = deriveCanonicalAssetName("skill", effectiveRoot, filePath);
248
- if (canonicalName && canonicalName !== entry.name) {
249
- entry.name = canonicalName;
250
- }
251
- }
252
- }
253
223
  function isDirStale(dirPath, currentFiles, previousEntries, builtAtMs) {
254
224
  // Check if file set changed (additions or deletions)
255
225
  const prevFileNames = new Set(previousEntries.map((ie) => ie.entry.filename).filter((e) => !!e));
package/dist/llm.js CHANGED
@@ -22,7 +22,7 @@ async function chatCompletion(config, messages) {
22
22
  return json.choices?.[0]?.message?.content?.trim() ?? "";
23
23
  }
24
24
  // ── Metadata Enhancement ────────────────────────────────────────────────────
25
- const SYSTEM_PROMPT = `You are a metadata generator for a developer tool registry. Given a tool/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`;
25
+ const SYSTEM_PROMPT = `You are a metadata generator for a developer asset registry. Given a script/skill/command/agent entry, generate improved metadata. Respond with ONLY valid JSON, no markdown fencing.`;
26
26
  /**
27
27
  * Use an LLM to enhance a stash entry's metadata: improve description,
28
28
  * generate searchHints, and suggest tags.
package/dist/matchers.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * at specificity 5 when no signals are found. Command signals (`agent`
17
17
  * frontmatter, `$ARGUMENTS`/`$1`-`$3` placeholders) return 18.
18
18
  */
19
- import { SCRIPT_EXTENSIONS_BROAD } from "./asset-spec";
19
+ import { SCRIPT_EXTENSIONS } from "./asset-spec";
20
20
  import { registerMatcher } from "./file-context";
21
21
  // ── extensionMatcher (specificity: 3) ────────────────────────────────────────
22
22
  /**
@@ -36,7 +36,7 @@ export function extensionMatcher(ctx) {
36
36
  return { type: "skill", specificity: 25, renderer: "skill-md" };
37
37
  }
38
38
  // Known script extensions (excluding .md, handled by smartMdMatcher)
39
- if (SCRIPT_EXTENSIONS_BROAD.has(ctx.ext)) {
39
+ if (SCRIPT_EXTENSIONS.has(ctx.ext)) {
40
40
  return { type: "script", specificity: 3, renderer: "script-source" };
41
41
  }
42
42
  return null;
@@ -45,19 +45,13 @@ export function extensionMatcher(ctx) {
45
45
  /**
46
46
  * Directory-based matcher that boosts specificity when the first ancestor
47
47
  * directory segment from the stash root matches a known type name.
48
- *
49
- * Accepts ALL known script extensions in both `tools/` and `scripts/`
50
- * directories -- the distinction is purely organizational.
51
48
  */
52
49
  export function directoryMatcher(ctx) {
53
50
  const topDir = ctx.ancestorDirs[0];
54
51
  if (!topDir)
55
52
  return null;
56
53
  const ext = ctx.ext;
57
- if (topDir === "tools" && SCRIPT_EXTENSIONS_BROAD.has(ext)) {
58
- return { type: "tool", specificity: 10, renderer: "script-source" };
59
- }
60
- if (topDir === "scripts" && SCRIPT_EXTENSIONS_BROAD.has(ext)) {
54
+ if (topDir === "scripts" && SCRIPT_EXTENSIONS.has(ext)) {
61
55
  return { type: "script", specificity: 10, renderer: "script-source" };
62
56
  }
63
57
  if (topDir === "skills" && ctx.fileName === "SKILL.md") {
@@ -80,15 +74,10 @@ export function directoryMatcher(ctx) {
80
74
  * the ancestor-based directory matcher because the file might be nested
81
75
  * several levels deep, yet its immediate parent can still carry strong
82
76
  * naming conventions (e.g. `my-project/agents/planning.md`).
83
- *
84
- * Accepts ALL known script extensions in both `tools/` and `scripts/`.
85
77
  */
86
78
  export function parentDirHintMatcher(ctx) {
87
79
  const { parentDir, ext, fileName } = ctx;
88
- if (parentDir === "tools" && SCRIPT_EXTENSIONS_BROAD.has(ext)) {
89
- return { type: "tool", specificity: 15, renderer: "script-source" };
90
- }
91
- if (parentDir === "scripts" && SCRIPT_EXTENSIONS_BROAD.has(ext)) {
80
+ if (parentDir === "scripts" && SCRIPT_EXTENSIONS.has(ext)) {
92
81
  return { type: "script", specificity: 15, renderer: "script-source" };
93
82
  }
94
83
  if (parentDir === "skills" && fileName === "SKILL.md") {
package/dist/metadata.js CHANGED
@@ -176,7 +176,7 @@ export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath)
176
176
  entry.confidence = 0.9;
177
177
  }
178
178
  }
179
- // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for tools/scripts)
179
+ // Priority 3: Type-specific metadata extraction (e.g. TOC for knowledge, comments for scripts)
180
180
  const fileCtx = buildFileContext(typeRoot, file);
181
181
  const match = runMatchers(fileCtx);
182
182
  if (match) {
@@ -204,7 +204,7 @@ export function generateMetadata(dirPath, assetType, files, typeRoot = dirPath)
204
204
  }
205
205
  return { entries };
206
206
  }
207
- /** Set of all known type directory names (e.g. "tools", "skills", "scripts") */
207
+ /** Set of all known type directory names (e.g. "scripts", "skills", "agents") */
208
208
  const TYPE_DIR_NAMES = new Set(Object.values(TYPE_DIRS));
209
209
  /**
210
210
  * Generate metadata for files using the matcher system instead of a fixed asset type.
@@ -226,7 +226,7 @@ export function generateMetadataFlat(stashRoot, files) {
226
226
  continue;
227
227
  // If the file lives under a known type directory, use that as the root
228
228
  // for canonical naming so names don't include the type prefix.
229
- // e.g. tools/deploy.sh → "deploy.sh" not "tools/deploy.sh"
229
+ // e.g. scripts/deploy.sh → "deploy.sh" not "scripts/deploy.sh"
230
230
  const firstAncestor = ctx.ancestorDirs[0];
231
231
  const effectiveRoot = firstAncestor && TYPE_DIR_NAMES.has(firstAncestor) ? path.join(stashRoot, firstAncestor) : stashRoot;
232
232
  const ext = path.extname(file).toLowerCase();
@@ -0,0 +1,8 @@
1
+ // ── Factory map ─────────────────────────────────────────────────────────────
2
+ const providers = new Map();
3
+ export function registerProvider(type, factory) {
4
+ providers.set(type, factory);
5
+ }
6
+ export function resolveProviderFactory(type) {
7
+ return providers.get(type) ?? null;
8
+ }
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fetchWithRetry } from "../common";
4
+ import { getRegistryIndexCacheDir } from "../paths";
5
+ import { registerProvider } from "../provider-registry";
6
+ // ── Constants ───────────────────────────────────────────────────────────────
7
+ /** Per-query cache TTL in milliseconds (15 minutes). */
8
+ const QUERY_CACHE_TTL_MS = 15 * 60 * 1000;
9
+ /** Maximum age before query cache is considered stale but still usable (1 day). */
10
+ const QUERY_CACHE_STALE_MS = 24 * 60 * 60 * 1000;
11
+ // ── Provider class ──────────────────────────────────────────────────────────
12
+ class SkillsShProvider {
13
+ type = "skills-sh";
14
+ config;
15
+ constructor(config) {
16
+ this.config = config;
17
+ }
18
+ async search(options) {
19
+ try {
20
+ const entries = await this.fetchSkills(options.query, options.limit);
21
+ const limited = entries.slice(0, options.limit);
22
+ const hits = this.mapToHits(limited);
23
+ let assetHits;
24
+ if (options.includeAssets) {
25
+ assetHits = this.mapToAssetHits(limited);
26
+ }
27
+ return { hits, assetHits };
28
+ }
29
+ catch (err) {
30
+ const label = this.config.name ?? "skills.sh";
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return { hits: [], warnings: [`Registry ${label}: ${message}`] };
33
+ }
34
+ }
35
+ async fetchSkills(query, limit) {
36
+ // Check per-query cache first
37
+ const cachePath = this.queryCachePath(query, limit);
38
+ const cached = this.readQueryCache(cachePath);
39
+ if (cached && !isExpired(cached.mtime, QUERY_CACHE_TTL_MS)) {
40
+ return cached.entries;
41
+ }
42
+ // Fetch from API
43
+ const baseUrl = this.config.url.replace(/\/+$/, "");
44
+ const url = `${baseUrl}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
45
+ try {
46
+ const response = await fetchWithRetry(url, undefined, { timeout: 10_000, retries: 1 });
47
+ if (!response.ok) {
48
+ throw new Error(`HTTP ${response.status}`);
49
+ }
50
+ const data = (await response.json());
51
+ const entries = parseSkillsResponse(data);
52
+ this.writeQueryCache(cachePath, entries);
53
+ return entries;
54
+ }
55
+ catch (err) {
56
+ // Fall back to stale cache if available
57
+ if (cached && !isExpired(cached.mtime, QUERY_CACHE_STALE_MS)) {
58
+ return cached.entries;
59
+ }
60
+ throw err;
61
+ }
62
+ }
63
+ mapToHits(entries) {
64
+ if (entries.length === 0)
65
+ return [];
66
+ // Assign decreasing synthetic scores for merge compatibility
67
+ const maxInstalls = Math.max(...entries.map((e) => e.installs), 1);
68
+ const registryName = this.config.name ?? "skills.sh";
69
+ const baseUrl = this.config.url.replace(/\/+$/, "");
70
+ return entries.map((entry) => {
71
+ const owner = entry.source.split("/")[0] ?? "";
72
+ const score = Math.round((entry.installs / maxInstalls) * 1000) / 1000;
73
+ return {
74
+ source: "github",
75
+ id: `skills-sh:${entry.id}`,
76
+ title: entry.name,
77
+ ref: entry.source,
78
+ homepage: `${baseUrl}/${entry.id}`,
79
+ score,
80
+ metadata: {
81
+ installs: String(entry.installs),
82
+ ...(owner ? { author: owner } : {}),
83
+ },
84
+ registryName,
85
+ };
86
+ });
87
+ }
88
+ mapToAssetHits(entries) {
89
+ if (entries.length === 0)
90
+ return undefined;
91
+ const registryName = this.config.name ?? "skills.sh";
92
+ const maxInstalls = Math.max(...entries.map((e) => e.installs), 1);
93
+ const hits = entries.map((entry) => ({
94
+ type: "registry-asset",
95
+ assetType: "skill",
96
+ assetName: entry.name,
97
+ kit: { id: `skills-sh:${entry.id}`, name: entry.name },
98
+ registryName,
99
+ action: `akm add ${entry.source}`,
100
+ score: Math.round((entry.installs / maxInstalls) * 1000) / 1000,
101
+ }));
102
+ return hits.length > 0 ? hits : undefined;
103
+ }
104
+ // ── Per-query cache ─────────────────────────────────────────────────────
105
+ queryCachePath(query, limit) {
106
+ const cacheDir = getRegistryIndexCacheDir();
107
+ const hasher = new Bun.CryptoHasher("md5");
108
+ hasher.update(this.config.url);
109
+ hasher.update("\0");
110
+ hasher.update(query.trim().toLowerCase());
111
+ hasher.update("\0");
112
+ hasher.update(String(limit));
113
+ const hash = hasher.digest("hex");
114
+ return path.join(cacheDir, `skills-sh-search-${hash}.json`);
115
+ }
116
+ readQueryCache(cachePath) {
117
+ try {
118
+ const stat = fs.statSync(cachePath);
119
+ const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
120
+ if (!Array.isArray(raw))
121
+ return null;
122
+ const entries = raw.filter(isValidSkillsEntry);
123
+ return { entries, mtime: stat.mtimeMs };
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ writeQueryCache(cachePath, entries) {
130
+ try {
131
+ const dir = path.dirname(cachePath);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+ const tmpPath = `${cachePath}.tmp.${process.pid}`;
134
+ fs.writeFileSync(tmpPath, JSON.stringify(entries), "utf8");
135
+ fs.renameSync(tmpPath, cachePath);
136
+ }
137
+ catch {
138
+ // Best-effort caching
139
+ }
140
+ }
141
+ }
142
+ // ── Self-register ───────────────────────────────────────────────────────────
143
+ registerProvider("skills-sh", (config) => new SkillsShProvider(config));
144
+ // ── Response parsing ────────────────────────────────────────────────────────
145
+ function parseSkillsResponse(data) {
146
+ if (typeof data !== "object" || data === null || Array.isArray(data))
147
+ return [];
148
+ const obj = data;
149
+ if (!Array.isArray(obj.skills))
150
+ return [];
151
+ return obj.skills.filter(isValidSkillsEntry);
152
+ }
153
+ function isValidSkillsEntry(entry) {
154
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
155
+ return false;
156
+ const obj = entry;
157
+ return (typeof obj.id === "string" &&
158
+ typeof obj.name === "string" &&
159
+ typeof obj.installs === "number" &&
160
+ typeof obj.source === "string");
161
+ }
162
+ // ── Utilities ───────────────────────────────────────────────────────────────
163
+ function isExpired(mtimeMs, ttlMs) {
164
+ return Date.now() - mtimeMs > ttlMs;
165
+ }