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

427 lines (426 loc) • 19.7 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 fs from "node:fs"; import path from "node:path"; import { detectAdapterId } from "../../core/adapter/detect-adapter.js"; import { isBundleSlug } from "../../core/asset/asset-ref.js"; import { slugForRegistryId, validateExplicitBundleName } from "../../core/bundle-id.js"; import { isHttpUrl, resolveStashDir } from "../../core/common.js"; import { bundleEntryToSourceEntry, getSources, installedSourceDescriptor, loadConfig, mutateConfig, resolveSecret, } from "../../core/config/config.js"; import { ConfigError, UsageError } from "../../core/errors.js"; import { akmIndex } from "../../indexer/indexer.js"; import { deriveBundleId } from "../../indexer/installations.js"; import { readLockfile, removeLockEntry, upsertLockEntry } from "../../integrations/lockfile.js"; import { parseRegistryRef } from "../../registry/resolve.js"; import { detectStashRoot } from "../../sources/providers/provider-utils.js"; import { syncFromRef } from "../../sources/providers/sync-from-ref.js"; import { storeSecretResolver } from "../../sources/snapshot-fetchers/secret-seam.js"; import { ensureWebsiteMirror, shouldAllowPrivateWebsiteUrlForTests, validateWebsiteInputUrl, } from "../../sources/snapshot-fetchers/website-ingest.js"; import { revokeSchedulerActivationsForBundle } from "../../tasks/activation-config.js"; import { bundleKeyForPath, bundleKeyForUrl, nextBundleKey, placeBundle, } from "./bundle-config-ops.js"; export async function akmAdd(input) { const ref = input.ref.trim(); if (!ref) throw new UsageError("Install ref or local directory is required. " + "Examples: `akm bundle add @scope/stash`, `akm bundle add github:owner/repo`, `akm bundle add ./local/path`"); const stashDir = resolveStashDir(); if (shouldAddAsWebsiteUrl(ref)) { return addWebsiteSource(ref, stashDir, input.name, input.options, input); } // Local directories become filesystem bundles; registry refs use the // registry-backed bundle installer below. try { const parsed = parseRegistryRef(ref); if (parsed.source === "local") { return addLocalSource(ref, parsed.sourcePath, stashDir, input.name, input.adapter, input); } } catch { // Not a local ref — fall through to registry install } return addRegistryStash(ref, stashDir, input.name, input.writable, input, input.credential); } /** Add a local directory as a filesystem bundle. */ async function addLocalSource(ref, sourcePath, stashDir, explicitName, explicitAdapter, position = {}) { const stashRoot = detectStashRoot(sourcePath); const resolvedPath = path.resolve(stashRoot); const adapter = explicitAdapter ?? detectAdapterId(resolvedPath); let bundleKey = explicitName ?? toReadableId(resolvedPath); mutateConfig((config) => { const existing = bundleKeyForPath(config, resolvedPath); if (existing) { if (explicitName !== undefined) { validateExplicitBundleName(config.bundles ?? {}, explicitName, existing); } bundleKey = existing; const current = config.bundles?.[existing]; if (current?.components && explicitAdapter === undefined) return config; const bundles = { ...(config.bundles ?? {}) }; bundles[existing] = { ...current, path: resolvedPath, components: { main: { root: ".", adapter } }, }; return { ...config, bundles }; } const bundles = { ...(config.bundles ?? {}) }; // D6: an explicit `--name` is a contract on this (local) add path — validated // strictly before nextBundleKey derives a key, since that shared helper is also // used by the out-of-scope `akm source add` (`addStash`) and stays forgiving. if (explicitName !== undefined) validateExplicitBundleName(bundles, explicitName); bundleKey = nextBundleKey(bundles, explicitName, resolvedPath); const entry = { path: resolvedPath, components: { main: { root: ".", adapter } }, }; return { ...config, bundles: placeBundle(bundles, bundleKey, entry, position) }; }); const index = await akmIndex({ stashDir }); const updatedConfig = loadConfig(); return { schemaVersion: 1, bundleDir: stashDir, ref, bundleId: bundleKey, sourceAdded: { type: "filesystem", path: resolvedPath, name: bundleKey, stashRoot: resolvedPath, }, config: { sourceCount: getSources(updatedConfig).length, }, index: { mode: index.mode, totalEntries: index.totalEntries, directoriesScanned: index.directoriesScanned, directoriesSkipped: index.directoriesSkipped, ...(index.warnings?.length ? { warnings: index.warnings } : {}), }, }; } async function addWebsiteSource(ref, stashDir, name, options, position = {}) { const allowPrivateHosts = shouldAllowPrivateWebsiteUrlForTests(ref); const normalizedUrl = validateWebsiteInputUrl(ref, { allowPrivateHosts }); const numberOption = (value) => (typeof value === "number" ? value : undefined); const maxPages = numberOption(options?.maxPages); const maxDepth = numberOption(options?.maxDepth); let entry; let bundleId = ""; mutateConfig((config) => { const bundles = { ...(config.bundles ?? {}) }; const existingKey = bundleKeyForUrl(config, normalizedUrl); if (existingKey && name !== undefined) { validateExplicitBundleName(bundles, name, existingKey); } // An explicit `--name` is a contract (D6) — validated strictly and used // as-is, never silently substituted. A DERIVED default (no --name) keeps // the forgiving `deriveBundleId` fallback, so a dotted hostname or a // URL collision still mints a usable id instead of erroring. let key; if (existingKey) { key = existingKey; } else if (name !== undefined) { validateExplicitBundleName(bundles, name); key = name; } else { key = deriveBundleId(toWebsiteName(normalizedUrl), normalizedUrl, new Set(Object.keys(bundles))); } bundleId = key; // Merge onto the existing descriptor rather than replacing it: re-running // `bundle add` for a URL that already has a bundle would otherwise drop // respectRobots / refresh, silently restoring default robots enforcement // and changing what the next update fetches. Explicitly-passed maxPages / // maxDepth still win over the stored values. const existingWebsite = existingKey ? (bundles[key]?.website ?? {}) : {}; const website = { ...existingWebsite, url: normalizedUrl, ...(maxPages !== undefined ? { maxPages } : {}), ...(maxDepth !== undefined ? { maxDepth } : {}), }; const nextBundle = { ...(existingKey ? bundles[key] : {}), website, components: { main: { root: ".", adapter: "website-snapshot", writable: false } }, }; if (JSON.stringify(bundles[key]) === JSON.stringify(nextBundle)) { entry = bundleEntryToSourceEntry(key, bundles[key]); return config; } const nextBundles = placeBundle(bundles, key, nextBundle, position); entry = bundleEntryToSourceEntry(key, nextBundle); return { ...config, bundles: nextBundles }; }); const cachePaths = await ensureWebsiteMirror(entry, { requireStashDir: true, resolveSecret: storeSecretResolver.resolveSecret, ...(allowPrivateHosts ? { allowPrivateHosts: true } : {}), }); const index = await akmIndex({ stashDir }); const updatedConfig = loadConfig(); return { schemaVersion: 1, bundleDir: stashDir, ref, bundleId, sourceAdded: { type: "website", url: normalizedUrl, name: entry?.name, stashRoot: cachePaths.stashDir, }, config: { sourceCount: getSources(updatedConfig).length, }, index: { mode: index.mode, totalEntries: index.totalEntries, directoriesScanned: index.directoriesScanned, directoriesSkipped: index.directoriesSkipped, ...(index.warnings?.length ? { warnings: index.warnings } : {}), }, }; } /** * Install a stash from a registry (npm, github, git) by dispatching to the * matching syncable provider and persisting the lock entry. */ async function addRegistryStash(ref, stashDir, explicitName, writable, position = {}, credentialRef) { const parsedRef = parseRegistryRef(ref); if (writable === true && parsedRef.source !== "git" && parsedRef.source !== "github") { throw new ConfigError("writable: true is only supported on filesystem and git sources", "INVALID_CONFIG_FILE"); } if (credentialRef && parsedRef.source !== "git" && parsedRef.source !== "github") { throw new ConfigError("credential is only supported on git sources", "INVALID_CONFIG_FILE"); } const currentConfig = loadConfig(); const existingBundleKey = findInstalledBundleKey(currentConfig.bundles ?? {}, parsedRef.id); // D6: validate an explicit `--name` before any network sync or write — an // illegal name, a name already taken by a different bundle, or re-adding // this same install under a different name than it already carries must // fail loudly here, not mint a `-<hash>` fallback deep inside the config // mutation below. if (explicitName !== undefined) { validateExplicitBundleName(currentConfig.bundles ?? {}, explicitName, existingBundleKey); } const existingBundle = existingBundleKey ? currentConfig.bundles?.[existingBundleKey] : undefined; const priorLock = existingBundleKey ? readLockfile().find((entry) => entry.id === existingBundleKey) : undefined; const existingWritable = Object.values(existingBundle?.components ?? {})[0]?.writable ?? existingBundle?.writable === true; const effectiveWritable = writable === true || existingWritable; const requiredRoots = priorLock?.localRoot ? Object.values(existingBundle?.components ?? {}).map((component) => path.resolve(priorLock.localRoot, component.root ?? ".")) : []; const synced = await syncFromRef(ref, { writable: effectiveWritable, ...(effectiveWritable && priorLock?.localRoot ? { writableRoot: priorLock.localRoot } : {}), ...(requiredRoots.length > 0 ? { writableRequiredRoots: requiredRoots } : {}), ...(credentialRef ? { credential: resolveSecret(credentialRef, storeSecretResolver.resolveSecret) } : existingBundle?.credential ? { credential: resolveSecret(existingBundle.credential, storeSecretResolver.resolveSecret) } : {}), }); const { config: updatedConfig, bundleId } = upsertInstalledRegistryEntry({ id: synced.id, source: synced.source, ref: synced.ref, artifactUrl: synced.artifactUrl, resolvedVersion: synced.resolvedVersion, resolvedRevision: synced.resolvedRevision, stashRoot: synced.contentDir, cacheDir: synced.cacheDir, installedAt: synced.syncedAt, writable: synced.writable, }, position, credentialRef, explicitName); // The prior materialized root (if this is a re-install) — read BEFORE the lock // upsert overwrites it, so a moved cache root can be cleaned afterwards. const priorLocalRoot = priorLock?.localRoot; await upsertLockEntry({ id: bundleId, source: synced.source, ref: synced.ref, resolvedVersion: synced.resolvedVersion, resolvedRevision: synced.resolvedRevision, integrity: synced.integrity, // §10.2 resolved lock state the install flow has on hand. localRoot: synced.contentDir, installedAt: synced.syncedAt, }); // Clean up the old materialized root on re-install (moved cache). if (!effectiveWritable && priorLocalRoot && path.resolve(priorLocalRoot) !== path.resolve(synced.contentDir)) { try { fs.rmSync(priorLocalRoot, { recursive: true, force: true }); } catch { // Best-effort cleanup only. } } const index = await akmIndex({ stashDir }); return { schemaVersion: 1, bundleDir: stashDir, ref, bundleId, registryId: synced.id, installed: { id: synced.id, source: synced.source, ref: synced.ref, artifactUrl: synced.artifactUrl, resolvedVersion: synced.resolvedVersion, resolvedRevision: synced.resolvedRevision, stashRoot: synced.contentDir, cacheDir: synced.cacheDir, extractedDir: synced.extractedDir, installedAt: synced.syncedAt, }, config: { sourceCount: getSources(updatedConfig).length, }, index: { mode: index.mode, totalEntries: index.totalEntries, directoriesScanned: index.directoriesScanned, directoriesSkipped: index.directoriesSkipped, ...(index.warnings?.length ? { warnings: index.warnings } : {}), }, }; } /** * Persist or replace a registry-installed source as a 0.9.0 `bundles` entry * (spec §10.1 / §10.2 desired/resolved split). The bundle carries ONLY the * desired descriptor (git/npm locator + preserved `registryId` + `writable`); * the resolved cache root belongs exclusively in the lock (written by callers * via {@link upsertLockEntry} with the returned `bundleId`). A new install is * keyed by `explicitName` (the CLI's `--name`) when given. Returns the config * plus the derived bundle id so the caller keys its lock entry identically. */ export function upsertInstalledRegistryEntry(entry, position = {}, credential, explicitName) { let bundleId = entry.id; const config = mutateConfig((current) => { const bundles = { ...(current.bundles ?? {}) }; bundleId = resolveInstalledBundleKey(bundles, entry.id, entry.stashRoot, explicitName); const existingComponents = bundles[bundleId]?.components; const components = existingComponents ? Object.fromEntries(Object.entries(existingComponents).map(([id, component]) => [ id, { ...component, writable: entry.writable === true }, ])) : { main: { root: ".", adapter: detectAdapterId(path.resolve(entry.stashRoot)), writable: entry.writable === true, }, }; const descriptor = installedSourceDescriptor(entry.source, entry.ref, path.resolve(entry.stashRoot)); const effectiveCredential = credential ?? bundles[bundleId]?.credential; const nextEntry = { ...descriptor, ...(effectiveCredential ? { credential: effectiveCredential } : {}), ...(entry.writable === true ? { writable: true } : {}), ...(entry.id !== bundleId ? { registryId: entry.id } : {}), components: components, }; return { ...current, bundles: placeBundle(bundles, bundleId, nextEntry, position) }; }).config; return { config, bundleId }; } /** * Remove a registry-installed source: delete its `bundles` entry and its lock * entry (spec §10.2). Matches the bundle by preserved `registryId` or by a * slug-legal install id used verbatim as the key. Idempotent. */ export async function removeInstalledRegistryEntry(id) { let removedKey; const config = mutateConfig((current) => { const bundles = { ...(current.bundles ?? {}) }; const key = findInstalledBundleKey(bundles, id); if (!key) return current; removedKey = key; delete bundles[key]; return revokeSchedulerActivationsForBundle({ ...current, bundles: Object.keys(bundles).length > 0 ? bundles : undefined, ...(current.defaultBundle === key ? { defaultBundle: undefined } : {}), ...(current.defaultWriteTarget === key ? { defaultWriteTarget: undefined } : {}), }, key); }).config; if (removedKey) await removeLockEntry(removedKey); return config; } /** * The bundle key that maps to a registry install id, or `undefined` when no * bundle currently represents it. A non-slug-legal install id (e.g. * `github:owner/repo`) is preserved verbatim on the bundle's `registryId`; a * slug-legal id is used directly as the key. */ function findInstalledBundleKey(bundles, installId) { for (const [key, bundle] of Object.entries(bundles)) { if (bundle.registryId === installId) return key; } if (isBundleSlug(installId) && installId in bundles) return installId; return undefined; } /** * The stable bundle key for a registry install: reuse the existing bundle for * this install id (so re-installs keep the same key — a caller-visible name * mismatch was already rejected upstream, by {@link validateExplicitBundleName} * in `addRegistryStash`, before any write). Otherwise an explicit `--name` * (D6) is a contract — validated strictly and used as-is, never silently * substituted. Without one, a batch-unique key is derived via the shared * {@link deriveBundleId} (D-R5) from the package/repo name the install id * names rather than the basename of the materialized cache directory * (`extracted`) — unique against the currently-configured bundle keys. */ function resolveInstalledBundleKey(bundles, installId, stashRoot, explicitName) { const existing = findInstalledBundleKey(bundles, installId); if (existing) return existing; if (explicitName !== undefined) { validateExplicitBundleName(bundles, explicitName); return explicitName; } return deriveBundleId(slugForRegistryId(installId), path.resolve(stashRoot), new Set(Object.keys(bundles))); } function toReadableId(resolvedPath) { const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; if (home && resolvedPath.startsWith(home + path.sep)) { return `~${resolvedPath.slice(home.length)}`; } return resolvedPath; } // Keep this list limited to widely-used git hosts for the non-breaking // "repo-like URL" fast-path; everything else continues to default to website snapshots. const KNOWN_GIT_HOSTS = new Set(["github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "git.sr.ht"]); export function shouldAddAsWebsiteUrl(ref) { return isHttpUrl(ref) && !isLikelyGitRepositoryUrl(ref); } function isLikelyGitRepositoryUrl(ref) { try { const parsed = new URL(ref); return KNOWN_GIT_HOSTS.has(parsed.hostname.toLowerCase()) || parsed.pathname.endsWith(".git"); } catch { return false; } } function toWebsiteName(siteUrl) { try { return new URL(siteUrl).hostname; } catch { return siteUrl; } }