Skip to content
Open
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
28 changes: 27 additions & 1 deletion packages/cli/src/__tests__/commands/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerSkillCommand } from '../../commands/skill.js';
import { ui } from '../../util/terminal-ui.js';

const mockAddSkill = vi.fn();
const mockRemoveSkill = vi.fn();

vi.mock('../../lib/Config.js', () => ({
ConfigManager: vi.fn(),
Expand All @@ -13,7 +14,7 @@ vi.mock('../../lib/SkillManager.js', () => ({
SkillManager: vi.fn(function () { return {
addSkill: (...args: unknown[]) => mockAddSkill(...args),
listSkills: vi.fn(),
removeSkill: vi.fn(),
removeSkill: (...args: unknown[]) => mockRemoveSkill(...args),
updateSkills: vi.fn(),
findSkills: vi.fn(),
rebuildIndex: vi.fn(),
Expand All @@ -34,6 +35,7 @@ describe('skill command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAddSkill.mockImplementation(async () => undefined);
mockRemoveSkill.mockImplementation(async () => undefined);
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any);
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as any);
});
Expand Down Expand Up @@ -134,4 +136,28 @@ describe('skill command', () => {
expect(addCommand?.usage()).toContain('[registry-repo]');
expect(addCommand?.usage()).toContain('[skill-name]');
});

it('forwards global removal options to the skill manager', async () => {
const program = new Command();
registerSkillCommand(program);

await program.parseAsync(['node', 'test', 'skill', 'remove', 'frontend-design', '--global', '--env', 'claude', 'codex']);

expect(mockRemoveSkill).toHaveBeenCalledWith('frontend-design', {
global: true,
environments: ['claude', 'codex'],
});
});

it('preserves project removal options when global flags are absent', async () => {
const program = new Command();
registerSkillCommand(program);

await program.parseAsync(['node', 'test', 'skill', 'remove', 'frontend-design']);

expect(mockRemoveSkill).toHaveBeenCalledWith('frontend-design', {
global: undefined,
environments: undefined,
});
});
});
81 changes: 81 additions & 0 deletions packages/cli/src/__tests__/lib/SkillManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock("fs-extra", () => ({
ensureDir: vi.fn(),
symlink: vi.fn(),
copy: vi.fn(),
lstat: vi.fn(),
remove: vi.fn(),
readdir: vi.fn(),
realpath: vi.fn(),
Expand Down Expand Up @@ -932,6 +933,7 @@ describe("SkillManager", () => {
(mockedFs.pathExists as any).mockResolvedValue(true);
(mockedFs.remove as any).mockResolvedValue(undefined);
mockConfigManager.removeSkill.mockResolvedValue({} as any);
(mockedFs.lstat as any).mockResolvedValue({ isSymbolicLink: () => false });
});

it("should validate skill name", async () => {
Expand Down Expand Up @@ -1023,6 +1025,85 @@ describe("SkillManager", () => {
"No skill-capable environments configured",
);
});

it("should reject env selection without global removal", async () => {
await expect(
skillManager.removeSkill(mockSkillName, { environments: ["claude"] }),
).rejects.toThrow("--env can only be used with --global");

expect(mockConfigManager.read).not.toHaveBeenCalled();
expect(mockedFs.remove).not.toHaveBeenCalled();
});

it("should remove only from selected global environments", async () => {
await skillManager.removeSkill(mockSkillName, {
global: true,
environments: ["claude", "codex"],
});

expect(mockedFs.remove).toHaveBeenCalledTimes(2);
expect(mockedFs.remove).toHaveBeenCalledWith(
path.join(os.homedir(), ".claude", "skills", mockSkillName),
);
expect(mockedFs.remove).toHaveBeenCalledWith(
path.join(os.homedir(), ".codex", "skills", mockSkillName),
);
expect(mockConfigManager.read).not.toHaveBeenCalled();
expect(mockConfigManager.removeSkill).not.toHaveBeenCalled();
});

it("should remove from every configured global skill root when env is omitted", async () => {
await skillManager.removeSkill(mockSkillName, { global: true });

expect(mockedFs.remove).toHaveBeenCalledWith(
path.join(os.homedir(), ".claude", "skills", mockSkillName),
);
expect(mockedFs.remove).toHaveBeenCalledWith(
path.join(os.homedir(), ".gemini", "config", "skills", mockSkillName),
);
expect(mockEnvironmentSelector.selectGlobalSkillEnvironments).not.toHaveBeenCalled();
});

it("should reject invalid global environments before removing anything", async () => {
await expect(
skillManager.removeSkill(mockSkillName, { global: true, environments: ["invalid-env"] }),
).rejects.toThrow("Invalid environment codes: invalid-env");

expect(mockedFs.remove).not.toHaveBeenCalled();
});

it("should continue global removal after one target fails and report the failure", async () => {
(mockedFs.remove as any)
.mockRejectedValueOnce(new Error("permission denied"))
.mockResolvedValueOnce(undefined);

await expect(
skillManager.removeSkill(mockSkillName, {
global: true,
environments: ["claude", "codex"],
}),
).rejects.toThrow("Failed to remove skill from 1 location(s)");

expect(mockedFs.remove).toHaveBeenCalledTimes(2);
});

it("should remove a dangling global symlink without following its target", async () => {
(mockedFs.pathExists as any).mockResolvedValue(false);
(mockedFs.lstat as any).mockResolvedValue({ isSymbolicLink: () => true });

await skillManager.removeSkill(mockSkillName, {
global: true,
environments: ["claude"],
});

expect(mockedFs.lstat).toHaveBeenCalledWith(
path.join(os.homedir(), ".claude", "skills", mockSkillName),
);
expect(mockedFs.remove).toHaveBeenCalledWith(
path.join(os.homedir(), ".claude", "skills", mockSkillName),
);
expect(mockedFs.realpath).not.toHaveBeenCalled();
});
});

describe("updateSkills", () => {
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/commands/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,20 @@ export function registerSkillCommand(program: Command): void {

skillCommand
.command('remove <skill-name>')
.description('Remove a skill from the current project')
.action(withErrorHandler('remove skill', async (skillName: string) => {
.description('Remove a skill from the current project or configured global skill paths')
.option('-g, --global', 'Remove skill from configured global skill paths (~/<path>)')
.option('-e, --env <environment...>', 'Limit global removal to specific environment(s) (requires --global)')
.action(withErrorHandler('remove skill', async (
skillName: string,
options: { global?: boolean; env?: string[] },
) => {
const configManager = new ConfigManager();
const skillManager = new SkillManager(configManager);

await skillManager.removeSkill(skillName);
await skillManager.removeSkill(skillName, {
global: options.global,
environments: options.env,
});
}));

skillCommand
Expand Down
95 changes: 93 additions & 2 deletions packages/cli/src/lib/SkillManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GlobalConfigManager } from './GlobalConfig.js';
import { EnvironmentSelector } from './EnvironmentSelector.js';
import { SkillRegistry, SKILL_CACHE_DIR } from './SkillRegistry.js';
import { SkillIndex } from './SkillIndex.js';
import { getGlobalSkillPath, getSkillCapableEnvironments, getSkillPath, validateEnvironmentCodes } from '../util/env.js';
import { getAllEnvironments, getGlobalSkillPath, getSkillCapableEnvironments, getSkillPath, validateEnvironmentCodes } from '../util/env.js';
import { ensureGitInstalled } from '../util/git.js';
import { validateRegistryId, validateSkillName, extractSkillDescription, isValidSkillName } from '../util/skill.js';
import { isInteractiveTerminal } from '../util/terminal.js';
Expand All @@ -29,6 +29,11 @@ interface AddSkillOptions {
environments?: string[];
}

interface RemoveSkillOptions {
global?: boolean;
environments?: string[];
}

interface RegistrySkillChoice {
name: string;
description?: string;
Expand Down Expand Up @@ -150,10 +155,23 @@ export class SkillManager {
/**
* Remove a skill from the project
*/
async removeSkill(skillName: string): Promise<void> {
async removeSkill(skillName: string, options: RemoveSkillOptions = {}): Promise<void> {
ui.info(`Removing skill: ${skillName}`);
validateSkillName(skillName);

if (options.environments && options.environments.length > 0 && !options.global) {
throw new ValidationError('--env can only be used with --global');
}

if (options.global) {
await this.removeGlobalSkill(skillName, options.environments);
return;
}

await this.removeProjectSkill(skillName);
}

private async removeProjectSkill(skillName: string): Promise<void> {
const config = await this.configManager.read();
if (!config || !config.environments || config.environments.length === 0) {
throw new ConfigNotFoundError('No .ai-devkit.json found. Run: ai-devkit init');
Expand Down Expand Up @@ -182,6 +200,79 @@ export class SkillManager {
}
}

private async removeGlobalSkill(skillName: string, envCodes?: string[]): Promise<void> {
const environments: EnvironmentCode[] = envCodes && envCodes.length > 0
? validateEnvironmentCodes(envCodes)
: getAllEnvironments()
.filter(env => env.globalSkillPath !== undefined)
.map(env => env.code as EnvironmentCode);
const unsupported = environments.filter(env => getGlobalSkillPath(env) === undefined);

if (unsupported.length > 0) {
throw new ValidationError(`Global skill removal is not supported for: ${unsupported.join(', ')}`);
}

const homeDir = path.resolve(os.homedir());
const targets = new Map<string, string>();

for (const environment of environments) {
const configuredRoot = getGlobalSkillPath(environment);
if (!configuredRoot) {
continue;
}

const rootPath = path.resolve(homeDir, configuredRoot);
const relativeRoot = path.relative(homeDir, rootPath);
if (path.isAbsolute(configuredRoot)
|| relativeRoot === '..'
|| relativeRoot.startsWith(`..${path.sep}`)) {
throw new ValidationError(`Unsafe global skill root configured for: ${environment}`);
}

const skillPath = path.resolve(rootPath, skillName);
if (path.dirname(skillPath) !== rootPath) {
throw new ValidationError(`Refusing to remove skill outside configured global skill root: ${environment}`);
}

targets.set(skillPath, configuredRoot);
}

let removedCount = 0;
const failures: string[] = [];

for (const [skillPath, configuredRoot] of targets) {
try {
await fs.lstat(skillPath);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
}
failures.push(`${configuredRoot}: ${(error as Error).message}`);
continue;
}

try {
await fs.remove(skillPath);
ui.text(` → Removed from ~/${configuredRoot}`);
removedCount++;
} catch (error: unknown) {
failures.push(`${configuredRoot}: ${(error as Error).message}`);
}
}

if (removedCount === 0 && failures.length === 0) {
ui.warning(`Skill "${skillName}" not found in selected global environments. Nothing to remove.`);
} else if (removedCount > 0) {
ui.success(`Successfully removed from ${removedCount} global location(s).`);
}

ui.info('Note: Cached copy in ~/.ai-devkit/skills/ preserved.');

if (failures.length > 0) {
throw new Error(`Failed to remove skill from ${failures.length} location(s): ${failures.join('; ')}`);
}
}

/**
* Update skills from registries
*/
Expand Down
26 changes: 18 additions & 8 deletions web/content/docs/7-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,31 +197,41 @@ This command lists skills installed in the current project only. It does not sho

### `ai-devkit skill remove`

Remove a skill from your project.
Remove a skill from your project or from supported global skill locations.

**Syntax:**

```bash
ai-devkit skill remove <skill-name>
ai-devkit skill remove <skill-name> --global [--env <environment...>]
```

**Example:**

```bash
ai-devkit skill remove frontend-design

# Remove from every supported global skill location
ai-devkit skill remove frontend-design --global

# Remove only from selected global environments
ai-devkit skill remove frontend-design --global --env claude codex
```

The cached copy remains in `~/.ai-devkit/skills/` so you can quickly reinstall it in other projects without re-downloading.
| Option | Description |
|--------|-------------|
| `-g, --global` | Remove the skill from global skill paths under your home directory |
| `-e, --env <environment...>` | Limit global removal to specific environments; only valid with `--global` |

This command removes project-installed skills from the current repository. It does not remove skills installed with `ai-devkit skill add --global`.
Without `--global`, existing project removal behavior is unchanged: AI DevKit reads `.ai-devkit.json`, removes the skill from configured project environments, and updates the project skill metadata after a successful removal. `--env` without `--global` is rejected.

To remove a globally installed skill, delete it from the matching global skill path for that environment. For example:
With `--global`, omitting `--env` removes the skill from every environment in the Supported Environments table that declares a global skill path. This is deterministic and does not prompt, including in non-interactive shells. Repeated paths are processed once. Missing skills are reported as nothing to remove and do not cause an error.

```bash
rm -rf ~/.codex/skills/frontend-design
```
Global removal deletes only the named skill entry directly inside known, configured global skill roots under your home directory. Symlinks are removed without following their targets, copied skill directories are removed in place, and `~/.ai-devkit/skills/` is never deleted or modified. If one location fails, AI DevKit continues with the remaining locations and exits with an error summarizing the failed locations.

The cached copy remains in `~/.ai-devkit/skills/` so you can quickly reinstall it in other projects without re-downloading.

Use the Supported Environments table above to find the correct global path for your agent.
Use the Supported Environments table above to see the global path associated with each environment code.

### `ai-devkit skill update`

Expand Down
Loading