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.
@@ -0,0 +1,340 @@
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
+ /** Cache TTL in milliseconds (1 hour). */
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ /** Maximum age before cache is considered stale but still usable as fallback (7 days). */
10
+ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
11
+ // ── Provider class ──────────────────────────────────────────────────────────
12
+ class StaticIndexProvider {
13
+ type = "static-index";
14
+ config;
15
+ constructor(config) {
16
+ this.config = config;
17
+ }
18
+ async search(options) {
19
+ const warnings = [];
20
+ const allKits = [];
21
+ try {
22
+ const index = await loadIndex(this.config);
23
+ if (index) {
24
+ const regName = this.config.name;
25
+ for (const kit of index.kits) {
26
+ allKits.push({ kit, registryName: regName });
27
+ }
28
+ }
29
+ }
30
+ catch (err) {
31
+ const label = this.config.name ? `${this.config.name} (${this.config.url})` : this.config.url;
32
+ warnings.push(`Registry ${label}: ${toErrorMessage(err)}`);
33
+ }
34
+ const hits = scoreKits(allKits, options.query, options.limit);
35
+ let assetHits;
36
+ if (options.includeAssets) {
37
+ const scored = scoreAssets(allKits, options.query, options.limit);
38
+ if (scored.length > 0)
39
+ assetHits = scored;
40
+ }
41
+ return { hits, assetHits, warnings: warnings.length > 0 ? warnings : undefined };
42
+ }
43
+ }
44
+ // ── Self-register ───────────────────────────────────────────────────────────
45
+ registerProvider("static-index", (config) => new StaticIndexProvider(config));
46
+ // ── Index loading with cache ────────────────────────────────────────────────
47
+ async function loadIndex(entry) {
48
+ const cachePath = indexCachePath(entry.url);
49
+ const cached = readCachedIndex(cachePath);
50
+ // Fresh cache: return immediately
51
+ if (cached && !isCacheExpired(cached.mtime)) {
52
+ return cached.index;
53
+ }
54
+ // Try to fetch fresh index
55
+ try {
56
+ const response = await fetchWithRetry(entry.url, undefined, { timeout: 10_000 });
57
+ if (!response.ok) {
58
+ throw new Error(`HTTP ${response.status}`);
59
+ }
60
+ const data = (await response.json());
61
+ const index = parseRegistryIndex(data);
62
+ if (index) {
63
+ writeCachedIndex(cachePath, index);
64
+ return index;
65
+ }
66
+ throw new Error("Invalid registry index format");
67
+ }
68
+ catch (err) {
69
+ // Fetch failed — use stale cache if available
70
+ if (cached && !isCacheStale(cached.mtime)) {
71
+ return cached.index;
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+ // ── Cache helpers (exported for reuse by other providers) ───────────────────
77
+ export function indexCachePath(url) {
78
+ const indexDir = getRegistryIndexCacheDir();
79
+ // Deterministic filename from URL
80
+ const slug = url
81
+ .replace(/[^a-zA-Z0-9]+/g, "-")
82
+ .replace(/^-+|-+$/g, "")
83
+ .slice(0, 120);
84
+ return path.join(indexDir, `${slug}.json`);
85
+ }
86
+ export function readCachedIndex(cachePath) {
87
+ try {
88
+ const stat = fs.statSync(cachePath);
89
+ const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
90
+ const index = parseRegistryIndex(raw);
91
+ if (!index)
92
+ return null;
93
+ return { index, mtime: stat.mtimeMs };
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function writeCachedIndex(cachePath, index) {
100
+ try {
101
+ const dir = path.dirname(cachePath);
102
+ fs.mkdirSync(dir, { recursive: true });
103
+ const tmpPath = `${cachePath}.tmp.${process.pid}`;
104
+ fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8");
105
+ fs.renameSync(tmpPath, cachePath);
106
+ }
107
+ catch {
108
+ // Best-effort caching — don't fail the search if we can't write
109
+ }
110
+ }
111
+ export function isCacheExpired(mtimeMs) {
112
+ return Date.now() - mtimeMs > CACHE_TTL_MS;
113
+ }
114
+ export function isCacheStale(mtimeMs) {
115
+ return Date.now() - mtimeMs > CACHE_STALE_MS;
116
+ }
117
+ // ── Index parsing (exported for reuse) ──────────────────────────────────────
118
+ export function parseRegistryIndex(data) {
119
+ if (typeof data !== "object" || data === null || Array.isArray(data))
120
+ return null;
121
+ const obj = data;
122
+ if (typeof obj.version !== "number" || (obj.version !== 1 && obj.version !== 2))
123
+ return null;
124
+ if (typeof obj.updatedAt !== "string")
125
+ return null;
126
+ if (!Array.isArray(obj.kits))
127
+ return null;
128
+ const kits = obj.kits.flatMap((raw) => {
129
+ const kit = parseKitEntry(raw);
130
+ return kit ? [kit] : [];
131
+ });
132
+ return { version: obj.version, updatedAt: obj.updatedAt, kits };
133
+ }
134
+ // ── Kit entry parsing ───────────────────────────────────────────────────────
135
+ function parseKitEntry(raw) {
136
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
137
+ return null;
138
+ const obj = raw;
139
+ const id = asString(obj.id);
140
+ const name = asString(obj.name);
141
+ const ref = asString(obj.ref);
142
+ const source = asSource(obj.source);
143
+ if (!id || !name || !ref || !source)
144
+ return null;
145
+ return {
146
+ id,
147
+ name,
148
+ ref,
149
+ source,
150
+ description: asString(obj.description),
151
+ homepage: asString(obj.homepage),
152
+ tags: asStringArray(obj.tags),
153
+ assetTypes: asStringArray(obj.assetTypes),
154
+ assets: parseAssets(obj.assets),
155
+ author: asString(obj.author),
156
+ license: asString(obj.license),
157
+ latestVersion: asString(obj.latestVersion),
158
+ curated: obj.curated === true ? true : undefined,
159
+ };
160
+ }
161
+ // ── Scoring ─────────────────────────────────────────────────────────────────
162
+ function scoreKits(kits, query, limit) {
163
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
164
+ const scored = [];
165
+ for (const { kit, registryName } of kits) {
166
+ const score = scoreKit(kit, tokens);
167
+ if (score > 0) {
168
+ scored.push({ kit, registryName, score });
169
+ }
170
+ }
171
+ scored.sort((a, b) => b.score - a.score);
172
+ return scored.slice(0, limit).map(({ kit, registryName, score }) => toSearchHit(kit, score, registryName));
173
+ }
174
+ function scoreKit(kit, tokens) {
175
+ let score = 0;
176
+ const nameLower = kit.name.toLowerCase();
177
+ const descLower = (kit.description ?? "").toLowerCase();
178
+ const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase());
179
+ for (const token of tokens) {
180
+ // Exact name match is strongest signal
181
+ if (nameLower === token) {
182
+ score += 1.0;
183
+ }
184
+ else if (nameLower.includes(token)) {
185
+ score += 0.6;
186
+ }
187
+ // Tag matches are high-signal (curated keywords)
188
+ if (tagsLower.some((tag) => tag === token)) {
189
+ score += 0.5;
190
+ }
191
+ else if (tagsLower.some((tag) => tag.includes(token))) {
192
+ score += 0.25;
193
+ }
194
+ // Description substring
195
+ if (descLower.includes(token)) {
196
+ score += 0.2;
197
+ }
198
+ // Author match
199
+ if (kit.author?.toLowerCase().includes(token)) {
200
+ score += 0.15;
201
+ }
202
+ }
203
+ // Normalize by token count so multi-word queries don't inflate scores
204
+ return tokens.length > 0 ? score / tokens.length : 0;
205
+ }
206
+ function toSearchHit(kit, score, registryName) {
207
+ const metadata = {};
208
+ if (kit.latestVersion)
209
+ metadata.version = kit.latestVersion;
210
+ if (kit.author)
211
+ metadata.author = kit.author;
212
+ if (kit.license)
213
+ metadata.license = kit.license;
214
+ if (kit.assetTypes?.length)
215
+ metadata.assetTypes = kit.assetTypes.join(", ");
216
+ return {
217
+ source: kit.source,
218
+ id: kit.id,
219
+ title: kit.name,
220
+ description: kit.description,
221
+ ref: kit.ref,
222
+ homepage: kit.homepage,
223
+ score: Math.round(score * 1000) / 1000,
224
+ metadata,
225
+ curated: kit.curated,
226
+ registryName,
227
+ };
228
+ }
229
+ // ── Asset parsing ───────────────────────────────────────────────────────────
230
+ function parseAssets(raw) {
231
+ if (!Array.isArray(raw))
232
+ return undefined;
233
+ const parsed = raw.flatMap((item) => {
234
+ const entry = parseAssetEntry(item);
235
+ return entry ? [entry] : [];
236
+ });
237
+ return parsed.length > 0 ? parsed : undefined;
238
+ }
239
+ function parseAssetEntry(raw) {
240
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
241
+ return null;
242
+ const obj = raw;
243
+ const type = asString(obj.type);
244
+ const name = asString(obj.name);
245
+ if (!type || !name)
246
+ return null;
247
+ return {
248
+ type,
249
+ name,
250
+ description: asString(obj.description),
251
+ tags: asStringArray(obj.tags),
252
+ };
253
+ }
254
+ // ── Asset-level scoring ─────────────────────────────────────────────────────
255
+ function scoreAssets(kits, query, limit) {
256
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
257
+ if (tokens.length === 0)
258
+ return [];
259
+ const scored = [];
260
+ for (const { kit, registryName } of kits) {
261
+ if (!kit.assets || kit.assets.length === 0)
262
+ continue;
263
+ const installRef = kit.source === "npm"
264
+ ? `npm:${kit.ref}`
265
+ : kit.source === "git"
266
+ ? `git+${kit.ref}`
267
+ : kit.source === "local"
268
+ ? kit.ref
269
+ : `github:${kit.ref}`;
270
+ for (const asset of kit.assets) {
271
+ const score = scoreAsset(asset, tokens);
272
+ if (score > 0) {
273
+ scored.push({
274
+ hit: {
275
+ type: "registry-asset",
276
+ assetType: asset.type,
277
+ assetName: asset.name,
278
+ description: asset.description,
279
+ kit: { id: kit.id, name: kit.name },
280
+ registryName,
281
+ action: `akm add ${installRef}`,
282
+ score: Math.round(score * 1000) / 1000,
283
+ },
284
+ score,
285
+ });
286
+ }
287
+ }
288
+ }
289
+ scored.sort((a, b) => b.score - a.score);
290
+ return scored.slice(0, limit).map(({ hit }) => hit);
291
+ }
292
+ function scoreAsset(asset, tokens) {
293
+ let score = 0;
294
+ const nameLower = asset.name.toLowerCase();
295
+ const descLower = (asset.description ?? "").toLowerCase();
296
+ const tagsLower = (asset.tags ?? []).map((t) => t.toLowerCase());
297
+ const typeLower = asset.type.toLowerCase();
298
+ for (const token of tokens) {
299
+ if (nameLower === token) {
300
+ score += 1.0;
301
+ }
302
+ else if (nameLower.includes(token)) {
303
+ score += 0.6;
304
+ }
305
+ if (typeLower === token) {
306
+ score += 0.4;
307
+ }
308
+ else if (typeLower.includes(token)) {
309
+ score += 0.2;
310
+ }
311
+ if (tagsLower.some((tag) => tag === token)) {
312
+ score += 0.5;
313
+ }
314
+ else if (tagsLower.some((tag) => tag.includes(token))) {
315
+ score += 0.25;
316
+ }
317
+ if (descLower.includes(token)) {
318
+ score += 0.2;
319
+ }
320
+ }
321
+ return tokens.length > 0 ? score / tokens.length : 0;
322
+ }
323
+ // ── Utilities ───────────────────────────────────────────────────────────────
324
+ function asString(value) {
325
+ return typeof value === "string" && value ? value : undefined;
326
+ }
327
+ function asSource(value) {
328
+ if (value === "npm" || value === "github" || value === "git" || value === "local")
329
+ return value;
330
+ return undefined;
331
+ }
332
+ function asStringArray(value) {
333
+ if (!Array.isArray(value))
334
+ return undefined;
335
+ const filtered = value.filter((v) => typeof v === "string");
336
+ return filtered.length > 0 ? filtered : undefined;
337
+ }
338
+ function toErrorMessage(error) {
339
+ return error instanceof Error ? error.message : String(error);
340
+ }
@@ -154,23 +154,23 @@ async function installGitRegistryRef(parsed, options) {
154
154
  }
155
155
  export function upsertInstalledRegistryEntry(entry) {
156
156
  const current = loadConfig();
157
- const currentInstalled = current.registry?.installed ?? [];
157
+ const currentInstalled = current.installed ?? [];
158
158
  const withoutExisting = currentInstalled.filter((item) => item.id !== entry.id);
159
159
  const nextInstalled = [...withoutExisting, normalizeInstalledEntry(entry)];
160
160
  const nextConfig = {
161
161
  ...current,
162
- registry: { installed: nextInstalled },
162
+ installed: nextInstalled,
163
163
  };
164
164
  saveConfig(nextConfig);
165
165
  return nextConfig;
166
166
  }
167
167
  export function removeInstalledRegistryEntry(id) {
168
168
  const current = loadConfig();
169
- const currentInstalled = current.registry?.installed ?? [];
169
+ const currentInstalled = current.installed ?? [];
170
170
  const nextInstalled = currentInstalled.filter((item) => item.id !== id);
171
171
  const nextConfig = {
172
172
  ...current,
173
- registry: nextInstalled.length > 0 ? { installed: nextInstalled } : undefined,
173
+ installed: nextInstalled.length > 0 ? nextInstalled : undefined,
174
174
  };
175
175
  saveConfig(nextConfig);
176
176
  return nextConfig;
@@ -405,7 +405,7 @@ function countStashDirs(dirPath) {
405
405
  /**
406
406
  * BFS to find the shallowest directory that looks like a stash root.
407
407
  * Checks for both `.stash` directories and well-known type directories
408
- * (tools/, skills/, etc.), so nested layouts like `project/my-kit/tools/`
408
+ * (scripts/, skills/, etc.), so nested layouts like `project/my-kit/scripts/`
409
409
  * are discovered even without a `.stash` marker.
410
410
  *
411
411
  * Skips `root` itself since the caller already checked it via `hasStashDirs`.
@@ -0,0 +1 @@
1
+ export {};