UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

148 lines (147 loc) • 7.05 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { formatRegistryCredentialWarning, formatRegistryError, formatRegistryLabel, hasRegistryUrlCredentials, } from "../../core/registry-url.js"; import { md5Hex } from "../../runtime.js"; import { fetchCachedJson } from "../../storage/repositories/registry-index-cache-repository.js"; import { registerRegistryProvider } from "../factory.js"; import { fetchRegistryJson } from "../network.js"; // ── Constants ─────────────────────────────────────────────────────────────── /** Per-query cache TTL in milliseconds (15 minutes). */ const QUERY_CACHE_TTL_MS = 15 * 60 * 1000; // ── Provider class ────────────────────────────────────────────────────────── class SkillsShProvider { type = "skills-sh"; config; constructor(config) { this.config = config; } async search(options) { if (hasRegistryUrlCredentials(this.config.url)) { return { hits: [], warnings: [formatRegistryCredentialWarning(this.config)] }; } try { const entries = await this.fetchSkills(options.query, options.limit); const limited = entries.slice(0, options.limit); const hits = this.mapToHits(limited); let assetHits; if (options.includeAssets) { assetHits = this.mapToAssetHits(limited); } return { hits, assetHits }; } catch (err) { return { hits: [], warnings: [`Registry ${formatRegistryLabel(this.config)}: ${formatRegistryError(err)}`], }; } } async fetchSkills(query, limit) { // Build a stable DB cache key for this query const dbCacheKey = this.queryDbCacheKey(query, limit); const baseUrl = this.config.url.replace(/\/+$/, ""); const url = `${baseUrl}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`; return fetchCachedJson({ cacheKey: dbCacheKey, ttlMs: QUERY_CACHE_TTL_MS, // A fresh hit returns even an empty array; a stale fallback only when // non-empty. Corrupt cache JSON is swallowed and treated as a miss. parseCache: (json, { stale }) => { try { const parsed = JSON.parse(json); if (!Array.isArray(parsed)) return undefined; const entries = parsed.filter(isValidSkillsEntry); if (stale && entries.length === 0) return undefined; return entries; } catch { return undefined; } }, fetchFresh: async () => { const data = await fetchRegistryJson(url, { timeoutMs: 10_000, retries: 1 }); const entries = parseSkillsResponse(data); return { value: entries, cacheJson: JSON.stringify(entries) }; }, }); } mapToHits(entries) { if (entries.length === 0) return []; // Assign decreasing synthetic scores for merge compatibility const maxInstalls = Math.max(...entries.map((e) => e.installs), 1); const registryName = this.config.name ?? "skills.sh"; const baseUrl = this.config.url.replace(/\/+$/, ""); return entries.map((entry) => { const segments = entry.source.split("/"); const owner = segments[0] ?? ""; const repo = segments[1] ?? ""; const ownerRepo = owner && repo ? `${owner}/${repo}` : entry.source; const score = Math.round((entry.installs / maxInstalls) * 1000) / 1000; return { source: "github", id: `skills-sh:${entry.id}`, title: entry.name, ref: ownerRepo, installRef: `github:${ownerRepo}`, homepage: `${baseUrl}/${entry.id}`, score, metadata: { installs: String(entry.installs), ...(owner ? { author: owner } : {}), }, registryName, }; }); } mapToAssetHits(entries) { if (entries.length === 0) return undefined; const registryName = this.config.name ?? "skills.sh"; const maxInstalls = Math.max(...entries.map((e) => e.installs), 1); const hits = entries.map((entry) => { const segments = entry.source.split("/"); const owner = segments[0] ?? ""; const repo = segments[1] ?? ""; const ownerRepo = owner && repo ? `${owner}/${repo}` : entry.source; return { type: "registry-asset", assetType: "skill", assetName: entry.name, stash: { id: `skills-sh:${entry.id}`, name: entry.name }, registryName, action: `akm bundle add github:${ownerRepo}`, score: Math.round((entry.installs / maxInstalls) * 1000) / 1000, }; }); return hits.length > 0 ? hits : undefined; } // ── DB cache key ──────────────────────────────────────────────────────── queryDbCacheKey(query, limit) { const hash = md5Hex(`${this.config.url}\0${query.trim().toLowerCase()}\0${String(limit)}`); return `skills-sh:${hash}`; } } // ── Self-register ─────────────────────────────────────────────────────────── registerRegistryProvider("skills-sh", (config) => new SkillsShProvider(config)); // ── Response parsing ──────────────────────────────────────────────────────── function parseSkillsResponse(data) { if (typeof data !== "object" || data === null || Array.isArray(data)) return []; const obj = data; if (!Array.isArray(obj.skills)) return []; return obj.skills.filter(isValidSkillsEntry); } function isValidSkillsEntry(entry) { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false; const obj = entry; return (typeof obj.id === "string" && typeof obj.name === "string" && typeof obj.installs === "number" && typeof obj.source === "string"); }