aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
373 lines • 14.7 kB
JavaScript
/**
* Project-Local Bundle Remove
*
* Reverts a project-local bundle's deployed artifacts from provider paths
* per the case table in `.aiwg/architecture/design-aiwg-remove-revert.md`
* (#1048). Reads artifactHashes from the registry to detect pristine vs
* mutated vs replaced files.
*
* Source under `.aiwg/<type>/<name>/` is NEVER deleted (load-bearing
* invariant — `--force` does not change this).
*
* @design @.aiwg/architecture/design-aiwg-remove-revert.md
* @implements #1037
*/
import { lstat, readdir, stat, unlink } from 'fs/promises';
import { resolve, join, relative } from 'path';
import { homedir } from 'os';
import { appendProjectLocalActivity } from './project-local-activity.js';
import { sha256OfFileNormalized } from './managed-marker.js';
/**
* Hash an artifact file with the managed-marker line stripped.
*
* Source files have no marker (it's deploy-time-injected); deployed files
* do. Stripping the marker on both sides makes the hash insensitive to
* the marker, which is the equivalence relation we actually want for
* pristine/mutated/drift checks. See `managed-marker.ts` for the details.
*
* @implements #1086
*/
async function sha256Hex(absPath) {
return sha256OfFileNormalized(absPath);
}
/**
* Compute hashes for every artifact under a project-local bundle, keyed by
* source-relative path (e.g., "rules/my-rule.md", "skills/x/SKILL.md").
*
* Artifact dirs scanned: agents/, commands/, skills/, rules/. Skill entries
* use the SKILL.md inside the per-skill subdirectory; everything else is a
* top-level .md file.
*/
export async function hashBundleArtifacts(bundleAbsPath) {
const out = {};
// Flat .md dirs
for (const dir of ['agents', 'commands', 'rules']) {
const dirAbs = join(bundleAbsPath, dir);
let entries;
try {
entries = await readdir(dirAbs);
}
catch {
continue;
}
for (const e of entries) {
if (!e.endsWith('.md'))
continue;
if (e === 'README.md' || e === 'RULES-INDEX.md' || e === 'INDEX.md')
continue;
const fileAbs = join(dirAbs, e);
try {
out[`${dir}/${e}`] = await sha256Hex(fileAbs);
}
catch {
// Read failures are non-fatal — skip the artifact
}
}
}
// Skill subdirs
const skillsAbs = join(bundleAbsPath, 'skills');
let skillEntries;
try {
skillEntries = await readdir(skillsAbs);
}
catch {
skillEntries = [];
}
for (const skill of skillEntries) {
const skillDir = join(skillsAbs, skill);
try {
const st = await stat(skillDir);
if (!st.isDirectory())
continue;
}
catch {
continue;
}
const skillMd = join(skillDir, 'SKILL.md');
try {
out[`skills/${skill}/SKILL.md`] = await sha256Hex(skillMd);
}
catch {
// No SKILL.md — skip
}
}
return out;
}
/**
* Provider-specific deploy-path conventions for the artifact directories
* we currently emit. Keyed by provider, value is the prefix relative to
* the project (or HOME for HOME-deploying providers — those are out of
* scope for revert in this iteration; they are silently skipped with a
* message).
*/
// Per PUW-026 (#1127): home-deploying providers get absolute prefixes so
// `resolve(projectDir, prefix)` correctly produces the home-rooted path.
// Previously these were `null`, which silently skipped lifecycle ops
// against home-deployed project-local bundles (OpenClaw, Hermes).
const PROVIDER_PREFIX = {
claude: '.claude',
cursor: '.cursor',
factory: '.factory',
opencode: '.opencode',
windsurf: '.windsurf',
warp: '.warp',
codex: '.codex',
copilot: '.github', // copilot uses .github/agents, .github/instructions, .github/skills
openclaw: resolve(homedir(), '.openclaw'),
hermes: resolve(homedir(), '.hermes'),
};
/**
* Translate a source-relative artifact path to the provider's deploy path,
* relative to the project (or HOME) root. Returns null when the provider
* is HOME-deploying (revert from HOME is out of scope for this iteration).
*
* For most providers this is a 1:1 mapping (rules/x.md → .{p}/rules/x.md).
*/
function deployedPathFor(provider, sourceRel) {
const prefix = PROVIDER_PREFIX[provider];
if (!prefix)
return null;
// Some providers rename rules to .mdc (cursor); revert checks both.
return `${prefix}/${sourceRel}`;
}
/** Try multiple plausible deployed paths (e.g., .md vs .mdc for rules). */
function candidateDeployedPaths(provider, sourceRel) {
const paths = [];
const main = deployedPathFor(provider, sourceRel);
if (main)
paths.push(main);
// Cursor rule rename
if (provider === 'cursor' && sourceRel.startsWith('rules/') && sourceRel.endsWith('.md')) {
paths.push(`.cursor/${sourceRel.slice(0, -3)}.mdc`);
}
return paths;
}
async function tryUnlink(absPath) {
try {
await unlink(absPath);
return { deleted: true };
}
catch (err) {
const e = err;
if (e.code === 'ENOENT')
return { deleted: false };
if (e.code === 'EACCES' || e.code === 'EROFS')
return { deleted: false, permission: true };
throw err;
}
}
async function classify(expectedHash, absPath) {
if (!expectedHash)
return 'unhashed';
try {
await lstat(absPath);
}
catch (err) {
if (err.code === 'ENOENT')
return 'missing';
return 'missing';
}
let actual;
try {
actual = await sha256Hex(absPath);
}
catch (err) {
if (err.code === 'EACCES')
return 'permission';
return 'missing';
}
if (actual === expectedHash)
return 'pristine';
// Mutated vs replaced — detection requires reading the file's frontmatter
// id and comparing to the bundle's artifact id. For this iteration we
// conservatively treat all hash-differs as 'mutated'; the design's case-4
// ("replaced by another bundle's artifact") is detected by registry-level
// ownership lookup (see resolveOwnership below) rather than per-file id.
return 'mutated';
}
/**
* Look up which other installed bundle (if any) records this deployed-path
* in its own registry entry. If found, the file belongs to another bundle —
* Case 4 (replaced).
*/
function resolveOwnership(config, selfBundleId, provider, sourceRel) {
for (const [name, entry] of Object.entries(config.installed)) {
if (name === selfBundleId)
continue;
if (entry.source !== 'project-local')
continue;
if (!entry.artifactHashes)
continue;
if (sourceRel in entry.artifactHashes) {
// Same source-rel path claimed by another project-local bundle —
// the deployed file (if present) is theirs, not ours.
void provider;
return name;
}
}
return null;
}
/**
* Remove a project-local bundle's deploys per the design at
* `.aiwg/architecture/design-aiwg-remove-revert.md`.
*
* Pure function over (config, projectDir, opts). The caller is responsible
* for persisting the returned (mutated) config.
*/
export async function removeProjectLocalBundle(config, projectDir, bundleId, opts = {}) {
const entry = config.installed[bundleId];
const isProjectLocal = entry !== undefined && entry.source === 'project-local';
if (!isProjectLocal) {
return {
found: false,
outcomes: [],
revertedProviders: [],
partialProviders: [],
isProjectLocal: false,
};
}
const { force = false, provider: onlyProvider, dryRun = false, keepRegistry = false } = opts;
const confirmMutation = opts.confirmMutation ?? (async () => false);
const installedEntry = entry;
const artifactHashes = installedEntry.artifactHashes ?? {};
const providers = Object.keys(installedEntry.deployedTo).filter(p => !onlyProvider || p === onlyProvider);
const outcomes = [];
const revertedProviders = [];
const partialProviders = [];
for (const provider of providers) {
let providerHadSkip = false;
for (const sourceRel of Object.keys(artifactHashes)) {
const owner = resolveOwnership(config, bundleId, provider, sourceRel);
if (owner) {
providerHadSkip = true;
outcomes.push({
provider,
artifactPath: sourceRel,
deployedAbsPath: '(other bundle)',
case: 'replaced',
reverted: false,
message: `owned by '${owner}' — refusing to delete`,
});
if (!dryRun) {
await appendProjectLocalActivity({
event: 'remove-conflict',
name: bundleId,
type: installedEntry.localType ?? 'extension',
summary: `${provider}:${sourceRel} owned by ${owner}`,
});
}
continue;
}
const candidates = candidateDeployedPaths(provider, sourceRel);
let resolvedAbs = null;
let detectedCase = 'missing';
for (const c of candidates) {
const abs = resolve(projectDir, c);
const k = await classify(artifactHashes[sourceRel], abs);
if (k !== 'missing') {
resolvedAbs = abs;
detectedCase = k;
break;
}
}
if (!resolvedAbs) {
// Pick the first candidate just so the outcome carries a path
const fallback = candidates[0] ? resolve(projectDir, candidates[0]) : '(unknown)';
outcomes.push({
provider,
artifactPath: sourceRel,
deployedAbsPath: fallback,
case: 'missing',
reverted: false,
message: 'already absent',
});
continue;
}
// Cases
if (detectedCase === 'pristine') {
if (dryRun) {
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: 'pristine', reverted: false, message: '[dry-run] would revert' });
}
else {
const r = await tryUnlink(resolvedAbs);
if (r.permission) {
providerHadSkip = true;
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: 'permission', reverted: false, message: 'permission denied' });
}
else {
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: 'pristine', reverted: r.deleted, message: r.deleted ? 'reverted' : 'already absent' });
}
}
continue;
}
if (detectedCase === 'mutated' || detectedCase === 'unhashed') {
const allowDelete = force || (await confirmMutation({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: detectedCase, reverted: false, message: 'mutation detected' }));
if (!allowDelete) {
providerHadSkip = true;
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: detectedCase, reverted: false, message: 'mutation skipped (--force to override)' });
if (!dryRun) {
await appendProjectLocalActivity({
event: 'remove-mutated',
name: bundleId,
type: installedEntry.localType ?? 'extension',
summary: `${provider}:${sourceRel} skipped`,
});
}
continue;
}
if (dryRun) {
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: detectedCase, reverted: false, message: '[dry-run] would revert (mutation override)' });
continue;
}
const r = await tryUnlink(resolvedAbs);
if (r.permission) {
providerHadSkip = true;
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: 'permission', reverted: false, message: 'permission denied' });
}
else {
outcomes.push({ provider, artifactPath: sourceRel, deployedAbsPath: resolvedAbs, case: detectedCase, reverted: r.deleted, message: r.deleted ? 'reverted (mutation overridden)' : 'already absent' });
}
}
}
if (providerHadSkip) {
partialProviders.push(provider);
}
else {
revertedProviders.push(provider);
}
// Mutate registry for fully-reverted providers
if (!dryRun && !keepRegistry && !providerHadSkip) {
delete installedEntry.deployedTo[provider];
}
}
// Top-level remove activity entry
if (!dryRun) {
const summary = providers
.map(p => {
const reverted = outcomes.filter(o => o.provider === p && o.reverted).length;
const skipped = outcomes.filter(o => o.provider === p && !o.reverted).length;
return `${p}=${reverted} reverted${skipped > 0 ? `, ${skipped} skipped` : ''}`;
})
.join(', ');
await appendProjectLocalActivity({
event: force ? 'remove-force' : 'remove',
name: bundleId,
type: installedEntry.localType ?? 'extension',
summary,
});
}
// Drop the entire `installed` entry only when every provider fully reverted
if (!dryRun && !keepRegistry && Object.keys(installedEntry.deployedTo).length === 0) {
delete config.installed[bundleId];
}
// Note: source under .aiwg/<type>/<name>/ is NEVER touched.
void relative;
return {
found: true,
outcomes,
revertedProviders,
partialProviders,
isProjectLocal: true,
};
}
//# sourceMappingURL=project-local-remove.js.map