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
JavaScript
// 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");
}