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

118 lines (117 loc) • 5.36 kB
import path from "node:path"; import { isBundleSlug } from "../asset/asset-ref.js"; import { warnOnce } from "../warn.js"; function isPlainRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } const DEFAULT_WRITABLE_BY_TYPE = { filesystem: true, git: false, website: false, npm: false, }; function bundleFromLegacySource(entry, index) { if (!isPlainRecord(entry)) return undefined; const type = typeof entry.type === "string" ? entry.type : undefined; const bundle = {}; switch (type) { case "filesystem": if (typeof entry.path !== "string" || !entry.path) return undefined; bundle.path = entry.path; break; case "git": if (typeof entry.url !== "string" || !entry.url) return undefined; bundle.git = entry.url; break; case "website": if (typeof entry.url !== "string" || !entry.url) return undefined; bundle.website = { url: entry.url }; break; case "npm": { const spec = typeof entry.url === "string" && entry.url ? entry.url : entry.path; if (typeof spec !== "string" || !spec) return undefined; bundle.npm = spec; break; } default: return undefined; } const writable = typeof entry.writable === "boolean" ? entry.writable : DEFAULT_WRITABLE_BY_TYPE[type ?? ""]; if (writable !== undefined) bundle.writable = writable; if (typeof entry.enabled === "boolean") bundle.enabled = entry.enabled; const name = typeof entry.name === "string" ? entry.name : undefined; const key = name && isBundleSlug(name) ? name : `source-${index + 1}`; return [key, bundle]; } function filesystemLocator(bundle) { if (!isPlainRecord(bundle) || typeof bundle.path !== "string" || bundle.path.length === 0) return undefined; return path.resolve(bundle.path); } /** * Pure legacy `stashDir`/`sources[]`/`installed` -> `bundles`/`defaultBundle` * conversion. Triggers on key *presence* (not on the value being usable), so * an unusable value — `stashDir: ""`, `sources: []` — is still removed along * with the rest of the legacy shape; only a usable `stashDir` or `sources[]` * entry is actually folded into `bundles`. Never warns — see * {@link migrateLegacySourceShape} for the warn-once wrapper callers use. */ export function convertLegacySourceShape(raw) { const hasStashDir = "stashDir" in raw; const hasSources = "sources" in raw; const hasInstalled = "installed" in raw && raw.installed !== undefined; if (!hasStashDir && !hasSources && !hasInstalled) return { config: raw, converted: [] }; const { stashDir: _stashDir, sources: _sources, installed: _installed, ...rest } = raw; const bundles = isPlainRecord(rest.bundles) ? { ...rest.bundles } : {}; let defaultBundle = typeof rest.defaultBundle === "string" ? rest.defaultBundle : undefined; const usableStashDir = typeof raw.stashDir === "string" && raw.stashDir.trim().length > 0; if (usableStashDir) { bundles.stash = { path: raw.stashDir, writable: true }; defaultBundle ??= "stash"; } if (Array.isArray(raw.sources)) { raw.sources.forEach((entry, index) => { const converted = bundleFromLegacySource(entry, index); if (!converted) return; const [key, bundle] = converted; const locator = filesystemLocator(bundle); if (locator && Object.values(bundles).some((candidate) => filesystemLocator(candidate) === locator)) return; bundles[key] = bundle; defaultBundle ??= key; }); } const converted = [hasStashDir && "stashDir", hasSources && "sources", hasInstalled && "installed"].filter((key) => typeof key === "string"); // Only add a `bundles` key when there is something to say: either a // legacy entry actually converted into one, or the config already had a // `bundles` map (preserved as-is). An unusable `stashDir: ""` / `sources: []` // with no pre-existing `bundles` should just disappear, not leave behind // an empty `bundles: {}`. const hasBundles = Object.keys(bundles).length > 0 || isPlainRecord(rest.bundles); return { config: { ...rest, ...(hasBundles ? { bundles } : {}), ...(defaultBundle !== undefined ? { defaultBundle } : {}) }, converted, }; } /** * Warn-once wrapper around {@link convertLegacySourceShape} for the * in-memory read path: converts the legacy shape and, when it removed * anything, warns once that `akm migrate apply` will persist the rewrite. */ export function migrateLegacySourceShape(raw, sourcePath) { const { config, converted } = convertLegacySourceShape(raw); if (converted.length === 0) return config; const where = sourcePath ? ` at ${sourcePath}` : ""; warnOnce(`legacy-source-shape${sourcePath ? `:${sourcePath}` : ""}`, `Config${where} uses the retired ${converted.join("/")} shape — auto-migrated in memory to \`bundles\`/\`defaultBundle\`. Run \`akm migrate apply\` to rewrite the config file and silence this warning.`); return config; }