From fcdc5fca17670abaeaf9f3d948f80fc085932171 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 6 Aug 2026 05:34:06 +0000 Subject: [PATCH] feat(cli): list globally installed skills --- packages/cli/README.md | 6 ++ .../cli/src/__tests__/commands/skill.test.ts | 69 ++++++++++++++++++- .../src/__tests__/lib/SkillManager.test.ts | 64 +++++++++++++++++ packages/cli/src/commands/skill.ts | 33 ++++++++- packages/cli/src/lib/SkillManager.ts | 66 +++++++++++++++++- web/content/docs/7-skills.md | 15 +++- 6 files changed, 247 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0e6fefc9..f6dd07bf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -88,6 +88,12 @@ ai-devkit lint --feature lint-command --json # Install a skill ai-devkit skill add [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 ``` diff --git a/packages/cli/src/__tests__/commands/skill.test.ts b/packages/cli/src/__tests__/commands/skill.test.ts index 0c8da5f2..a1ab88b7 100644 --- a/packages/cli/src/__tests__/commands/skill.test.ts +++ b/packages/cli/src/__tests__/commands/skill.test.ts @@ -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(), @@ -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(), @@ -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); }); @@ -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 '); + expect(listCommand?.helpInformation()).toMatch(/requires\s+--global/); + }); }); diff --git a/packages/cli/src/__tests__/lib/SkillManager.test.ts b/packages/cli/src/__tests__/lib/SkillManager.test.ts index c2dd9543..adcf7222 100644 --- a/packages/cli/src/__tests__/lib/SkillManager.test.ts +++ b/packages/cli/src/__tests__/lib/SkillManager.test.ts @@ -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"; diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill.ts index 073efefd..999927da 100644 --- a/packages/cli/src/commands/skill.ts +++ b/packages/cli/src/commands/skill.ts @@ -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 ', '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 / [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) { diff --git a/packages/cli/src/lib/SkillManager.ts b/packages/cli/src/lib/SkillManager.ts index 6247daf7..23e783a4 100644 --- a/packages/cli/src/lib/SkillManager.ts +++ b/packages/cli/src/lib/SkillManager.ts @@ -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'; @@ -24,6 +24,12 @@ interface InstalledSkill { environments: string[]; } +interface GlobalInstalledSkill { + name: string; + environments: string[]; + path: string; +} + interface AddSkillOptions { global?: boolean; environments?: string[]; @@ -147,6 +153,64 @@ export class SkillManager { return skills; } + /** + * List valid skills installed in known global environment paths. + */ + async listGlobalSkills(envCodes?: string[]): Promise { + const selectedCodes = envCodes && envCodes.length > 0 + ? new Set(validateEnvironmentCodes(envCodes)) + : undefined; + const roots = new Map(); + + 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 */ diff --git a/web/content/docs/7-skills.md b/web/content/docs/7-skills.md index 3a8d757c..3bdad9d9 100644 --- a/web/content/docs/7-skills.md +++ b/web/content/docs/7-skills.md @@ -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 ` | Limit global listing to selected environments; only valid with `--global` | + **Example Output:** ``` @@ -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`