@alavida/agentpack
Version:
Compiler-driven lifecycle CLI for source-backed agent skills
447 lines (388 loc) • 14.8 kB
JavaScript
import { join } from 'node:path';
import { findRepoRoot } from '../../lib/context.js';
import { normalizeDisplayPath, readPackageMetadata } from '../../domain/skills/skill-model.js';
import { findPackageDirByName } from '../../domain/skills/package-discovery.js';
import {
buildInstalledWorkspaceGraph,
resolveInstalledSkillTarget,
} from '../../domain/skills/installed-workspace-graph.js';
import { readInstallState, writeInstallState } from '../../infrastructure/fs/install-state-repository.js';
import {
readMaterializationState,
} from '../../infrastructure/fs/materialization-state-repository.js';
import { inspectMaterializedSkills } from '../../infrastructure/runtime/inspect-materialized-skills.js';
import {
removePathIfExists,
} from '../../infrastructure/runtime/materialize-skills.js';
import { applyRuntimeMaterializationPlanUseCase } from './apply-runtime-materialization.js';
import { ValidationError } from '../../utils/errors.js';
const SUPPORTED_RUNTIMES = ['agents', 'claude'];
const RUNTIME_DIRS = {
agents: '.agents',
claude: '.claude',
};
function normalizeRuntimeSelection(runtimes) {
const values = runtimes == null
? []
: Array.isArray(runtimes)
? runtimes
: [runtimes];
if (values.length === 0) return [...SUPPORTED_RUNTIMES];
const normalized = [...new Set(values)]
.map((value) => String(value).trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const unsupported = normalized.filter((value) => !SUPPORTED_RUNTIMES.includes(value));
if (unsupported.length > 0) {
throw new ValidationError(`unsupported runtime selection: ${unsupported.join(', ')}`, {
code: 'unsupported_runtime',
suggestion: `Supported runtimes: ${SUPPORTED_RUNTIMES.join(', ')}`,
});
}
return normalized;
}
function cloneSelections(selections) {
return Object.fromEntries(
Object.entries(selections || {}).map(([target, runtimes]) => [
target,
[...new Set(runtimes || [])].sort((a, b) => a.localeCompare(b)),
])
);
}
function inferLegacySelections(state) {
const selections = {};
for (const [packageName, install] of Object.entries(state?.installs || {})) {
if (!install?.direct) continue;
const runtimes = new Set();
for (const materialization of install.materializations || []) {
if (typeof materialization?.target !== 'string') continue;
if (materialization.target.startsWith('.claude/')) runtimes.add('claude');
if (materialization.target.startsWith('.agents/')) runtimes.add('agents');
}
selections[packageName] = [...(runtimes.size > 0 ? runtimes : new Set(SUPPORTED_RUNTIMES))]
.sort((a, b) => a.localeCompare(b));
}
return selections;
}
function readDirectSelections(repoRoot) {
const state = readInstallState(repoRoot);
if (state?.enabled_targets && typeof state.enabled_targets === 'object' && !Array.isArray(state.enabled_targets)) {
return cloneSelections(state.enabled_targets);
}
return inferLegacySelections(state);
}
function writeDirectSelections(repoRoot, selections) {
writeInstallState(repoRoot, {
version: 2,
enabled_targets: cloneSelections(selections),
});
}
function selectionKeyForResolvedTarget(resolved) {
return resolved.kind === 'package'
? resolved.package.packageName
: resolved.export.id;
}
function listInitialExportIds(graph, selectionKey) {
if (graph.packages[selectionKey]) {
return [...graph.packages[selectionKey].exports];
}
if (graph.exports[selectionKey]) {
return [selectionKey];
}
return [];
}
function resolveRequirementExportId(graph, requirement) {
if (!requirement) return null;
if (graph.exports[requirement]) return requirement;
const pkg = graph.packages[requirement];
if (!pkg) return null;
if (pkg.primaryExport) return pkg.primaryExport;
if (pkg.exports.length === 1) return pkg.exports[0];
return null;
}
function buildMissingDependencyError(requirement, exportNode, runtime) {
const packageName = requirement.includes(':')
? requirement.slice(0, requirement.indexOf(':'))
: requirement;
throw new ValidationError(`installed dependency not found: ${requirement}`, {
code: 'installed_dependency_not_found',
suggestion: `Enable could not resolve ${requirement} from ${exportNode.id}`,
nextSteps: packageName
? [{
action: 'run_command',
reason: 'Install the missing package with npm, then rerun the enable command.',
example: {
command: `npm install ${packageName}`,
},
}]
: [],
details: {
requirement,
exportId: exportNode.id,
runtime,
},
});
}
function buildRuntimeNameConflictError(runtime, existingExport, nextExport) {
throw new ValidationError(`runtime name conflict for ${runtime}: ${nextExport.runtimeName}`, {
code: 'runtime_name_conflict',
suggestion: `${existingExport.id} and ${nextExport.id} would both materialize to ${nextExport.runtimeName}`,
details: {
runtime,
runtimeName: nextExport.runtimeName,
exports: [existingExport.id, nextExport.id],
},
});
}
function resolveRuntimeClosure(graph, directSelections, runtime) {
const queue = [];
const seen = new Set();
for (const [target, runtimes] of Object.entries(directSelections)) {
if (!(runtimes || []).includes(runtime)) continue;
for (const exportId of listInitialExportIds(graph, target)) {
if (seen.has(exportId)) continue;
seen.add(exportId);
queue.push(exportId);
}
}
while (queue.length > 0) {
const exportId = queue.shift();
const exportNode = graph.exports[exportId];
if (!exportNode) continue;
for (const requirement of exportNode.requires || []) {
const dependencyId = resolveRequirementExportId(graph, requirement);
if (!dependencyId) {
buildMissingDependencyError(requirement, exportNode, runtime);
}
if (seen.has(dependencyId)) continue;
seen.add(dependencyId);
queue.push(dependencyId);
}
}
return [...seen].sort((a, b) => a.localeCompare(b));
}
function buildMaterializationEntry(repoRoot, runtime, exportNode) {
const runtimeDir = RUNTIME_DIRS[runtime];
const target = normalizeDisplayPath(
repoRoot,
join(repoRoot, runtimeDir, 'skills', exportNode.runtimeName)
);
return {
exportId: exportNode.id,
packageName: exportNode.packageName,
skillName: exportNode.name,
runtimeName: exportNode.runtimeName,
skillDirPath: exportNode.skillDirPath,
sourceSkillPath: exportNode.skillPath,
sourceSkillFile: exportNode.skillFile,
target,
mode: 'symlink',
};
}
function buildDesiredMaterializations(repoRoot, directSelections) {
const graph = buildInstalledWorkspaceGraph(repoRoot);
const adapters = {
agents: [],
claude: [],
};
for (const runtime of SUPPORTED_RUNTIMES) {
const seenRuntimeNames = new Map();
for (const exportId of resolveRuntimeClosure(graph, directSelections, runtime)) {
const exportNode = graph.exports[exportId];
if (!exportNode) continue;
const existing = seenRuntimeNames.get(exportNode.runtimeName);
if (existing && existing.id !== exportNode.id) {
buildRuntimeNameConflictError(runtime, existing, exportNode);
}
seenRuntimeNames.set(exportNode.runtimeName, exportNode);
adapters[runtime].push(buildMaterializationEntry(repoRoot, runtime, exportNode));
}
adapters[runtime].sort((a, b) => a.runtimeName.localeCompare(b.runtimeName));
}
return {
graph,
adapters,
};
}
function mutateSelections(currentSelections, targetKey, runtimes, mode) {
const nextSelections = cloneSelections(currentSelections);
const matchingKeys = targetKey.includes(':')
? [targetKey]
: Object.keys(nextSelections).filter((key) => key === targetKey || key.startsWith(`${targetKey}:`));
if (mode === 'enable') {
const current = new Set(nextSelections[targetKey] || []);
for (const runtime of runtimes) current.add(runtime);
nextSelections[targetKey] = [...current].sort((a, b) => a.localeCompare(b));
return nextSelections;
}
for (const key of matchingKeys) {
const current = new Set(nextSelections[key] || []);
for (const runtime of runtimes) current.delete(runtime);
if (current.size === 0) {
delete nextSelections[key];
continue;
}
nextSelections[key] = [...current].sort((a, b) => a.localeCompare(b));
}
return nextSelections;
}
function sortPackages(packages) {
return packages.sort((a, b) => a.packageName.localeCompare(b.packageName));
}
function parseSimpleSemver(version) {
const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
function compareSimpleSemver(left, right) {
const a = parseSimpleSemver(left);
const b = parseSimpleSemver(right);
if (!a || !b) return 0;
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
function classifyUpdateType(currentVersion, availableVersion) {
const current = parseSimpleSemver(currentVersion);
const next = parseSimpleSemver(availableVersion);
if (!current || !next) return 'unknown';
if (next.major !== current.major) return 'major';
if (next.minor !== current.minor) return 'minor';
if (next.patch !== current.patch) return 'patch';
return 'none';
}
function resolveAvailablePackageVersion(repoRoot, packageName, discoveryRoot = process.env.AGENTPACK_DISCOVERY_ROOT) {
const root = discoveryRoot || repoRoot;
const availableDir = findPackageDirByName(root, packageName);
if (!availableDir) return null;
return readPackageMetadata(availableDir).packageVersion;
}
export function listInstalledSkillsUseCase({ cwd = process.cwd() } = {}) {
const repoRoot = findRepoRoot(cwd);
const graph = buildInstalledWorkspaceGraph(repoRoot);
const packages = sortPackages(
Object.values(graph.packages).map((pkg) => ({
availableVersion: resolveAvailablePackageVersion(repoRoot, pkg.packageName),
packageName: pkg.packageName,
packageVersion: pkg.packageVersion,
packagePath: pkg.packagePath,
primaryExport: pkg.primaryExport,
updateAvailable: false,
updateType: null,
exports: pkg.exports
.map((id) => graph.exports[id])
.filter(Boolean)
.sort((a, b) => a.runtimeName.localeCompare(b.runtimeName))
.map((entry) => ({
id: entry.id,
name: entry.name,
runtimeName: entry.runtimeName,
enabled: entry.enabled,
isPrimary: entry.isPrimary,
})),
})).map((pkg) => {
const updateAvailable = Boolean(
pkg.packageVersion
&& pkg.availableVersion
&& compareSimpleSemver(pkg.availableVersion, pkg.packageVersion) > 0
);
return {
...pkg,
updateAvailable,
updateType: updateAvailable
? classifyUpdateType(pkg.packageVersion, pkg.availableVersion)
: null,
};
})
);
return {
repoRoot,
packageCount: packages.length,
exportCount: packages.reduce((sum, pkg) => sum + pkg.exports.length, 0),
packages,
};
}
export function enableInstalledSkillsUseCase(target, {
cwd = process.cwd(),
runtimes,
} = {}) {
const repoRoot = findRepoRoot(cwd);
const resolved = resolveInstalledSkillTarget(repoRoot, target);
const runtimeSelection = normalizeRuntimeSelection(runtimes);
const selectionKey = selectionKeyForResolvedTarget(resolved);
const nextSelections = mutateSelections(readDirectSelections(repoRoot), selectionKey, runtimeSelection, 'enable');
const { adapters } = buildDesiredMaterializations(repoRoot, nextSelections);
applyRuntimeMaterializationPlanUseCase(repoRoot, adapters);
writeDirectSelections(repoRoot, nextSelections);
return {
action: 'enable',
target: selectionKey,
runtimes: runtimeSelection,
exports: resolved.exports.map((entry) => entry.id).sort((a, b) => a.localeCompare(b)),
enabledTargets: Object.keys(nextSelections).sort((a, b) => a.localeCompare(b)),
};
}
export function disableInstalledSkillsUseCase(target, {
cwd = process.cwd(),
runtimes,
} = {}) {
const repoRoot = findRepoRoot(cwd);
const resolved = resolveInstalledSkillTarget(repoRoot, target);
const runtimeSelection = normalizeRuntimeSelection(runtimes);
const selectionKey = selectionKeyForResolvedTarget(resolved);
const nextSelections = mutateSelections(readDirectSelections(repoRoot), selectionKey, runtimeSelection, 'disable');
const { adapters } = buildDesiredMaterializations(repoRoot, nextSelections);
applyRuntimeMaterializationPlanUseCase(repoRoot, adapters);
writeDirectSelections(repoRoot, nextSelections);
return {
action: 'disable',
target: selectionKey,
runtimes: runtimeSelection,
exports: resolved.exports.map((entry) => entry.id).sort((a, b) => a.localeCompare(b)),
enabledTargets: Object.keys(nextSelections).sort((a, b) => a.localeCompare(b)),
};
}
export function inspectInstalledSkillsStatusUseCase({ cwd = process.cwd() } = {}) {
const repoRoot = findRepoRoot(cwd);
const listing = listInstalledSkillsUseCase({ cwd });
const graph = buildInstalledWorkspaceGraph(repoRoot);
const selectionIssues = Object.entries(readDirectSelections(repoRoot))
.filter(([target]) => !graph.packages[target] && !graph.exports[target])
.map(([target, runtimes]) => ({
code: 'enabled_target_not_installed',
target,
runtimes,
}));
const runtimeInspection = inspectMaterializedSkills(repoRoot, { installs: {} });
const enabledExportCount = listing.packages
.flatMap((pkg) => pkg.exports)
.filter((entry) => entry.enabled.length > 0)
.length;
const enabledPackageCount = listing.packages
.filter((pkg) => pkg.exports.some((entry) => entry.enabled.length > 0))
.length;
const health = runtimeInspection.runtimeDriftCount > 0
|| runtimeInspection.orphanedMaterializationCount > 0
|| selectionIssues.length > 0
? 'attention-needed'
: 'healthy';
return {
repoRoot,
installedPackageCount: listing.packageCount,
installedExportCount: listing.exportCount,
enabledPackageCount,
enabledExportCount,
selectionIssueCount: selectionIssues.length,
runtimeDriftCount: runtimeInspection.runtimeDriftCount,
orphanedMaterializationCount: runtimeInspection.orphanedMaterializationCount,
selectionIssues,
runtimeDrift: runtimeInspection.runtimeDrift,
orphanedMaterializations: runtimeInspection.orphanedMaterializations,
packages: listing.packages,
health,
};
}