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

250 lines (249 loc) • 12.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 { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { stashDirFor } from "../../core/asset/asset-placement.js"; import { isBundleSlug } from "../../core/asset/asset-ref.js"; import { displayRef, parseQualifiedRefInput } from "../../core/asset/resolve-ref.js"; import { isWithin } from "../../core/common.js"; import { loadConfig } from "../../core/config/config.js"; import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js"; import { defaultBundleForTarget } from "../../core/mutation-target.js"; import { warn } from "../../core/warn.js"; import { commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js"; import { withAssetMutationLease } from "../../indexer/index-writer-lock.js"; import { indexWrittenAssets } from "../../indexer/index-written-assets.js"; import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js"; import { isRemoteOrigin, resolveSourcesForLocator, resolveSourcesForOrigin } from "../../registry/origin-resolve.js"; import { syncFromRef } from "../../sources/providers/sync-from-ref.js"; import { resolveAssetPath } from "../../sources/resolve.js"; function destinationRef(config, target, type, name) { if (!config || !target) return undefined; return displayRef({ type, name, bundleId: target.source.name }, defaultBundleForTarget(config)); } export async function akmClone(options) { const unmanagedDest = options.dest; const hasUnmanagedDest = unmanagedDest !== undefined; if (hasUnmanagedDest && options.target !== undefined) { throw new UsageError("--dest and --bundle cannot be used together; choose an unmanaged path or a managed bundle."); } // F1b/F4b: accept the 0.9.0 conceptId spelling (`akm clone scripts/deploy.sh`). // F5 origin split (parseQualifiedRefInput): `akm clone` also accepts a NON-slug // clone SOURCE as the `origin//conceptId` prefix — a registry ref // (`npm:@scope/pkg`), a bare path, or a URL — resolved by // resolveSourcesForOrigin's registry-id / path matching + the remote-fetch // fallback below (the strict new-grammar parser rejects such origins as they // are not bundle slugs). const parsed = parseQualifiedRefInput(options.sourceRef); const config = hasUnmanagedDest ? undefined : loadConfig(); const resolvedWriteTarget = config ? resolveWriteTarget(config, options.target, { flag: "--bundle" }) : undefined; const writeTarget = resolvedWriteTarget ? prepareWriteTargetForMutation(resolvedWriteTarget) : undefined; // An unmanaged --dest does not require any configured write target. let allSources; try { allSources = resolveSourceEntries(); } catch (err) { if (hasUnmanagedDest) { allSources = []; } else { throw err; } } const destRoot = unmanagedDest !== undefined ? path.resolve(unmanagedDest) : writeTarget?.source.path; if (!destRoot) { throw new ConfigError("No writable source configured and no --dest provided. Run `akm bundle create` or pass --dest.", "STASH_DIR_NOT_FOUND"); } let searchSources = parsed.origin && !isBundleSlug(parsed.origin) ? resolveSourcesForLocator(parsed.origin, allSources) : resolveSourcesForOrigin(parsed.origin, allSources); // Remote fetch fallback: if no local source matched and origin looks remote, fetch it let remoteFetched; if (searchSources.length === 0 && parsed.origin && isRemoteOrigin(parsed.origin, allSources)) { const installResult = await syncFromRef(parsed.origin); const syntheticSource = { path: installResult.contentDir, registryId: installResult.id, }; searchSources = [syntheticSource]; allSources = [...allSources, syntheticSource]; remoteFetched = { origin: parsed.origin, stashRoot: installResult.contentDir, cacheDir: installResult.cacheDir, }; } let sourcePath; let lastError; for (const source of searchSources) { try { sourcePath = await resolveAssetPath(source.path, parsed.type, parsed.name); break; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); } } if (!sourcePath) { if (remoteFetched) { throw new NotFoundError(`Source asset not found for ref: ${options.sourceRef} (remote package fetched but asset not found inside it)`, "ASSET_NOT_FOUND", "The remote package was fetched but doesn't contain the requested asset. Check the asset name and type."); } throw lastError ?? new NotFoundError(`Source asset not found for ref: ${options.sourceRef}`, "ASSET_NOT_FOUND"); } const sourceSource = findSourceForPath(sourcePath, allSources); const destName = options.newName ?? parsed.name; const typeDir = stashDirFor(parsed.type); // Validate destName to prevent path traversal (parsed.name is already // validated by the ref parser, but newName comes directly from user input). // Run whenever newName is provided, including empty string. if (options.newName !== undefined) { if (destName === "") { throw new UsageError("Clone name must not be empty."); } const normalized = path.posix.normalize(destName.replace(/\\/g, "/")); if (path.isAbsolute(destName) || normalized === "." || normalized.startsWith("../") || normalized === ".." || destName.includes("\0")) { throw new UsageError(`Unsafe clone name "${destName}": must not contain path traversal or absolute paths.`); } // Ensure the resolved destination is strictly inside the type directory, // not equal to it (which can happen with crafted multi-segment names). // path.relative() is used instead of startsWith() for cross-platform safety. const destTypeDir = path.resolve(path.join(destRoot, typeDir)); const resolvedDest = path.resolve(path.join(destRoot, typeDir, destName)); const rel = path.relative(destTypeDir, resolvedDest); if (rel === "" || rel.startsWith("..")) { throw new UsageError(`Unsafe clone name "${destName}": resolves outside the target type directory.`); } } const destLabel = hasUnmanagedDest ? "at destination" : writeTarget?.selector ? `in target "${writeTarget.selector}"` : "in working stash"; // Guard against self-clone if (parsed.type === "skill") { const sourceSkillDir = path.resolve(path.dirname(sourcePath)); const destSkillDir = path.resolve(path.join(destRoot, typeDir, destName)); if (sourceSkillDir === destSkillDir) { throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`); } } else { const resolvedSource = path.resolve(sourcePath); const sourceExt = path.extname(sourcePath); const effectiveDestName = !path.extname(destName) && sourceExt ? destName + sourceExt : destName; const resolvedDest = path.resolve(path.join(destRoot, typeDir, effectiveDestName)); if (resolvedSource === resolvedDest) { throw new Error(`Source and destination are the same path. Use --name to provide a new name for the clone.`); } } return withAssetMutationLease("clone", async () => { let destPath; let overwritten; let operationPaths; if (parsed.type === "skill") { const sourceSkillDir = path.dirname(sourcePath); const destSkillDir = path.join(destRoot, typeDir, destName); assertNoDestinationSymlinkParent(destRoot, destSkillDir); const existing = lstatIfExists(destSkillDir); overwritten = existing !== undefined; if (overwritten && !options.force) { throw new UsageError(`Asset already exists ${destLabel}: ${destSkillDir}. Use --force to overwrite.`, "RESOURCE_ALREADY_EXISTS"); } const replacedPaths = existing?.isDirectory() && !existing.isSymbolicLink() ? listTreeFiles(destSkillDir) : []; // Stage first so a failed source copy leaves the old entry untouched. A // final symlink is removed as an entry, never traversed. const stagingDir = `${destSkillDir}.tmp-${randomUUID()}`; try { fs.cpSync(sourceSkillDir, stagingDir, { recursive: true, errorOnExist: true, force: false }); if (overwritten) fs.rmSync(destSkillDir, { recursive: true, force: true }); fs.renameSync(stagingDir, destSkillDir); } catch (err) { fs.rmSync(stagingDir, { recursive: true, force: true }); throw err; } destPath = path.join(destSkillDir, "SKILL.md"); operationPaths = [...new Set([...replacedPaths, ...listTreeFiles(destSkillDir)])]; } else { destPath = path.join(destRoot, typeDir, destName); assertNoDestinationSymlinkParent(destRoot, destPath); const existing = lstatIfExists(destPath); overwritten = existing !== undefined; if (overwritten && !options.force) { throw new UsageError(`Asset already exists ${destLabel}: ${destPath}. Use --force to overwrite.`, "RESOURCE_ALREADY_EXISTS"); } fs.mkdirSync(path.dirname(destPath), { recursive: true }); const stagingPath = `${destPath}.tmp-${randomUUID()}`; try { fs.copyFileSync(sourcePath, stagingPath, fs.constants.COPYFILE_EXCL); if (overwritten) fs.rmSync(destPath, { recursive: true, force: true }); fs.renameSync(stagingPath, destPath); } catch (err) { fs.rmSync(stagingPath, { force: true }); throw err; } operationPaths = [destPath]; } const ref = destinationRef(config, writeTarget, parsed.type, destName); if (writeTarget) { const commitRoot = writeTarget.source.repoPath ?? writeTarget.source.path; const commitPaths = operationPaths.map((filePath) => path.relative(commitRoot, filePath).replaceAll(path.sep, "/")); commitWriteTargetBoundary(writeTarget, `Clone ${ref}`, { paths: commitPaths }); if (!(await indexWrittenAssets(writeTarget.source.path, [destPath], { bundleId: writeTarget.source.name }))) { warn(`Clone ${ref} succeeded, but its targeted index update failed; run \`akm index\` to refresh it.`); } } return { source: { path: sourcePath, registryId: sourceSource?.registryId }, destination: { path: destPath, ...(ref ? { ref } : {}) }, overwritten, ...(remoteFetched ? { remoteFetched } : {}), }; }); } function lstatIfExists(filePath) { try { return fs.lstatSync(filePath); } catch (error) { if (error.code === "ENOENT") return undefined; throw error; } } function assertNoDestinationSymlinkParent(root, destination) { const resolvedRoot = path.resolve(root); const parentDir = path.dirname(path.resolve(destination)); const relativeParent = path.relative(resolvedRoot, parentDir); if (relativeParent === "" || relativeParent === ".") return; if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) { throw new UsageError(`Clone destination escapes the selected target: ${destination}.`, "PATH_ESCAPE_VIOLATION"); } if (!isWithin(parentDir, resolvedRoot)) { throw new UsageError(`Clone destination has a symbolic-link parent outside the selected target boundary: ${parentDir}.`, "PATH_ESCAPE_VIOLATION"); } } function listTreeFiles(root) { const files = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const entryPath = path.join(root, entry.name); if (entry.isDirectory()) files.push(...listTreeFiles(entryPath)); else files.push(entryPath); } return files; }