Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .agents/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ The agents package is a CLI tool (`codeassembly-agents`) that installs reusable

**CLI commands:**

| Command | Description |
| ------------------- | ----------------------------------------------------------------- |
| `generate <target>` | Scaffolds project files (`label-map`) |
| `install` | Copies or symlinks skills and subagents into platform directories |
| `status` | Shows current vs modified vs missing installed items |
| `uninstall` | Removes previously installed items (respects drift detection) |
| Command | Description |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `generate <target>` | Scaffolds project files (`label-map`) |
| `install` | Copies or symlinks skills and subagents into platform directories; prunes deleted-source files |
| `status` | Shows current vs modified vs missing installed items |
| `uninstall` | Removes previously installed items (respects drift detection) |

Key flags: `--platform <claude|rovodev|all>`, `--link` (symlink instead of copy), `--force` (overwrite modified), `--dry-run`.

Expand Down
4 changes: 2 additions & 2 deletions packages/agents/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ function printUsage(): void {
console.info(`Usage: codeassembly-agents <command> [options]

Commands:
install Install guidance, skills, and subagents into harness directories
install Install guidance, skills, and subagents into harness directories, removing files whose source was deleted
init Scaffold an empty .agents/rulebooks.yaml in the current project
sync Resolve .agents/rulebooks.yaml and materialize declared rulebooks
uninstall Remove installed guidance, skills, and subagents
Expand All @@ -175,7 +175,7 @@ Commands:
Options:
--harness <name> Target harness: claude, rovodev, or all (default: all)
--link Use symlinks instead of copies (install only)
--force Overwrite modified files (install/uninstall)
--force Overwrite or remove modified files (install/uninstall)
--dry-run Show what would be done without making changes (install, sync, init)
--help, -h Show this help message`);
}
Expand Down
194 changes: 194 additions & 0 deletions packages/agents/src/commands/__tests__/install-prune.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { getManifestPath, readManifest } from '../../lib/manifest.ts';
import type { InstallOptions } from '../../lib/types.ts';
import { installCommand } from '../install.ts';

