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
76 changes: 73 additions & 3 deletions packages/agents/src/commands/__tests__/uninstall.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import assert from 'node:assert';
import { existsSync } from 'node:fs';
import { mkdir, readFile, 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 { readManifest } from '../../lib/manifest.js';
import { getManifestPath } from '../../lib/manifest.js';
import type { InstallOptions } from '../../lib/types.js';
import { computeContentHash, getManifestPath, readManifest, writeManifest } from '../../lib/manifest.js';
import type { AgentsManifest, InstallOptions } from '../../lib/types.js';
import { installCommand } from '../install.js';
import { uninstallCommand } from '../uninstall.js';

Expand Down Expand Up @@ -91,6 +91,76 @@ describe('uninstallCommand', () => {
expect(manifest.platforms.claude).toBeDefined();
});

it('should retain only skipped entries in platform manifest after partial uninstall', async () => {
const claudeHome = path.join(tempDir, '.claude');
await mkdir(path.join(claudeHome, 'skills'), { recursive: true });
await mkdir(path.join(claudeHome, 'agents'), { recursive: true });

// Install
await installCommand(makeInstallOptions(), tempDir);

// Modify one installed subagent file
const agentFile = path.join(claudeHome, 'agents', 'orchestrated-coder.md');
const original = await readFile(agentFile, 'utf8');
await writeFile(agentFile, original + '\n<!-- user modification -->', 'utf8');

// Uninstall without force — modified file is skipped, others are removed
await uninstallCommand({ platform: 'claude', force: false }, tempDir);

// Platform manifest should contain only the skipped entry
const manifest = await readManifest(getManifestPath(tempDir));
const claudeEntries = manifest.platforms.claude?.entries;
assert.ok(claudeEntries, 'Expected claude platform entries to be defined');
expect(claudeEntries).toHaveLength(1);
expect(claudeEntries[0]?.relativePath).toBe('agents/orchestrated-coder.md');
});

it('should retain only skipped entries in shared manifest after partial uninstall', async () => {
const sharedHome = path.join(tempDir, '.agents');
await mkdir(sharedHome, { recursive: true });

// Create two shared guidance files on disk
const fileA = path.join(sharedHome, 'AGENTS.md');
const fileB = path.join(sharedHome, 'EXTRA.md');
await writeFile(fileA, 'original content A', 'utf8');
await writeFile(fileB, 'original content B', 'utf8');

// Write a synthetic manifest with two shared entries
const hashA = await computeContentHash(fileA);
const hashB = await computeContentHash(fileB);
const manifest: AgentsManifest = {
schemaVersion: 1,
shared: {
version: '0.0.0',
installedAt: new Date().toISOString(),
entries: [
{ relativePath: 'AGENTS.md', contentHash: hashA, linked: false },
{ relativePath: 'EXTRA.md', contentHash: hashB, linked: false },
],
},
platforms: {},
};
await writeManifest(getManifestPath(tempDir), manifest);

// Modify one file so it gets skipped
await writeFile(fileA, 'modified content A', 'utf8');

// Uninstall without force — AGENTS.md is skipped, EXTRA.md is removed
await uninstallCommand({ platform: 'claude', force: false }, tempDir);

// Shared manifest should contain only the skipped entry
const updated = await readManifest(getManifestPath(tempDir));
const sharedEntries = updated.shared?.entries;
assert.ok(sharedEntries, 'Expected shared entries to be defined');
expect(sharedEntries).toHaveLength(1);
expect(sharedEntries[0]?.relativePath).toBe('AGENTS.md');

// EXTRA.md should be deleted from disk
expect(existsSync(fileB)).toBe(false);
// AGENTS.md should still exist
expect(existsSync(fileA)).toBe(true);
});

it('should remove modified files when force is true', async () => {
const claudeHome = path.join(tempDir, '.claude');
await mkdir(path.join(claudeHome, 'skills'), { recursive: true });
Expand Down
29 changes: 17 additions & 12 deletions packages/agents/src/commands/uninstall.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { removeItem } from '../lib/installer.js';
import { detectDrift, getManifestPath, readManifest, resolveSharedHome, writeManifest } from '../lib/manifest.js';
import { resolvePlatformIds, resolvePlatformPaths } from '../lib/platform.js';
import type { AgentsManifest, InstallOptions, SharedManifest } from '../lib/types.js';
import type { AgentsManifest, InstallOptions, ManifestEntry, SharedManifest } from '../lib/types.js';

/**
* Executes the uninstall command, removing installed skills, subagents, and guidance files.
Expand Down Expand Up @@ -36,7 +36,7 @@ export async function uninstallCommand(
console.info(`\nUninstalling for platform: ${platformId}`);
const paths = resolvePlatformPaths(platformId, baseDir);
let removedCount = 0;
let skippedCount = 0;
const skippedEntries: ManifestEntry[] = [];

for (const entry of platformManifest.entries) {
const drift = await detectDrift(entry, paths.platformHome);
Expand All @@ -49,7 +49,7 @@ export async function uninstallCommand(

if (drift === 'modified' && !options.force) {
console.warn(` Skipping modified file: ${entry.relativePath}`);
skippedCount++;
skippedEntries.push(entry);
continue;
}

Expand All @@ -58,13 +58,18 @@ export async function uninstallCommand(
removedCount++;
}

// Only remove platform from manifest when all entries were successfully removed
if (skippedCount === 0) {
// Remove platform from manifest or retain only skipped entries
if (skippedEntries.length === 0) {
const { [platformId]: _removed, ...rest } = remainingPlatforms;
remainingPlatforms = rest;
} else {
remainingPlatforms = {
...remainingPlatforms,
[platformId]: { ...platformManifest, entries: skippedEntries },
};
}

console.info(` Removed ${removedCount} items, skipped ${skippedCount} modified items`);
console.info(` Removed ${removedCount} items, skipped ${skippedEntries.length} modified items`);
}

const updatedManifest: AgentsManifest = {
Expand Down Expand Up @@ -94,7 +99,7 @@ async function uninstallSharedGuidance(
console.info('\nUninstalling shared guidance');
const sharedHome = resolveSharedHome(baseDir);
let removedCount = 0;
let skippedCount = 0;
const skippedEntries: ManifestEntry[] = [];

for (const entry of sharedManifest.entries) {
const drift = await detectDrift(entry, sharedHome);
Expand All @@ -106,7 +111,7 @@ async function uninstallSharedGuidance(

if (drift === 'modified' && !options.force) {
console.warn(` Skipping modified file: ~/.agents/${entry.relativePath}`);
skippedCount++;
skippedEntries.push(entry);
continue;
}

Expand All @@ -115,11 +120,11 @@ async function uninstallSharedGuidance(
removedCount++;
}

console.info(` Removed ${removedCount} items, skipped ${skippedCount} modified items`);
console.info(` Removed ${removedCount} items, skipped ${skippedEntries.length} modified items`);

// Retain shared manifest only if some entries were skipped
if (skippedCount > 0) {
return sharedManifest;
// Retain shared manifest only with the entries that were skipped
if (skippedEntries.length > 0) {
return { ...sharedManifest, entries: skippedEntries };
}
return undefined;
}
Loading