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
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ ai-devkit lint --feature lint-command --json
# Install a skill
ai-devkit skill add <skill-registry> [skill-name]

# List skills installed across known global environment paths
ai-devkit skill list --global

# Limit global listing to selected environments
ai-devkit skill list --global --env claude codex

# Store project knowledge for future agent sessions
ai-devkit memory store
```
Expand Down
69 changes: 68 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,8 @@ import { registerSkillCommand } from '../../commands/skill.js';
import { ui } from '../../util/terminal-ui.js';

const mockAddSkill = vi.fn();
const mockListGlobalSkills = vi.fn();
const mockListSkills = vi.fn();

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

it('lists global skills for selected environments with provenance', async () => {
mockListGlobalSkills.mockResolvedValue([{
name: 'frontend-design',
environments: ['claude'],
path: '~/.claude/skills/frontend-design',
}]);
const program = new Command();
registerSkillCommand(program);

await program.parseAsync(['node', 'test', 'skill', 'list', '--global', '--env', 'claude']);

expect(mockListGlobalSkills).toHaveBeenCalledWith(['claude']);
expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({
headers: ['Skill Name', 'Environments', 'Path'],
rows: [['frontend-design', 'claude', '~/.claude/skills/frontend-design']],
}));
});

it('preserves project-local list behavior when --global is absent', async () => {
mockListSkills.mockResolvedValue([{
name: 'frontend-design',
registry: 'anthropics/skills',
environments: ['cursor', 'claude'],
}]);
const program = new Command();
registerSkillCommand(program);

await program.parseAsync(['node', 'test', 'skill', 'list']);

expect(mockListSkills).toHaveBeenCalledOnce();
expect(mockListGlobalSkills).not.toHaveBeenCalled();
expect(ui.text).toHaveBeenNthCalledWith(1, 'Installed Skills:', { breakline: true });
expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({
headers: ['Skill Name', 'Registry', 'Environments'],
rows: [['frontend-design', 'anthropics/skills', 'cursor, claude']],
}));
expect(ui.text).toHaveBeenNthCalledWith(2, 'Total: 1 skill(s)', { breakline: true });
});

it('rejects skill list --env unless --global is present', async () => {
const program = new Command();
registerSkillCommand(program);

await program.parseAsync(['node', 'test', 'skill', 'list', '--env', 'claude']);

expect(ui.error).toHaveBeenCalledWith('Failed to list skills: --env can only be used with --global');
expect(process.exit).toHaveBeenCalledWith(1);
expect(mockListGlobalSkills).not.toHaveBeenCalled();
});

it('documents global list filtering in command help', () => {
const program = new Command();
registerSkillCommand(program);

const skillCommand = program.commands.find(command => command.name() === 'skill');
const listCommand = skillCommand?.commands.find(command => command.name() === 'list');

expect(listCommand?.helpInformation()).toContain('--global');
expect(listCommand?.helpInformation()).toContain('--env <environment...>');
expect(listCommand?.helpInformation()).toMatch(/requires\s+--global/);
});
});
64 changes: 64 additions & 0 deletions packages/cli/src/__tests__/lib/SkillManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,70 @@ describe("SkillManager", () => {
});
});

describe("listGlobalSkills", () => {
it("lists valid skills deterministically with environment and path provenance", async () => {
const claudeRoot = path.join(os.homedir(), ".claude", "skills");
const codexRoot = path.join(os.homedir(), ".codex", "skills");
(mockedFs.pathExists as any).mockImplementation((checkPath: string) =>
Promise.resolve([
claudeRoot,
codexRoot,
path.join(claudeRoot, "zeta", "SKILL.md"),
path.join(claudeRoot, "alpha", "SKILL.md"),
path.join(claudeRoot, "bad_name", "SKILL.md"),
path.join(codexRoot, "alpha", "SKILL.md"),
].includes(checkPath)),
);
(mockedFs.readdir as any).mockImplementation((root: string) => Promise.resolve(
root === claudeRoot
? [
{ name: "zeta", isDirectory: () => true, isSymbolicLink: () => false },
{ name: "broken", isDirectory: () => false, isSymbolicLink: () => true },
{ name: "bad_name", isDirectory: () => true, isSymbolicLink: () => false },
{ name: "README.md", isDirectory: () => false, isSymbolicLink: () => false },
{ name: "alpha", isDirectory: () => false, isSymbolicLink: () => true },
]
: [{ name: "alpha", isDirectory: () => true, isSymbolicLink: () => false }],
));

const skills = await skillManager.listGlobalSkills(["codex", "claude"]);

expect(skills).toEqual([
{ name: "alpha", environments: ["claude"], path: "~/.claude/skills/alpha" },
{ name: "alpha", environments: ["codex"], path: "~/.codex/skills/alpha" },
{ name: "zeta", environments: ["claude"], path: "~/.claude/skills/zeta" },
]);
expect(mockConfigManager.read).not.toHaveBeenCalled();
});

it("groups environments that share a duplicate global path", async () => {
const sharedRoot = path.join(os.homedir(), ".config", "agents", "skills");
(mockedFs.pathExists as any).mockImplementation((checkPath: string) =>
Promise.resolve(checkPath === sharedRoot || checkPath === path.join(sharedRoot, "shared", "SKILL.md")),
);
(mockedFs.readdir as any).mockResolvedValue([
{ name: "shared", isDirectory: () => true, isSymbolicLink: () => false },
]);

const skills = await skillManager.listGlobalSkills(["amp", "amp"]);

expect(mockedFs.pathExists.mock.calls).toEqual([
[sharedRoot],
[path.join(sharedRoot, "shared", "SKILL.md")],
]);
expect(skills).toEqual([
{ name: "shared", environments: ["amp"], path: "~/.config/agents/skills/shared" },
]);
expect(mockedFs.readdir).toHaveBeenCalledTimes(1);
});

it("rejects invalid environment filters", async () => {
await expect(skillManager.listGlobalSkills(["invalid-env"])).rejects.toThrow(
"Invalid environment codes: invalid-env",
);
});
});

describe("removeSkill", () => {
const mockSkillName = "frontend-design";

Expand Down
33 changes: 31 additions & 2 deletions packages/cli/src/commands/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,40 @@ export function registerSkillCommand(program: Command): void {

skillCommand
.command('list')
.description('List all installed skills in the current project')
.action(withErrorHandler('list skills', async () => {
.description('List installed project skills, or global skills with --global')
.option('-g, --global', 'List skills in known configured global skill paths')
.option('-e, --env <environment...>', 'Limit global listing to environment(s) (requires --global)')
.action(withErrorHandler('list skills', async (options: { global?: boolean; env?: string[] }) => {
const configManager = new ConfigManager();
const skillManager = new SkillManager(configManager);

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

if (options.global) {
const skills = await skillManager.listGlobalSkills(options.env);

if (skills.length === 0) {
ui.warning('No global skills installed in the selected environments.');
ui.info('Install a global skill with: ai-devkit skill add <registry>/<repo> [skill-name] --global');
return;
}

ui.text('Globally Installed Skills:', { breakline: true });
ui.table({
headers: ['Skill Name', 'Environments', 'Path'],
rows: skills.map(skill => [
skill.name,
skill.environments.join(', '),
skill.path,
]),
columnStyles: [chalk.cyan, chalk.green, chalk.dim],
});
ui.text(`Total: ${skills.length} skill installation(s)`, { breakline: true });
return;
}

const skills = await skillManager.listSkills();

if (skills.length === 0) {
Expand Down
66 changes: 65 additions & 1 deletion 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 @@ -24,6 +24,12 @@ interface InstalledSkill {
environments: string[];
}

interface GlobalInstalledSkill {
name: string;
environments: string[];
path: string;
}

interface AddSkillOptions {
global?: boolean;
environments?: string[];
Expand Down Expand Up @@ -147,6 +153,64 @@ export class SkillManager {
return skills;
}

/**
* List valid skills installed in known global environment paths.
*/
async listGlobalSkills(envCodes?: string[]): Promise<GlobalInstalledSkill[]> {
const selectedCodes = envCodes && envCodes.length > 0
? new Set(validateEnvironmentCodes(envCodes))
: undefined;
const roots = new Map<string, { path: string; environments: string[] }>();

for (const environment of getAllEnvironments()) {
if (!environment.globalSkillPath || (selectedCodes && !selectedCodes.has(environment.code as EnvironmentCode))) {
continue;
}

const fullPath = path.join(os.homedir(), environment.globalSkillPath);
const existing = roots.get(fullPath);
if (existing) {
if (!existing.environments.includes(environment.code)) {
existing.environments.push(environment.code);
}
} else {
roots.set(fullPath, {
path: environment.globalSkillPath,
environments: [environment.code],
});
}
}

const skills: GlobalInstalledSkill[] = [];
for (const [fullPath, root] of roots) {
if (!await fs.pathExists(fullPath)) {
continue;
}

const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || !isValidSkillName(entry.name)) {
continue;
}

const skillPath = path.join(fullPath, entry.name);
if (!await fs.pathExists(path.join(skillPath, 'SKILL.md'))) {
continue;
}

skills.push({
name: entry.name,
environments: [...root.environments],
path: `~/${path.join(root.path, entry.name).split(path.sep).join('/')}`,
});
}
}

return skills.sort((left, right) =>
left.name.localeCompare(right.name) || left.path.localeCompare(right.path),
);
}

/**
* Remove a skill from the project
*/
Expand Down
15 changes: 13 additions & 2 deletions web/content/docs/7-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,25 @@ This command will:

### `ai-devkit skill list`

List all skills installed in your project.
List skills installed in the current project, or inspect skills installed across known global environment paths.

**Syntax:**

```bash
ai-devkit skill list

# List skills across every known global skill path
ai-devkit skill list --global

# Limit the global scan to selected environments
ai-devkit skill list --global --env claude codex
```

| Option | Description |
|--------|-------------|
| `-g, --global` | List skills installed in known global skill paths under your home directory |
| `-e, --env <environment...>` | Limit global listing to selected environments; only valid with `--global` |

**Example Output:**

```
Expand All @@ -193,7 +204,7 @@ The list shows:
- **Registry**: The source registry where the skill came from
- **Environments**: Which AI environments have this skill installed

This command lists skills installed in the current project only. It does not show skills installed globally with `ai-devkit skill add --global`.
Without `--global`, output and behavior remain project-local. With `--global`, the table instead shows each valid skill's environment and `~/...` path provenance. A directory is listed only when it contains `SKILL.md`; missing roots, broken symlinks, non-skill files, and invalid skill directories are ignored. If the same skill is installed at multiple global paths, each path is shown separately. Environments configured with the same global path are grouped on one row. Global listing is read-only and does not require a project configuration or an interactive terminal.

### `ai-devkit skill remove`

Expand Down
Loading