describe('install stale-file pruning', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = path.join(tmpdir(), `agents-test-prune-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(tempDir, { recursive: true });
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

function makeOptions(overrides: Partial<InstallOptions> = {}): InstallOptions {
return { harness: 'claude', link: false, force: false, dryRun: false, ...overrides };
}

it('removes an installed skill directory whose source was deleted', async () => {
const contentDir = await buildContent({
skills: { keep: { 'SKILL.md': skillBody('keep') }, drop: { 'SKILL.md': skillBody('drop') } },
});
await installCommand(makeOptions(), tempDir, contentDir);
expect(existsSync(path.join(tempDir, '.claude', 'skills', 'drop'))).toBe(true);

await rm(path.join(contentDir, 'skills', 'drop'), { recursive: true, force: true });
await installCommand(makeOptions(), tempDir, contentDir);

expect(existsSync(path.join(tempDir, '.claude', 'skills', 'drop'))).toBe(false);
expect(existsSync(path.join(tempDir, '.claude', 'skills', 'keep'))).toBe(true);
});

it('removes orphaned subagent, script, and shared-guidance files whose sources were deleted', async () => {
const contentDir = await buildContent({
subagents: { 'keep-agent.md': agentBody('keep-agent'), 'drop-agent.md': agentBody('drop-agent') },
scripts: { 'keep.sh': scriptBody, 'drop.sh': scriptBody },
shared: { 'AGENTS.md': '# Shared\n', 'EXTRA.md': '# Extra\n' },
});
await installCommand(makeOptions(), tempDir, contentDir);

await rm(path.join(contentDir, 'subagents', 'drop-agent.md'));
await rm(path.join(contentDir, 'scripts', 'drop.sh'));
await rm(path.join(contentDir, 'guidance', 'shared', 'EXTRA.md'));
await installCommand(makeOptions(), tempDir, contentDir);

expect(existsSync(path.join(tempDir, '.claude', 'agents', 'drop-agent.md'))).toBe(false);
expect(existsSync(path.join(tempDir, '.claude', 'scripts', 'drop.sh'))).toBe(false);
expect(existsSync(path.join(tempDir, '.agents', 'EXTRA.md'))).toBe(false);
expect(existsSync(path.join(tempDir, '.claude', 'agents', 'keep-agent.md'))).toBe(true);
expect(existsSync(path.join(tempDir, '.claude', 'scripts', 'keep.sh'))).toBe(true);
expect(existsSync(path.join(tempDir, '.agents', 'AGENTS.md'))).toBe(true);
});

it('keeps a user-modified orphan without --force and retains it in the manifest', async () => {
const contentDir = await buildContent({ scripts: { 'keep.sh': scriptBody, 'drop.sh': scriptBody } });
await installCommand(makeOptions(), tempDir, contentDir);

const installedScript = path.join(tempDir, '.claude', 'scripts', 'drop.sh');
await writeFile(installedScript, '#!/usr/bin/env bash\necho edited\n', 'utf8');
await rm(path.join(contentDir, 'scripts', 'drop.sh'));
await installCommand(makeOptions(), tempDir, contentDir);

expect(existsSync(installedScript)).toBe(true);
const manifest = await readManifest(getManifestPath(tempDir));
const paths = manifest.harnesses.claude?.entries.map((entry) => entry.relativePath) ?? [];
expect(paths).toContain('scripts/drop.sh');
});

it('removes a user-modified orphan when --force is set', async () => {
const contentDir = await buildContent({ scripts: { 'keep.sh': scriptBody, 'drop.sh': scriptBody } });
await installCommand(makeOptions(), tempDir, contentDir);

const installedScript = path.join(tempDir, '.claude', 'scripts', 'drop.sh');
await writeFile(installedScript, '#!/usr/bin/env bash\necho edited\n', 'utf8');
await rm(path.join(contentDir, 'scripts', 'drop.sh'));
await installCommand(makeOptions({ force: true }), tempDir, contentDir);

expect(existsSync(installedScript)).toBe(false);
const manifest = await readManifest(getManifestPath(tempDir));
const paths = manifest.harnesses.claude?.entries.map((entry) => entry.relativePath) ?? [];
expect(paths).not.toContain('scripts/drop.sh');
});

it('in dry-run, leaves an orphan on disk and does not rewrite the manifest', async () => {
const contentDir = await buildContent({
skills: { keep: { 'SKILL.md': skillBody('keep') }, drop: { 'SKILL.md': skillBody('drop') } },
});
await installCommand(makeOptions(), tempDir, contentDir);

await rm(path.join(contentDir, 'skills', 'drop'), { recursive: true, force: true });
await installCommand(makeOptions({ dryRun: true }), tempDir, contentDir);

expect(existsSync(path.join(tempDir, '.claude', 'skills', 'drop'))).toBe(true);
const manifest = await readManifest(getManifestPath(tempDir));
const paths = manifest.harnesses.claude?.entries.map((entry) => entry.relativePath) ?? [];
expect(paths).toContain('skills/drop');
});

it('removes a file deleted from within a still-present multi-file skill', async () => {
const contentDir = await buildContent({
skills: {
multi: { 'SKILL.md': skillBody('multi'), 'modules/old.md': '# old\n', 'modules/keep.md': '# keep\n' },
},
});
await installCommand(makeOptions(), tempDir, contentDir);
expect(existsSync(path.join(tempDir, '.claude', 'skills', 'multi', 'modules', 'old.md'))).toBe(true);

await rm(path.join(contentDir, 'skills', 'multi', 'modules', 'old.md'));
await installCommand(makeOptions(), tempDir, contentDir);

expect(existsSync(path.join(tempDir, '.claude', 'skills', 'multi', 'modules', 'old.md'))).toBe(false);
expect(existsSync(path.join(tempDir, '.claude', 'skills', 'multi', 'modules', 'keep.md'))).toBe(true);
expect(existsSync(path.join(tempDir, '.claude', 'skills', 'multi', 'SKILL.md'))).toBe(true);
});

it('does not clean a coincidentally same-named directory on first install', async () => {
const preExisting = path.join(tempDir, '.claude', 'skills', 'multi');
await mkdir(preExisting, { recursive: true });
await writeFile(path.join(preExisting, 'user-note.md'), '# mine\n', 'utf8');

const contentDir = await buildContent({ skills: { multi: { 'SKILL.md': skillBody('multi') } } });
await installCommand(makeOptions(), tempDir, contentDir);

expect(existsSync(path.join(preExisting, 'user-note.md'))).toBe(true);
expect(existsSync(path.join(preExisting, 'SKILL.md'))).toBe(true);
});

// region | Helpers

function skillBody(name: string): string {
return `---\nname: ${name}\ndescription: Test skill\n---\n# ${name}\n`;
}

function agentBody(name: string): string {
return `---\nname: ${name}\ndescription: Test agent\n---\n# ${name}\n`;
}

const scriptBody = '#!/usr/bin/env bash\necho hi\n';

/**
* Builds a minimal content tree under a fresh temp directory and returns its path. Only the surfaces named in
* `options` get extra files; the baseline (shared `AGENTS.md`, claude guidance, subagent overlay) is always present
* so the install pipeline runs end to end.
*/
async function buildContent(options: {
skills?: Record<string, Record<string, string>>;
subagents?: Record<string, string>;
scripts?: Record<string, string>;
shared?: Record<string, string>;
}): Promise<string> {
const contentDir = path.join(tempDir, 'content');
await mkdir(path.join(contentDir, 'guidance', 'shared'), { recursive: true });
await mkdir(path.join(contentDir, 'guidance', '_harnesses', 'claude'), { recursive: true });
await mkdir(path.join(contentDir, 'subagents', '_data'), { recursive: true });
await mkdir(path.join(contentDir, 'skills'), { recursive: true });
await mkdir(path.join(contentDir, 'scripts'), { recursive: true });

await writeFile(path.join(contentDir, 'guidance', '_harnesses', 'claude', 'CLAUDE.md'), '# Claude\n', 'utf8');
await writeFile(path.join(contentDir, 'subagents', '_data', 'claude.yaml'), '_defaults: {}\n', 'utf8');

const shared = options.shared ?? { 'AGENTS.md': '# Shared\n' };
for (const [name, body] of Object.entries(shared)) {
await writeFile(path.join(contentDir, 'guidance', 'shared', name), body, 'utf8');
}
for (const [name, body] of Object.entries(options.subagents ?? {})) {
await writeFile(path.join(contentDir, 'subagents', name), body, 'utf8');
}
for (const [name, body] of Object.entries(options.scripts ?? {})) {
await writeFile(path.join(contentDir, 'scripts', name), body, 'utf8');
}
for (const [skillName, files] of Object.entries(options.skills ?? {})) {
const skillDir = path.join(contentDir, 'skills', skillName);
await mkdir(skillDir, { recursive: true });
for (const [fileName, body] of Object.entries(files)) {
const fullPath = path.join(skillDir, fileName);
await mkdir(path.dirname(fullPath), { recursive: true });
await writeFile(fullPath, body, 'utf8');
}
}

return contentDir;
}

// endregion | Helpers
});
19 changes: 18 additions & 1 deletion packages/agents/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { resolveContentDir } from '../lib/content-resolver.ts';
import { expandIncludes } from '../lib/directive-expander.ts';
import { mergeFrontmatter, parseFrontmatter } from '../lib/frontmatter-merger.ts';
import { HARNESSES, resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js';
import { checkSymlinkSafety, copyItem, linkItem, unlinkIfSymlink } from '../lib/installer.ts';
import { checkSymlinkSafety, copyItem, linkItem, removeItem, unlinkIfSymlink } from '../lib/installer.ts';
import {
computeContentHash,
detectDrift,
Expand All @@ -20,6 +20,7 @@ import {
injectMarkersInDirectory,
injectProvenanceMarker,
} from '../lib/marker-injector.js';
import { pruneOrphanedEntries } from '../lib/orphan-pruner.ts';
import { rewritePathsInDirectory, rewritePathsInFile } from '../lib/path-rewriter.js';
import { loadToolMapping, rewriteToolNames } from '../lib/tool-name-rewriter.js';
import { isEnoent, isMissingFile } from '../lib/type-guards.ts';
Expand Down Expand Up @@ -137,6 +138,11 @@ export async function installCommand(
}
}

// Reconcile against the previous manifest: remove files whose source was deleted. Runs before the dry-run
// gate so `--dry-run` previews removals. User-modified orphans are kept (unless `--force`) and stay tracked.
const { retained } = await pruneOrphanedEntries(existingEntries, entries, paths.harnessHome, options);
entries.push(...retained);

if (options.dryRun) {
console.info(` [dry-run] Would install ${entries.length} items:`);
console.info(` ${skillEntries.length} skill items`);
Expand Down Expand Up @@ -315,6 +321,12 @@ async function installSkillEntry(
if (expandedDirContents === undefined) {
throw new Error(`Invariant violation: expandedDirContents undefined for directory ${srcPath}`);
}
// Clean-write directories CodeAssembly previously installed: remove the prior copy so files deleted from the
// source skill don't survive in the destination. Gated on prior ownership (a manifest entry exists) so a
// first-time install never wipes a coincidentally same-named directory the user already had.
if (existingEntry) {
await removeItem(destPath);
}
await writeExpandedSkillDir(srcPath, destPath, expandedDirContents);
const skillsDestDir = path.dirname(destPath);
await rewritePathsInDirectory(destPath, skillsDestDir, skillsPrefix, homeDir, harnessId);
Expand Down Expand Up @@ -769,6 +781,11 @@ async function installSharedGuidance(
});
}

// Reconcile shared guidance against the previous manifest before reporting or persisting, so deleted-source
// files are removed from `~/.agents/` too. User-modified orphans are kept (unless `--force`) and stay tracked.
const { retained } = await pruneOrphanedEntries(existingEntries, entries, sharedHome, options);
entries.push(...retained);

if (options.dryRun) {
console.info(` [dry-run] Would install ${entries.length} shared guidance items`);
return undefined;
Expand Down
112 changes: 112 additions & 0 deletions packages/agents/src/lib/__tests__/orphan-pruner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { existsSync, lstatSync } from 'node:fs';
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { computeContentHash } from '../manifest.ts';
import { pruneOrphanedEntries } from '../orphan-pruner.ts';
import type { ManifestEntry } from '../types.ts';

describe(pruneOrphanedEntries, () => {
let home: string;

beforeEach(async () => {
home = path.join(tmpdir(), `agents-test-prune-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(home, { recursive: true });
});

afterEach(async () => {
await rm(home, { recursive: true, force: true });
});

async function writeTracked(relativePath: string, content: string): Promise<ManifestEntry> {
const fullPath = path.join(home, relativePath);
await mkdir(path.dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf8');
return { relativePath, contentHash: await computeContentHash(fullPath), linked: false };
}

function options(overrides: Partial<{ force: boolean; dryRun: boolean }> = {}): { force: boolean; dryRun: boolean } {
return { force: false, dryRun: false, ...overrides };
}

it('removes an unmodified orphan and omits it from retained', async () => {
const entry = await writeTracked('skills/gone/SKILL.md', 'body');

const result = await pruneOrphanedEntries([entry], [], home, options());

expect(existsSync(path.join(home, entry.relativePath))).toBe(false);
expect(result.removedPaths).toEqual([entry.relativePath]);
expect(result.retained).toEqual([]);
});

it('leaves an entry whose path is still in the current set', async () => {
const entry = await writeTracked('skills/kept/SKILL.md', 'body');

const result = await pruneOrphanedEntries([entry], [entry], home, options());

expect(existsSync(path.join(home, entry.relativePath))).toBe(true);
expect(result.removedPaths).toEqual([]);
});

it('retains a user-modified orphan when force is unset', async () => {
const entry = await writeTracked('scripts/edited.sh', 'original');
await writeFile(path.join(home, entry.relativePath), 'user edit', 'utf8');

const result = await pruneOrphanedEntries([entry], [], home, options());

expect(existsSync(path.join(home, entry.relativePath))).toBe(true);
expect(result.retained).toEqual([entry]);
expect(result.removedPaths).toEqual([]);
});

it('removes a user-modified orphan when force is set', async () => {
const entry = await writeTracked('scripts/edited.sh', 'original');
await writeFile(path.join(home, entry.relativePath), 'user edit', 'utf8');

const result = await pruneOrphanedEntries([entry], [], home, options({ force: true }));

expect(existsSync(path.join(home, entry.relativePath))).toBe(false);
expect(result.removedPaths).toEqual([entry.relativePath]);
expect(result.retained).toEqual([]);
});

it('drops an already-absent orphan without error', async () => {
const entry: ManifestEntry = {
relativePath: 'skills/never/SKILL.md',
contentHash: 'sha256:absent',
linked: false,
};

const result = await pruneOrphanedEntries([entry], [], home, options());

expect(result.removedPaths).toEqual([entry.relativePath]);
expect(result.retained).toEqual([]);
});

it('removes a dangling symlink orphan', async () => {
const target = path.join(home, 'target.sh');
await writeFile(target, 'content', 'utf8');
const linkPath = path.join(home, 'scripts', 'linked.sh');
await mkdir(path.dirname(linkPath), { recursive: true });
await symlink(target, linkPath);
await rm(target); // Leave the symlink dangling, as a deleted source would.
const entry: ManifestEntry = { relativePath: 'scripts/linked.sh', contentHash: 'sha256:linked', linked: true };

const result = await pruneOrphanedEntries([entry], [], home, options());

expect(() => lstatSync(linkPath)).toThrow();
expect(result.removedPaths).toEqual([entry.relativePath]);
});

it('records a removal in dry-run without deleting the file', async () => {
const entry = await writeTracked('skills/gone/SKILL.md', 'body');

const result = await pruneOrphanedEntries([entry], [], home, options({ dryRun: true }));

expect(existsSync(path.join(home, entry.relativePath))).toBe(true);
expect(result.removedPaths).toEqual([entry.relativePath]);
});
});
Loading
Loading