diff --git a/packages/cli/src/__tests__/commands/skill.test.ts b/packages/cli/src/__tests__/commands/skill.test.ts index 1694385f..0d249292 100644 --- a/packages/cli/src/__tests__/commands/skill.test.ts +++ b/packages/cli/src/__tests__/commands/skill.test.ts @@ -1,12 +1,17 @@ import { Command } from 'commander'; -import { registerSkillCommand } from '../../commands/skill.js'; +import { registerSkillCommand } from '../../commands/skill/index.js'; import { ui } from '../../util/terminal-ui.js'; import { SkillService } from '../../services/skill/skill.service.js'; const mockGetBuiltinSkillNames = vi.hoisted(() => vi.fn()); const mockIsInteractiveTerminal = vi.hoisted(() => vi.fn(() => true)); const mockCheckbox = vi.hoisted(() => vi.fn()); +const mockConfigRead = vi.hoisted(() => vi.fn()); +const mockConfigCreate = vi.hoisted(() => vi.fn()); +const mockConfigUpdate = vi.hoisted(() => vi.fn()); +const mockSelectSkillEnvironments = vi.hoisted(() => vi.fn()); +const mockSelectGlobalSkillEnvironments = vi.hoisted(() => vi.fn()); const mockAddSkill = vi.fn(); @@ -19,7 +24,18 @@ const mockRemoveSkill = vi.fn(); const mockRemoveRegistry = vi.fn(); vi.mock('../../lib/Config.js', () => ({ - ConfigManager: vi.fn(function () { return {}; }), + ConfigManager: vi.fn(function () { return { + read: (...args: unknown[]) => mockConfigRead(...args), + create: (...args: unknown[]) => mockConfigCreate(...args), + update: (...args: unknown[]) => mockConfigUpdate(...args), + }; }), +})); + +vi.mock('../../lib/EnvironmentSelector.js', () => ({ + EnvironmentSelector: vi.fn(function () { return { + selectSkillEnvironments: (...args: unknown[]) => mockSelectSkillEnvironments(...args), + selectGlobalSkillEnvironments: (...args: unknown[]) => mockSelectGlobalSkillEnvironments(...args), + }; }), })); vi.mock('../../services/skill/skill.service.js', () => ({ @@ -65,8 +81,20 @@ vi.mock('@inquirer/prompts', () => ({ describe('skill command', () => { beforeEach(() => { vi.clearAllMocks(); - mockAddSkill.mockImplementation(async () => undefined); - mockAddSkills.mockImplementation(async () => undefined); + mockAddSkill.mockImplementation(async (registryId: string, skillName: string, options: { global?: boolean; environments?: string[] }) => ({ + status: 'installed', + registryId, + installMode: options.global ? 'global' : 'project', + environments: options.environments || [], + items: [{ skillName, target: `.claude/skills/${skillName}`, action: 'symlinked' }], + })); + mockAddSkills.mockImplementation(async (registryId: string, skillNames: string[], options: { global?: boolean; environments?: string[] }) => ({ + status: 'installed', + registryId, + installMode: options.global ? 'global' : 'project', + environments: options.environments || [], + items: skillNames.map(skillName => ({ skillName, target: `.claude/skills/${skillName}`, action: 'symlinked' })), + })); mockAddRegistry.mockResolvedValue('added'); mockListGlobalSkills.mockResolvedValue([]); mockListInstallableSkills.mockResolvedValue([ @@ -74,10 +102,20 @@ describe('skill command', () => { { name: 'debug' }, ]); mockListSkills.mockResolvedValue([]); - mockRemoveSkill.mockImplementation(async () => undefined); + mockRemoveSkill.mockImplementation(async (skillName: string, options: { global?: boolean }) => ({ + skillName, + scope: options.global ? 'global' : 'project', + removedTargets: [], + failures: [], + })); mockRemoveRegistry.mockResolvedValue('project'); mockGetBuiltinSkillNames.mockResolvedValue(['remote-one', 'remote-two']); mockIsInteractiveTerminal.mockReturnValue(true); + mockConfigRead.mockResolvedValue({ environments: ['claude'] }); + mockConfigCreate.mockResolvedValue({ environments: [] }); + mockConfigUpdate.mockResolvedValue({}); + mockSelectSkillEnvironments.mockResolvedValue(['claude']); + mockSelectGlobalSkillEnvironments.mockResolvedValue(['claude']); mockCheckbox.mockResolvedValue(['frontend-design']); vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any); vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as any); @@ -259,7 +297,7 @@ describe('skill command', () => { }); expect(mockAddSkills).toHaveBeenCalledWith('anthropics/skills', ['frontend-design'], { global: undefined, - environments: undefined, + environments: ['claude'], }); expect(mockAddSkill).not.toHaveBeenCalled(); expect(process.stderr.write).not.toHaveBeenCalled(); @@ -273,7 +311,7 @@ describe('skill command', () => { expect(mockAddSkill).toHaveBeenCalledWith('anthropics/skills', 'frontend-design', { global: undefined, - environments: undefined, + environments: ['claude'], }); expect(mockListInstallableSkills).not.toHaveBeenCalled(); expect(mockAddSkills).not.toHaveBeenCalled(); @@ -318,11 +356,11 @@ describe('skill command', () => { expect(mockAddSkill).toHaveBeenCalledTimes(2); expect(mockAddSkill).toHaveBeenCalledWith('codeaholicguy/ai-devkit', 'remote-one', { global: undefined, - environments: undefined, + environments: ['claude'], }); expect(mockAddSkill).toHaveBeenCalledWith('codeaholicguy/ai-devkit', 'remote-two', { global: undefined, - environments: undefined, + environments: ['claude'], }); expect(mockGetBuiltinSkillNames).toHaveBeenCalledOnce(); expect(SkillService).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/__tests__/services/install/install.service.test.ts b/packages/cli/src/__tests__/services/install/install.service.test.ts index 5485550e..d8f9b7b1 100644 --- a/packages/cli/src/__tests__/services/install/install.service.test.ts +++ b/packages/cli/src/__tests__/services/install/install.service.test.ts @@ -78,7 +78,13 @@ describe('install service', () => { mockTemplateManager.setupMultipleEnvironments.mockResolvedValue([]); mockTemplateManager.copyPhaseTemplate.mockResolvedValue('docs/ai/requirements/README.md'); - mockSkillService.addSkill.mockResolvedValue(undefined); + mockSkillService.addSkill.mockImplementation(async (registryId: string, skillName: string) => ({ + status: 'installed', + registryId, + installMode: 'project', + environments: ['codex'], + items: [{ skillName, target: `.codex/skills/${skillName}`, action: 'symlinked' }], + })); mockConfirm.mockResolvedValue(false); mockIsInteractiveTerminal.mockReturnValue(true); }); diff --git a/packages/cli/src/__tests__/services/skill/index/skill-index.service.test.ts b/packages/cli/src/__tests__/services/skill/index/skill-index.service.test.ts index 02f2d7fa..b1ca0a3c 100644 --- a/packages/cli/src/__tests__/services/skill/index/skill-index.service.test.ts +++ b/packages/cli/src/__tests__/services/skill/index/skill-index.service.test.ts @@ -4,7 +4,6 @@ import * as os from "os"; import * as path from "path"; import { SkillService } from "../../../../services/skill/skill.service.js"; import { ConfigManager } from "../../../../lib/Config.js"; -import { EnvironmentSelector } from "../../../../lib/EnvironmentSelector.js"; import { GlobalConfigManager } from "../../../../lib/GlobalConfig.js"; import * as gitUtil from "../../../../util/git.js"; import * as skillUtil from "../../../../services/skill/skill-validation.js"; @@ -36,15 +35,6 @@ vi.mock("../../../../lib/Config.js", () => ({ update: vi.fn(), }; }), })); -vi.mock("../../../../lib/EnvironmentSelector.js", () => ({ - EnvironmentSelector: vi.fn(function () { return { - selectEnvironments: vi.fn(), - selectSkillEnvironments: vi.fn(), - selectGlobalSkillEnvironments: vi.fn(), - confirmOverride: vi.fn(), - displaySelectionSummary: vi.fn(), - }; }), -})); vi.mock("../../../../lib/GlobalConfig.js", () => ({ GlobalConfigManager: vi.fn(function () { return { getSkillRegistries: vi.fn(), @@ -91,9 +81,6 @@ const mockedFs = fs as Mocked; const MockedConfigManager = ConfigManager as MockedClass< typeof ConfigManager >; -const MockedEnvironmentSelector = EnvironmentSelector as MockedClass< - typeof EnvironmentSelector ->; const MockedGlobalConfigManager = GlobalConfigManager as MockedClass< typeof GlobalConfigManager >; @@ -112,7 +99,6 @@ function mockFetch(response: any) { describe("SkillService", () => { let skillManager: SkillService; let mockConfigManager: Mocked; - let mockEnvironmentSelector: Mocked; let mockGlobalConfigManager: Mocked; beforeEach(() => { @@ -120,8 +106,6 @@ describe("SkillService", () => { vi.spyOn(console, "log").mockImplementation(() => { }); mockConfigManager = new MockedConfigManager() as Mocked; - mockEnvironmentSelector = - new MockedEnvironmentSelector() as Mocked; mockGlobalConfigManager = new MockedGlobalConfigManager() as Mocked; @@ -130,7 +114,6 @@ describe("SkillService", () => { skillManager = new SkillService( mockConfigManager, - mockEnvironmentSelector, mockGlobalConfigManager, ); @@ -177,11 +160,7 @@ describe("SkillService", () => { failed: 0, results: [], }); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("⚠"), - expect.stringContaining("No skills cache found"), - ); + expect(console.log).not.toHaveBeenCalled(); }); it("should update all registries when no registryId provided", async () => { @@ -336,14 +315,10 @@ describe("SkillService", () => { (mockedGitUtil.isGitRepository as any).mockResolvedValue(true); (mockedGitUtil.pullRepository as any).mockResolvedValue(undefined); - await skillManager.updateSkills(); + const result = await skillManager.updateSkills(); - // Summary now uses ui.summary() which formats differently - // It outputs "✓ 1 updated" as a single colored string - expect(console.log).toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("updated"), - ); + expect(result.successful).toBe(1); + expect(console.log).not.toHaveBeenCalled(); }); it("should display summary after updates", async () => { @@ -359,14 +334,14 @@ describe("SkillService", () => { (mockedGitUtil.isGitRepository as any).mockResolvedValue(true); (mockedGitUtil.pullRepository as any).mockResolvedValue(undefined); - await skillManager.updateSkills(); + const result = await skillManager.updateSkills(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("Summary:"), - ); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("1 updated"), - ); + expect(result).toMatchObject({ + successful: 1, + skipped: 0, + failed: 0, + }); + expect(console.log).not.toHaveBeenCalled(); }); it("should handle mixed results (success, skip, error)", async () => { diff --git a/packages/cli/src/__tests__/services/skill/registry/skill-registry.service.test.ts b/packages/cli/src/__tests__/services/skill/registry/skill-registry.service.test.ts index 33ff25dc..9d09a7b8 100644 --- a/packages/cli/src/__tests__/services/skill/registry/skill-registry.service.test.ts +++ b/packages/cli/src/__tests__/services/skill/registry/skill-registry.service.test.ts @@ -97,8 +97,6 @@ describe('SkillRegistryService repository preparation', () => { await registry.prepareRegistryRepository(registryId, gitUrl); expect(mockedGit.pullRepository).toHaveBeenCalledTimes(1); - expect(mockUi.info).toHaveBeenCalledWith(`Refreshing registry ${registryId}...`); - expect(mockUi.success).toHaveBeenCalledWith(`Registry ${registryId} refreshed.`); }); it('shares one in-flight refresh between concurrent preparations', async () => { @@ -136,10 +134,6 @@ describe('SkillRegistryService repository preparation', () => { await expect(registry.prepareRegistryRepository(registryId, gitUrl)).resolves.toBe(cachedPath); expect(mockedGit.pullRepository).toHaveBeenCalledTimes(1); - expect(mockUi.warning).toHaveBeenCalledTimes(1); - expect(mockUi.warning).toHaveBeenCalledWith( - `Could not refresh registry ${registryId}: network down. Using cached registry contents for this run.`, - ); }); it('reuses a failed no-cache preparation without retrying', async () => { @@ -169,10 +163,6 @@ describe('SkillRegistryService repository preparation', () => { expect(mockedGit.isGitRepository).toHaveBeenCalledTimes(1); expect(mockedGit.pullRepository).not.toHaveBeenCalled(); - expect(mockUi.warning).toHaveBeenCalledTimes(1); - expect(mockUi.warning).toHaveBeenCalledWith( - `Cached registry ${registryId} is not a git repository, using as-is.`, - ); }); it('prepares the registry repository in the local cache', async () => { diff --git a/packages/cli/src/__tests__/services/skill/skill-builtins.test.ts b/packages/cli/src/__tests__/services/skill/skill-builtins.test.ts index e6029271..b510ca91 100644 --- a/packages/cli/src/__tests__/services/skill/skill-builtins.test.ts +++ b/packages/cli/src/__tests__/services/skill/skill-builtins.test.ts @@ -1,19 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { readFile } from 'node:fs/promises'; -const mockWarning = vi.fn(); - -vi.mock('../../../util/terminal-ui.js', () => ({ - ui: { - warning: (...args: unknown[]) => mockWarning(...args), - }, -})); - describe('getBuiltinSkillNames', () => { beforeEach(() => { vi.resetModules(); vi.unstubAllGlobals(); - mockWarning.mockReset(); }); it('returns the live bare-array manifest and fetches it once per process', async () => { @@ -31,7 +22,6 @@ describe('getBuiltinSkillNames', () => { expect(fetchMock).toHaveBeenCalledWith( 'https://raw.githubusercontent.com/codeaholicguy/ai-devkit/main/skills/built-in.json' ); - expect(mockWarning).not.toHaveBeenCalled(); }); it('falls back to the bundled list when the manifest cannot be fetched', async () => { @@ -43,9 +33,6 @@ describe('getBuiltinSkillNames', () => { expect(names).toHaveLength(23); expect(names).toContain('agent-communication'); expect(names).toContain('tdd'); - expect(mockWarning).toHaveBeenCalledWith( - 'Failed to load built-in skills manifest: network unavailable. Using bundled fallback.' - ); }); it('falls back to the bundled list for an unsuccessful response', async () => { @@ -57,9 +44,6 @@ describe('getBuiltinSkillNames', () => { const { getBuiltinSkillNames } = await import('../../../services/skill/skill-builtins.js'); await expect(getBuiltinSkillNames()).resolves.toHaveLength(23); - expect(mockWarning).toHaveBeenCalledWith( - 'Failed to load built-in skills manifest: HTTP 404. Using bundled fallback.' - ); }); it('falls back to the bundled list when response JSON cannot be parsed', async () => { @@ -73,9 +57,6 @@ describe('getBuiltinSkillNames', () => { const { getBuiltinSkillNames } = await import('../../../services/skill/skill-builtins.js'); await expect(getBuiltinSkillNames()).resolves.toHaveLength(23); - expect(mockWarning).toHaveBeenCalledWith( - 'Failed to load built-in skills manifest: Unexpected token. Using bundled fallback.' - ); }); it.each([ @@ -94,9 +75,6 @@ describe('getBuiltinSkillNames', () => { const { getBuiltinSkillNames } = await import('../../../services/skill/skill-builtins.js'); await expect(getBuiltinSkillNames()).resolves.toHaveLength(23); - expect(mockWarning).toHaveBeenCalledWith( - expect.stringMatching(/^Failed to load built-in skills manifest: .+ Using bundled fallback\.$/) - ); }); }); diff --git a/packages/cli/src/__tests__/services/skill/skill.service.test.ts b/packages/cli/src/__tests__/services/skill/skill.service.test.ts index 84c137f7..c018dc30 100644 --- a/packages/cli/src/__tests__/services/skill/skill.service.test.ts +++ b/packages/cli/src/__tests__/services/skill/skill.service.test.ts @@ -132,7 +132,6 @@ describe("SkillService", () => { skillManager = new SkillService( mockConfigManager, - mockEnvironmentSelector, mockGlobalConfigManager, ); @@ -243,9 +242,9 @@ describe("SkillService", () => { }; it("should successfully add a skill", async () => { - const status = await skillManager.addSkill(mockRegistryId, mockSkillName); + const result = await skillManager.addSkill(mockRegistryId, mockSkillName); - expect(status).toBe("matched"); + expect(result.status).toBe("matched"); expect(mockedSkillUtil.validateRegistryId).toHaveBeenCalledWith( mockRegistryId, @@ -271,14 +270,13 @@ describe("SkillService", () => { return Promise.resolve(true); }); - await skillManager.addSkill(mockRegistryId, mockSkillName, { global: true }); + await skillManager.addSkill(mockRegistryId, mockSkillName, { global: true, environments: ["cursor", "claude"] }); expect(mockedFs.symlink).toHaveBeenCalledWith( expect.any(String), path.join(os.homedir(), ".cursor", "skills", mockSkillName), "dir", ); - expect(mockEnvironmentSelector.selectGlobalSkillEnvironments).toHaveBeenCalled(); expect(mockConfigManager.read).not.toHaveBeenCalled(); expect(mockConfigManager.create).not.toHaveBeenCalled(); expect(mockConfigManager.addSkill).not.toHaveBeenCalled(); @@ -290,10 +288,11 @@ describe("SkillService", () => { ).rejects.toThrow("Invalid environment codes: invalid-env"); }); - it("should throw error when env is provided without global option", async () => { - await expect( - skillManager.addSkill(mockRegistryId, mockSkillName, { environments: ["claude"] }), - ).rejects.toThrow("--env can only be used with --global"); + it("should accept resolved project environments from the command layer", async () => { + const result = await skillManager.addSkill(mockRegistryId, mockSkillName, { environments: ["claude"] }); + + expect(result.environments).toEqual(["claude"]); + expect(mockConfigManager.read).not.toHaveBeenCalled(); }); it("should install only selected global environments", async () => { @@ -476,7 +475,6 @@ describe("SkillService", () => { const skillManagerWithRealGlobal = new SkillService( mockConfigManager, - mockEnvironmentSelector, realGlobalConfigManager, ); @@ -575,53 +573,43 @@ describe("SkillService", () => { it("should skip if skill already exists in target", async () => { (mockedFs.pathExists as any).mockResolvedValue(true); - const status = await skillManager.addSkill(mockRegistryId, mockSkillName); + const result = await skillManager.addSkill(mockRegistryId, mockSkillName); - expect(status).toBe("matched"); + expect(result.status).toBe("matched"); expect(mockedFs.symlink).not.toHaveBeenCalled(); expect(mockedFs.copy).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("already exists, skipped"), - ); + expect(result.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "skipped", skillName: mockSkillName }), + ])); }); - it("should create config if missing", async () => { - mockIsInteractiveTerminal.mockReturnValue(true); + it("should create config if missing and fail when environments remain unresolved", async () => { mockConfigManager.read.mockResolvedValue(null); mockConfigManager.create.mockResolvedValue({ environments: [], } as any); - mockEnvironmentSelector.selectSkillEnvironments.mockResolvedValue([ - "cursor", - ]); - await skillManager.addSkill(mockRegistryId, mockSkillName); + await expect(skillManager.addSkill(mockRegistryId, mockSkillName)).rejects.toThrow( + 'No environments configured. Run "ai-devkit init" or add "environments" in .ai-devkit.json.', + ); expect(mockConfigManager.create).toHaveBeenCalled(); - expect( - mockEnvironmentSelector.selectSkillEnvironments, - ).toHaveBeenCalled(); - expect(mockConfigManager.update).toHaveBeenCalledWith({ - environments: ["cursor"], - }); + expect(mockEnvironmentSelector.selectSkillEnvironments).not.toHaveBeenCalled(); + expect(mockConfigManager.update).not.toHaveBeenCalled(); }); - it("should select environments when config exists but has no environments", async () => { - mockIsInteractiveTerminal.mockReturnValue(true); + it("should fail when config exists but has no environments", async () => { mockConfigManager.read.mockResolvedValue({ environments: [], } as any); - mockEnvironmentSelector.selectSkillEnvironments.mockResolvedValue([ - "claude", - ]); - await skillManager.addSkill(mockRegistryId, mockSkillName); + await expect(skillManager.addSkill(mockRegistryId, mockSkillName)).rejects.toThrow( + 'No environments configured. Run "ai-devkit init" or add "environments" in .ai-devkit.json.', + ); expect(mockConfigManager.create).not.toHaveBeenCalled(); - expect(mockEnvironmentSelector.selectSkillEnvironments).toHaveBeenCalled(); - expect(mockConfigManager.update).toHaveBeenCalledWith({ - environments: ["claude"], - }); + expect(mockEnvironmentSelector.selectSkillEnvironments).not.toHaveBeenCalled(); + expect(mockConfigManager.update).not.toHaveBeenCalled(); }); it("should throw in non-interactive mode when no environments configured", async () => { @@ -682,10 +670,7 @@ describe("SkillService", () => { const skills = await skillManager.listInstallableSkills(mockRegistryId); expect(skills.map(skill => skill.name)).toEqual(["debug", "frontend-design"]); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("⚠"), - expect.stringContaining("Using cached registry contents"), - ); + expect(console.log).not.toHaveBeenCalled(); }); it("should throw a clear error when the registry has no valid skills", async () => { @@ -773,11 +758,7 @@ describe("SkillService", () => { const skills = await skillManager.listSkills(); expect(skills).toEqual([]); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("⚠"), - expect.stringContaining("No .ai-devkit.json found"), - ); + expect(console.log).not.toHaveBeenCalled(); }); it("should return empty array if no environments configured", async () => { @@ -1004,14 +985,10 @@ describe("SkillService", () => { }); it("should remove skill from all skill-capable environments", async () => { - await skillManager.removeSkill(mockSkillName); + const result = await skillManager.removeSkill(mockSkillName); expect(mockedFs.remove).toHaveBeenCalled(); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("✔"), - expect.stringContaining("Successfully removed"), - ); + expect(result.removedTargets).toEqual([".cursor/skills", ".claude/skills"]); }); it("should update config to remove skill entry after successful removal", async () => { @@ -1031,40 +1008,26 @@ describe("SkillService", () => { it("should handle skill not found gracefully", async () => { (mockedFs.pathExists as any).mockResolvedValue(false); - await skillManager.removeSkill(mockSkillName); + const result = await skillManager.removeSkill(mockSkillName); expect(mockedFs.remove).not.toHaveBeenCalled(); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("⚠"), - expect.stringContaining("not found"), - ); + expect(result.removedTargets).toEqual([]); }); it("should log helpful tip when skill not found", async () => { (mockedFs.pathExists as any).mockResolvedValue(false); - await skillManager.removeSkill(mockSkillName); + const result = await skillManager.removeSkill(mockSkillName); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("ℹ"), - expect.stringContaining("ai-devkit skill list"), - ); + expect(result.removedTargets).toEqual([]); + expect(console.log).not.toHaveBeenCalled(); }); it("should note that cache is preserved", async () => { - await skillManager.removeSkill(mockSkillName); + const result = await skillManager.removeSkill(mockSkillName); - // UI utility outputs symbol and message as separate parameters - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("ℹ"), - expect.stringContaining("Cache"), - ); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("ℹ"), - expect.stringContaining("preserved"), - ); + expect(result.removedTargets).toHaveLength(2); + expect(console.log).not.toHaveBeenCalled(); }); it("should throw error if no valid skill-capable environments", async () => { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8de5726a..f6c5f278 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,7 +6,7 @@ import { phaseCommand } from './commands/phase.js'; import { lintCommand } from './commands/lint.js'; import { installCommand } from './commands/install.js'; import { registerMemoryCommand } from './commands/memory.js'; -import { registerSkillCommand } from './commands/skill.js'; +import { registerSkillCommand } from './commands/skill/index.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerChannelCommand } from './commands/channel.js'; import { registerDocsCommand } from './commands/docs.js'; diff --git a/packages/cli/src/commands/skill/index.ts b/packages/cli/src/commands/skill/index.ts new file mode 100644 index 00000000..d83794b4 --- /dev/null +++ b/packages/cli/src/commands/skill/index.ts @@ -0,0 +1 @@ +export { registerSkillCommand } from './skill.command.js'; diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill/skill.command.ts similarity index 65% rename from packages/cli/src/commands/skill.ts rename to packages/cli/src/commands/skill/skill.command.ts index a009902f..7bb07fd0 100644 --- a/packages/cli/src/commands/skill.ts +++ b/packages/cli/src/commands/skill/skill.command.ts @@ -1,14 +1,23 @@ import { Command } from 'commander'; import { checkbox } from '@inquirer/prompts'; -import chalk from 'chalk'; -import { ConfigManager } from '../lib/Config.js'; -import { SkillService } from '../services/skill/skill.service.js'; -import { BUILTIN_SKILL_REGISTRY, getBuiltinSkillNames } from '../services/skill/skill-builtins.js'; -import { ui } from '../util/terminal-ui.js'; -import { withErrorHandler } from '../util/errors.js'; -import { isInteractiveTerminal } from '../util/terminal.js'; -import { truncate, getErrorMessage } from '../util/text.js'; -import type { RegistrySkillChoice } from '../services/skill/skill.types.js'; +import { ConfigManager } from '../../lib/Config.js'; +import { EnvironmentSelector } from '../../lib/EnvironmentSelector.js'; +import { SkillService } from '../../services/skill/skill.service.js'; +import { BUILTIN_SKILL_REGISTRY, getBuiltinSkillNames } from '../../services/skill/skill-builtins.js'; +import { ui } from '../../util/terminal-ui.js'; +import { ConfigNotFoundError, ValidationError, withErrorHandler } from '../../util/errors.js'; +import { isInteractiveTerminal } from '../../util/terminal.js'; +import { getErrorMessage } from '../../util/text.js'; +import type { AddSkillOptions, RegistrySkillChoice } from '../../services/skill/skill.types.js'; +import { + renderGlobalSkills, + renderProjectSkills, + renderSkillIndexRebuild, + renderSkillInstallResult, + renderSkillRemoveResult, + renderSkillSearchResults, + renderUpdateSummary, +} from './skill.render.js'; export function registerSkillCommand(program: Command): void { const skillCommand = program @@ -25,18 +34,18 @@ export function registerSkillCommand(program: Command): void { try { const configManager = new ConfigManager(); const skillService = new SkillService(configManager); - const installOptions = { - global: options.global, - environments: options.env, - }; if (options.builtIn) { if (registryRepo || skillName) { ui.warning('Ignoring registry and skill arguments because --built-in installs the curated AI DevKit set.'); } + const installOptions = await resolveSkillInstallOptions(configManager, { + global: options.global, + environments: options.env, + }); for (const builtInSkill of await getBuiltinSkillNames()) { - await skillService.addSkill(BUILTIN_SKILL_REGISTRY, builtInSkill, installOptions); + renderSkillInstallResult(await skillService.addSkill(BUILTIN_SKILL_REGISTRY, builtInSkill, installOptions)); } return; @@ -49,7 +58,11 @@ export function registerSkillCommand(program: Command): void { } if (skillName) { - await skillService.addSkill(registryRepo, skillName, installOptions); + const installOptions = await resolveSkillInstallOptions(configManager, { + global: options.global, + environments: options.env, + }); + renderSkillInstallResult(await skillService.addSkill(registryRepo, skillName, installOptions)); return; } @@ -60,7 +73,11 @@ export function registerSkillCommand(program: Command): void { const selectedSkillNames = await promptForSkillSelection( await skillService.listInstallableSkills(registryRepo), ); - await skillService.addSkills(registryRepo, selectedSkillNames, installOptions); + const installOptions = await resolveSkillInstallOptions(configManager, { + global: options.global, + environments: options.env, + }); + renderSkillInstallResult(await skillService.addSkills(registryRepo, selectedSkillNames, installOptions)); } catch (error: unknown) { const message = getErrorMessage(error); if (message === 'Skill selection cancelled.') { @@ -121,49 +138,11 @@ export function registerSkillCommand(program: Command): void { } if (options.global) { - const skills = await skillService.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 }); + renderGlobalSkills(await skillService.listGlobalSkills(options.env)); return; } - const skills = await skillService.listSkills(); - - if (skills.length === 0) { - ui.warning('No skills installed in this project.'); - ui.info('Install a skill with: ai-devkit skill add / [skill-name]'); - return; - } - - ui.text('Installed Skills:', { breakline: true }); - - ui.table({ - headers: ['Skill Name', 'Registry', 'Environments'], - rows: skills.map(skill => [ - skill.name, - skill.registry, - skill.environments.join(', ') - ]), - columnStyles: [chalk.cyan, chalk.dim, chalk.green] - }); - - ui.text(`Total: ${skills.length} skill(s)`, { breakline: true }); + renderProjectSkills(await skillService.listSkills()); })); skillCommand @@ -178,10 +157,11 @@ export function registerSkillCommand(program: Command): void { const configManager = new ConfigManager(); const skillService = new SkillService(configManager); - await skillService.removeSkill(skillName, { + const result = await skillService.removeSkill(skillName, { global: options.global, environments: options.env, }); + renderSkillRemoveResult(result); })); skillCommand @@ -191,7 +171,7 @@ export function registerSkillCommand(program: Command): void { const configManager = new ConfigManager(); const skillService = new SkillService(configManager); - await skillService.updateSkills(registryId); + renderUpdateSummary(await skillService.updateSkills(registryId)); })); skillCommand @@ -202,27 +182,7 @@ export function registerSkillCommand(program: Command): void { const configManager = new ConfigManager(); const skillService = new SkillService(configManager); - const results = await skillService.findSkills(keyword, { refresh: options.refresh }); - - if (results.length === 0) { - ui.warning(`No skills found matching "${keyword}"`); - ui.info('Try a different keyword or use --refresh to update the skill index'); - return; - } - - ui.text(`Found ${results.length} skill(s) matching "${keyword}":`, { breakline: true }); - - ui.table({ - headers: ['Skill Name', 'Registry', 'Description'], - rows: results.map(skill => [ - skill.name, - skill.registry, - truncate(skill.description, 60, '...') - ]), - columnStyles: [chalk.cyan, chalk.dim, chalk.white] - }); - - ui.text(`\nInstall with: ai-devkit skill add [skill-name]`, { breakline: true }); + renderSkillSearchResults(keyword, await skillService.findSkills(keyword, { refresh: options.refresh })); })); skillCommand @@ -233,10 +193,63 @@ export function registerSkillCommand(program: Command): void { const configManager = new ConfigManager(); const skillService = new SkillService(configManager); - await skillService.rebuildIndex(options.output); + renderSkillIndexRebuild(await skillService.rebuildIndex(options.output)); })); } +async function resolveSkillInstallOptions( + configManager: ConfigManager, + options: AddSkillOptions, +): Promise { + if (options.environments && options.environments.length > 0 && !options.global) { + throw new ValidationError('--env can only be used with --global'); + } + + const environmentSelector = new EnvironmentSelector(); + + if (options.global) { + if (options.environments && options.environments.length > 0) { + return options; + } + + if (!isInteractiveTerminal()) { + throw new ValidationError('Global skill installation requires at least one environment.'); + } + + return { + ...options, + environments: await environmentSelector.selectGlobalSkillEnvironments(), + }; + } + + ui.info('Loading project configuration...'); + let config = await configManager.read(); + if (!config) { + ui.info('No .ai-devkit.json found. Creating configuration...'); + config = await configManager.create(); + } + + if (config.environments && config.environments.length > 0) { + return { + ...options, + environments: config.environments, + }; + } + + if (!isInteractiveTerminal()) { + throw new ConfigNotFoundError('No environments configured. Run "ai-devkit init" or add "environments" in .ai-devkit.json.'); + } + + const selectedEnvironments = await environmentSelector.selectSkillEnvironments(); + await configManager.update({ environments: selectedEnvironments }); + ui.success('Configuration saved.'); + + return { + ...options, + environments: selectedEnvironments, + }; +} + async function promptForSkillSelection(skills: RegistrySkillChoice[]): Promise { try { return await checkbox({ diff --git a/packages/cli/src/commands/skill/skill.render.ts b/packages/cli/src/commands/skill/skill.render.ts new file mode 100644 index 00000000..1e102b1e --- /dev/null +++ b/packages/cli/src/commands/skill/skill.render.ts @@ -0,0 +1,137 @@ +import chalk from 'chalk'; +import { ui } from '../../util/terminal-ui.js'; +import { truncate } from '../../util/text.js'; +import type { GlobalInstalledSkill, InstalledSkill, SkillInstallResult, SkillRemoveResult } from '../../services/skill/skill.types.js'; +import type { UpdateSummary } from '../../services/skill/registry/skill-registry.service.js'; +import type { SkillEntry, SkillIndexRebuildResult } from '../../services/skill/index/skill-index.service.js'; + +export function renderSkillInstallResult(result: SkillInstallResult): void { + for (const item of result.items) { + const suffix = item.action === 'skipped' + ? 'already exists, skipped' + : item.action; + ui.text(` -> ${item.target} (${suffix})`); + } + + ui.text(`Successfully installed: ${[...new Set(result.items.map(item => item.skillName))].join(', ')}`); + ui.info(` Source: ${result.registryId}`); + ui.info(` Installed to (${result.installMode}): ${result.environments.join(', ')}`); +} + +export function renderSkillRemoveResult(result: SkillRemoveResult): void { + for (const target of result.removedTargets) { + ui.text(` -> Removed from ${target}`); + } + + if (result.removedTargets.length === 0) { + ui.warning(result.scope === 'global' + ? `Skill "${result.skillName}" not found in selected global environments. Nothing to remove.` + : `Skill "${result.skillName}" not found. Nothing to remove.`); + if (result.scope === 'project') { + ui.info('Tip: Run "ai-devkit skill list" to see installed skills.'); + } + return; + } + + ui.success(result.scope === 'global' + ? `Successfully removed from ${result.removedTargets.length} global location(s).` + : `Successfully removed from ${result.removedTargets.length} location(s).`); + ui.info(result.scope === 'global' + ? 'Note: Cached copy in ~/.ai-devkit/skills/ preserved.' + : 'Note: Cached copy in ~/.ai-devkit/skills/ preserved for other projects.'); +} + +export function renderUpdateSummary(summary: UpdateSummary): void { + const errors = summary.results.filter(result => result.status === 'error'); + + ui.summary({ + title: 'Summary', + items: [ + { type: 'success', count: summary.successful, label: 'updated' }, + { type: 'warning', count: summary.skipped, label: 'skipped' }, + { type: 'error', count: summary.failed, label: 'failed' }, + ], + details: errors.length > 0 ? { + title: 'Errors', + items: errors.map(error => { + let tip: string | undefined; + + if (error.message.includes('uncommitted') || error.message.includes('unstaged')) { + tip = `Run 'git status' in ~/.ai-devkit/skills/${error.registryId} to see details.`; + } else if (error.message.includes('network') || error.message.includes('timeout')) { + tip = 'Check your internet connection and try again.'; + } + + return { + message: `${error.registryId}: ${error.message}`, + tip, + }; + }), + } : undefined, + }); +} + +export function renderProjectSkills(skills: InstalledSkill[]): void { + if (skills.length === 0) { + ui.warning('No skills installed in this project.'); + ui.info('Install a skill with: ai-devkit skill add / [skill-name]'); + return; + } + + ui.text('Installed Skills:', { breakline: true }); + ui.table({ + headers: ['Skill Name', 'Registry', 'Environments'], + rows: skills.map(skill => [ + skill.name, + skill.registry, + skill.environments.join(', ') + ]), + columnStyles: [chalk.cyan, chalk.dim, chalk.green] + }); + ui.text(`Total: ${skills.length} skill(s)`, { breakline: true }); +} + +export function renderGlobalSkills(skills: GlobalInstalledSkill[]): void { + 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 }); +} + +export function renderSkillSearchResults(keyword: string, results: SkillEntry[]): void { + if (results.length === 0) { + ui.warning(`No skills found matching "${keyword}"`); + ui.info('Try a different keyword or use --refresh to update the skill index'); + return; + } + + ui.text(`Found ${results.length} skill(s) matching "${keyword}":`, { breakline: true }); + ui.table({ + headers: ['Skill Name', 'Registry', 'Description'], + rows: results.map(skill => [ + skill.name, + skill.registry, + truncate(skill.description, 60, '...') + ]), + columnStyles: [chalk.cyan, chalk.dim, chalk.white] + }); + ui.text('\nInstall with: ai-devkit skill add [skill-name]', { breakline: true }); +} + +export function renderSkillIndexRebuild(result: SkillIndexRebuildResult): void { + ui.success(`Skill index rebuilt: ${result.skillCount} skills`); + ui.info(`Written to: ${result.outputPath}`); +} diff --git a/packages/cli/src/services/install/install.service.ts b/packages/cli/src/services/install/install.service.ts index ae5b26b6..81fca771 100644 --- a/packages/cli/src/services/install/install.service.ts +++ b/packages/cli/src/services/install/install.service.ts @@ -1,5 +1,4 @@ import { ConfigManager } from '../../lib/Config.js'; -import { EnvironmentSelector } from '../../lib/EnvironmentSelector.js'; import { SkillService } from '../skill/skill.service.js'; import { TemplateManager } from '../../lib/TemplateManager.js'; import { InstallConfigData } from '../../util/config.js'; @@ -47,7 +46,7 @@ export async function reconcileAndInstall( const configManager = new ConfigManager(); const docsDir = await configManager.getDocsDir(); const templateManager = new TemplateManager({ docsDir }); - const skillService = new SkillService(configManager, new EnvironmentSelector()); + const skillService = new SkillService(configManager); const report: InstallReport = { environments: { installed: 0, skipped: 0, failed: 0 }, @@ -135,8 +134,8 @@ export async function reconcileAndInstall( for (const skill of config.skills) { try { - const status = await skillService.addSkill(skill.registry, skill.name); - if (status === 'matched') { + const result = await skillService.addSkill(skill.registry, skill.name); + if (result.status === 'matched') { report.skills.skipped += 1; } else { report.skills.installed += 1; @@ -144,7 +143,7 @@ export async function reconcileAndInstall( report.items.push({ section: 'skill', name: skill.name, - status: status === 'matched' ? 'matched' : 'installed' + status: result.status === 'matched' ? 'matched' : 'installed' }); } catch (error) { report.skills.failed += 1; diff --git a/packages/cli/src/services/skill/index/skill-index.service.ts b/packages/cli/src/services/skill/index/skill-index.service.ts index 75a179da..8efc8181 100644 --- a/packages/cli/src/services/skill/index/skill-index.service.ts +++ b/packages/cli/src/services/skill/index/skill-index.service.ts @@ -4,7 +4,6 @@ import { SkillRegistryService, SKILL_CACHE_DIR } from '../registry/skill-registr import { extractSkillDescription } from '../skill-description.js'; import { fetchGitHead } from '../../../util/git.js'; import { fetchGitHubSkillPaths, fetchRawGitHubFile } from '../../../util/github.js'; -import { ui } from '../../../util/terminal-ui.js'; import { getErrorMessage } from '../../../util/text.js'; import { parseLocalRegistryPath } from '../registry/skill-registry-source.js'; import { discoverRegistrySkills } from '../registry/registry-skill-discovery.js'; @@ -33,6 +32,11 @@ export interface SkillIndexData { skills: SkillEntry[]; } +export interface SkillIndexRebuildResult { + outputPath: string; + skillCount: number; +} + export class SkillIndexService { constructor( private registry: SkillRegistryService, @@ -50,19 +54,17 @@ export class SkillIndexService { return this.searchSkillIndex(index, normalizedKeyword); } - async rebuildIndex(outputPath?: string): Promise { + async rebuildIndex(outputPath?: string): Promise { const targetPath = outputPath || this.repository.defaultPath; - const spinner = ui.spinner('Rebuilding skill index from all registries...'); - spinner.start(); - try { const newIndex = await this.buildSkillIndex(); await this.repository.write(newIndex, targetPath); - spinner.succeed(`Skill index rebuilt: ${newIndex.skills.length} skills`); - ui.info(`Written to: ${targetPath}`); + return { + outputPath: targetPath, + skillCount: newIndex.skills.length, + }; } catch (error: unknown) { - spinner.fail('Failed to rebuild index'); throw new Error(`Failed to rebuild skill index: ${getErrorMessage(error)}`); } } @@ -110,41 +112,30 @@ export class SkillIndexService { if (age < INDEX_TTL_MS) { return this.refreshLocalRegistryEntries(index); } - ui.info(`Index is older than 24h, checking for updates...`); - } catch (ignore) { - ui.warning('Failed to read skill index, will rebuild'); + } catch { + // Fall through to rebuilding the index. } } if (!indexExists && !forceRefresh) { - const spinner = ui.spinner('Fetching seed index...'); - spinner.start(); try { const response = await fetch(SEED_INDEX_URL); if (response.ok) { const seedIndex = (await response.json()) as SkillIndexData; await this.repository.write(seedIndex); - spinner.succeed('Seed index fetched successfully'); return this.refreshLocalRegistryEntries(seedIndex); } - } catch (ignore) { - spinner.fail('Failed to fetch seed index, falling back to build'); + } catch { + // Fall through to building from registries. } } - const spinner = ui.spinner('Building skill index from registries...'); - spinner.start(); - try { const newIndex = await this.buildSkillIndex(); await this.repository.write(newIndex); - spinner.succeed('Skill index updated'); return newIndex; } catch (error: unknown) { - spinner.fail('Failed to build index'); - if (!forceRefresh && await this.repository.exists()) { - ui.warning('Using stale index due to error'); return await this.repository.readRequired(); } @@ -159,8 +150,6 @@ export class SkillIndexService { const existingIndex = await this.repository.read(); const localSkills = await this.readConfiguredLocalRegistrySkills(registry.registries); - ui.info(`Building skill index from ${registryIds.length} registries...`); - const HEAD_CONCURRENCY = 10; type HeadResult = { registryId: string; headSha?: string; owner?: string; repo?: string; error?: string }; const headResults: HeadResult[] = []; @@ -209,8 +198,6 @@ export class SkillIndexService { } } - ui.info(`${registriesToFetch.length} registries need updating, ${unchangedSkills.length} skills cached`); - const CONCURRENCY = 5; const newSkills: SkillEntry[] = []; diff --git a/packages/cli/src/services/skill/installer/skill-installer.service.ts b/packages/cli/src/services/skill/installer/skill-installer.service.ts index d1ea6a5f..7a3d20ff 100644 --- a/packages/cli/src/services/skill/installer/skill-installer.service.ts +++ b/packages/cli/src/services/skill/installer/skill-installer.service.ts @@ -2,17 +2,14 @@ import fs from 'fs-extra'; import * as path from 'path'; import * as os from 'os'; import { ConfigManager } from '../../../lib/Config.js'; -import { EnvironmentSelector } from '../../../lib/EnvironmentSelector.js'; import { SkillRegistryService, SKILL_CACHE_DIR } from '../registry/skill-registry.service.js'; import { getAllEnvironments, getGlobalSkillPath, getSkillCapableEnvironments, getSkillPath, validateEnvironmentCodes } from '../../../util/env.js'; import { validateRegistryId, validateSkillName, isValidSkillName } from '../skill-validation.js'; import { parseLocalRegistryPath } from '../registry/skill-registry-source.js'; import { discoverRegistrySkills, resolveContainedSkill } from '../registry/registry-skill-discovery.js'; -import { isInteractiveTerminal } from '../../../util/terminal.js'; -import { ui } from '../../../util/terminal-ui.js'; import { ConfigNotFoundError, NotFoundError, ValidationError } from '../../../util/errors.js'; import type { EnvironmentCode } from '../../../types.js'; -import type { AddSkillOptions, GlobalInstalledSkill, InstalledSkill, RegistrySkillChoice, RemoveSkillOptions } from '../skill.types.js'; +import type { AddSkillOptions, GlobalInstalledSkill, InstalledSkill, RegistrySkillChoice, RemoveSkillOptions, SkillInstallItem, SkillInstallResult, SkillRemoveResult } from '../skill.types.js'; interface ResolvedInstallTargets { targets: string[]; @@ -28,7 +25,6 @@ export class SkillInstallerService { constructor( private configManager: ConfigManager, private registry: SkillRegistryService, - private environmentSelector: EnvironmentSelector = new EnvironmentSelector(), ) { } /** @@ -38,7 +34,7 @@ export class SkillInstallerService { registryId: string, skillName: string, options: AddSkillOptions = {} - ): Promise<'installed' | 'matched'> { + ): Promise { if (!skillName) { throw new ValidationError('Skill name is required. Re-run with: ai-devkit skill add '); } @@ -50,27 +46,35 @@ export class SkillInstallerService { registryId: string, skillNames: string[], options: AddSkillOptions = {} - ): Promise<'installed' | 'matched'> { + ): Promise { if (skillNames.length === 0) { throw new ValidationError('At least one skill name is required.'); } - ui.info(`Validating registry: ${registryId}`); validateRegistryId(registryId); const { repoPath, isLocal } = await this.prepareInstallableRegistry(registryId); const selectedEnvironments = await this.resolveInstallEnvironments(options); const installContext = this.buildInstallContext(selectedEnvironments, options); let status: 'installed' | 'matched' = 'matched'; + const items: SkillInstallItem[] = []; for (const resolvedSkillName of skillNames) { - const itemStatus = await this.installResolvedSkill( + const result = await this.installResolvedSkill( registryId, repoPath, resolvedSkillName, options, installContext, isLocal ); - if (itemStatus === 'installed') { + items.push(...result.items); + if (result.status === 'installed') { status = 'installed'; } } - return status; + + return { + status, + registryId, + installMode: installContext.installMode, + environments: installContext.capableEnvironments, + items, + }; } async listInstallableSkills(registryId: string): Promise { @@ -94,10 +98,7 @@ export class SkillInstallerService { } private async prepareInstallableRegistry(registryId: string): Promise<{ repoPath: string; isLocal: boolean }> { - const spinner = ui.spinner('Fetching registries...'); - spinner.start(); const registry = await this.registry.fetchMergedRegistry(); - spinner.succeed('Registries fetched'); const gitUrl = registry.registries[registryId]; const cachedPath = path.join(SKILL_CACHE_DIR, registryId); @@ -122,7 +123,6 @@ export class SkillInstallerService { const config = await this.configManager.read(); if (!config || !config.environments || config.environments.length === 0) { - ui.warning('No .ai-devkit.json found or no environments configured.'); return []; } @@ -236,8 +236,7 @@ export class SkillInstallerService { /** * Remove a skill from the project */ - async removeSkill(skillName: string, options: RemoveSkillOptions = {}): Promise { - ui.info(`Removing skill: ${skillName}`); + async removeSkill(skillName: string, options: RemoveSkillOptions = {}): Promise { validateSkillName(skillName); if (options.environments && options.environments.length > 0 && !options.global) { @@ -245,43 +244,43 @@ export class SkillInstallerService { } if (options.global) { - await this.removeGlobalSkill(skillName, options.environments); - return; + return this.removeGlobalSkill(skillName, options.environments); } - await this.removeProjectSkill(skillName); + return this.removeProjectSkill(skillName); } - private async removeProjectSkill(skillName: string): Promise { + private async removeProjectSkill(skillName: string): Promise { 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'); } const { targets } = resolveInstallationTargets(config.environments); - let removedCount = 0; + const removedTargets: string[] = []; for (const targetDir of targets) { const skillPath = path.join(process.cwd(), targetDir, skillName); if (await fs.pathExists(skillPath)) { await fs.remove(skillPath); - ui.text(` → Removed from ${targetDir}`); - removedCount++; + removedTargets.push(targetDir); } } - if (removedCount === 0) { - ui.warning(`Skill "${skillName}" not found. Nothing to remove.`); - ui.info('Tip: Run "ai-devkit skill list" to see installed skills.'); - } else { + if (removedTargets.length > 0) { await this.configManager.removeSkill(skillName); - ui.success(`Successfully removed from ${removedCount} location(s).`); - ui.info(`Note: Cached copy in ~/.ai-devkit/skills/ preserved for other projects.`); } + + return { + skillName, + scope: 'project', + removedTargets, + failures: [], + }; } - private async removeGlobalSkill(skillName: string, envCodes?: string[]): Promise { + private async removeGlobalSkill(skillName: string, envCodes?: string[]): Promise { const environments: EnvironmentCode[] = envCodes && envCodes.length > 0 ? validateEnvironmentCodes(envCodes) : getAllEnvironments() @@ -318,7 +317,7 @@ export class SkillInstallerService { targets.set(skillPath, configuredRoot); } - let removedCount = 0; + const removedTargets: string[] = []; const failures: string[] = []; for (const [skillPath, configuredRoot] of targets) { @@ -334,46 +333,35 @@ export class SkillInstallerService { try { await fs.remove(skillPath); - ui.text(` → Removed from ~/${configuredRoot}`); - removedCount++; + removedTargets.push(`~/${configuredRoot}`); } 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('; ')}`); } + + return { + skillName, + scope: 'global', + removedTargets, + failures, + }; } /** * Update skills from registries */ private async resolveProjectEnvironments(): Promise { - ui.info('Loading project configuration...'); let config = await this.configManager.read(); if (!config) { - ui.info('No .ai-devkit.json found. Creating configuration...'); config = await this.configManager.create(); } if (!config.environments || config.environments.length === 0) { - if (!isInteractiveTerminal()) { - throw new ConfigNotFoundError('No environments configured. Run "ai-devkit init" or add "environments" in .ai-devkit.json.'); - } - - const selectedEnvs = await this.environmentSelector.selectSkillEnvironments(); - config.environments = selectedEnvs; - await this.configManager.update({ environments: selectedEnvs }); - ui.success('Configuration saved.'); + throw new ConfigNotFoundError('No environments configured. Run "ai-devkit init" or add "environments" in .ai-devkit.json.'); } return config.environments; @@ -381,7 +369,7 @@ export class SkillInstallerService { private async resolveGlobalEnvironments(envCodes?: string[]): Promise { if (!envCodes || envCodes.length === 0) { - return await this.environmentSelector.selectGlobalSkillEnvironments(); + throw new ValidationError('Global skill installation requires at least one environment.'); } const validCodes = validateEnvironmentCodes(envCodes); @@ -394,14 +382,14 @@ export class SkillInstallerService { } private async resolveInstallEnvironments(options: AddSkillOptions): Promise { - if (options.environments && options.environments.length > 0 && !options.global) { - throw new ValidationError('--env can only be used with --global'); - } - if (options.global) { return await this.resolveGlobalEnvironments(options.environments); } + if (options.environments && options.environments.length > 0) { + return options.environments; + } + return await this.resolveProjectEnvironments(); } @@ -412,21 +400,24 @@ export class SkillInstallerService { options: AddSkillOptions, installContext: ResolvedInstallContext, isLocal: boolean, - ): Promise<'installed' | 'matched'> { - ui.info(`Validating skill: ${resolvedSkillName} from ${registryId}`); + ): Promise<{ status: 'installed' | 'matched'; items: SkillInstallItem[] }> { validateSkillName(resolvedSkillName); const skillPath = isLocal ? await resolveContainedSkill(registryId, repoPath, resolvedSkillName) : await this.resolveInstallableSkillPath(repoPath, registryId, resolvedSkillName); - ui.info(`Installing skill to ${installContext.installMode}...`); let installed = false; + const items: SkillInstallItem[] = []; for (const targetDir of installContext.targets) { const targetPath = path.join(installContext.baseDir, targetDir, resolvedSkillName); if (await fs.pathExists(targetPath)) { - ui.text(` → ${targetDir}/${resolvedSkillName} (already exists, skipped)`); + items.push({ + skillName: resolvedSkillName, + target: `${targetDir}/${resolvedSkillName}`, + action: 'skipped', + }); continue; } @@ -434,10 +425,18 @@ export class SkillInstallerService { try { await fs.symlink(skillPath, targetPath, 'dir'); - ui.text(` → ${targetDir}/${resolvedSkillName} (symlinked)`); + items.push({ + skillName: resolvedSkillName, + target: `${targetDir}/${resolvedSkillName}`, + action: 'symlinked', + }); } catch (ignoreError) { await fs.copy(skillPath, targetPath); - ui.text(` → ${targetDir}/${resolvedSkillName} (copied)`); + items.push({ + skillName: resolvedSkillName, + target: `${targetDir}/${resolvedSkillName}`, + action: 'copied', + }); } installed = true; } @@ -449,10 +448,10 @@ export class SkillInstallerService { }); } - ui.text(`Successfully installed: ${resolvedSkillName}`); - ui.info(` Source: ${registryId}`); - ui.info(` Installed to (${installContext.installMode}): ${installContext.capableEnvironments.join(', ')}`); - return installed ? 'installed' : 'matched'; + return { + status: installed ? 'installed' : 'matched', + items, + }; } private buildInstallContext( diff --git a/packages/cli/src/services/skill/registry/skill-registry.service.ts b/packages/cli/src/services/skill/registry/skill-registry.service.ts index 1dcb0ad3..14efe566 100644 --- a/packages/cli/src/services/skill/registry/skill-registry.service.ts +++ b/packages/cli/src/services/skill/registry/skill-registry.service.ts @@ -4,7 +4,6 @@ import * as os from 'os'; import { ConfigManager } from '../../../lib/Config.js'; import { GlobalConfigManager } from '../../../lib/GlobalConfig.js'; import { ensureGitInstalled, cloneRepository, isGitRepository, pullRepository } from '../../../util/git.js'; -import { ui } from '../../../util/terminal-ui.js'; import { getErrorMessage } from '../../../util/text.js'; import { CliError, NotFoundError } from '../../../util/errors.js'; import { normalizeRegistrySourceInput, normalizeRegistrySources, parseLocalRegistryPath, planSkillRegistryAdd } from './skill-registry-source.js'; @@ -74,8 +73,7 @@ export class SkillRegistryService { try { const defaultRegistry = await this.fetchDefaultRegistry(); defaultRegistries = defaultRegistry.registries || {}; - } catch (error: unknown) { - ui.warning(`Failed to fetch default registry: ${getErrorMessage(error)}`); + } catch { defaultRegistries = {}; } @@ -96,13 +94,8 @@ export class SkillRegistryService { if (await fs.pathExists(repoPath)) { if (await isGitRepository(repoPath)) { - ui.info(`Updating cached repository ${registryId}...`); await pullRepository(repoPath); - ui.success(`Cached repository ${registryId} updated`); - } else { - ui.warning(`Cached registry ${registryId} is not a git repository, using as-is.`); } - ui.text(' → Using cached repository'); return repoPath; } @@ -110,11 +103,9 @@ export class SkillRegistryService { throw new NotFoundError(`Registry "${registryId}" is not cached and has no configured URL.`, { registryId }); } - ui.info(`Cloning ${registryId} (this may take a moment)...`); await fs.ensureDir(path.dirname(repoPath)); const result = await cloneRepository(SKILL_CACHE_DIR, registryId, gitUrl); - ui.success(`${registryId} cloned successfully`); return result; } @@ -259,21 +250,16 @@ export class SkillRegistryService { ); } - ui.info(`Using local registry ${registryId}: ${root}`); return root; } private async refreshOrUseStaleCache(registryId: string, gitUrl?: string): Promise { const cachedPath = path.join(SKILL_CACHE_DIR, registryId); - ui.info(`Refreshing registry ${registryId}...`); try { - const repositoryPath = await this.cloneRepositoryToCache(registryId, gitUrl); - ui.success(`Registry ${registryId} refreshed.`); - return repositoryPath; + return await this.cloneRepositoryToCache(registryId, gitUrl); } catch (error: unknown) { if (await fs.pathExists(cachedPath)) { - ui.warning(`Could not refresh registry ${registryId}: ${getErrorMessage(error)}. Using cached registry contents for this run.`); return cachedPath; } @@ -282,11 +268,6 @@ export class SkillRegistryService { } async updateSkills(registryId?: string): Promise { - ui.info(registryId - ? `Updating registry: ${registryId}...` - : 'Updating all skills...' - ); - const cacheDir = SKILL_CACHE_DIR; const configured = await this.fetchMergedRegistry(); const localEntries = Object.entries(configured.registries) @@ -303,17 +284,13 @@ export class SkillRegistryService { status: 'skipped', message: 'Local registry uses the live filesystem; nothing to update', }); - ui.warning(`${id} skipped (Local registry uses the live filesystem; nothing to update)`); } if (!await fs.pathExists(cacheDir)) { if (registryId && localEntries.length === 0) { throw new NotFoundError(`Registry "${registryId}" not found.`, { registryId }); } - ui.warning('No skills cache found. Nothing to update.'); - const summary = this.summarize(results); - this.displayUpdateSummary(summary); - return summary; + return this.summarize(results); } const entries = await fs.readdir(cacheDir, { withFileTypes: true }); @@ -345,22 +322,11 @@ export class SkillRegistryService { } for (const registry of registries) { - ui.info(`Updating ${registry.id}...`); const result = await this.updateRegistry(registry.path, registry.id); results.push(result); - if (result.status === 'success') { - ui.success(`${registry.id} updated`); - } else if (result.status === 'skipped') { - ui.warning(`${registry.id} skipped (${result.message})`); - } else { - ui.error(`${registry.id} failed`); - } } - const summary = this.summarize(results); - this.displayUpdateSummary(summary); - - return summary; + return this.summarize(results); } private summarize(results: UpdateResult[]): UpdateSummary { @@ -401,33 +367,4 @@ export class SkillRegistryService { } } - private displayUpdateSummary(summary: UpdateSummary): void { - const errors = summary.results.filter(r => r.status === 'error'); - - ui.summary({ - title: 'Summary', - items: [ - { type: 'success', count: summary.successful, label: 'updated' }, - { type: 'warning', count: summary.skipped, label: 'skipped' }, - { type: 'error', count: summary.failed, label: 'failed' }, - ], - details: errors.length > 0 ? { - title: 'Errors', - items: errors.map(error => { - let tip: string | undefined; - - if (error.message.includes('uncommitted') || error.message.includes('unstaged')) { - tip = `Run 'git status' in ~/.ai-devkit/skills/${error.registryId} to see details.`; - } else if (error.message.includes('network') || error.message.includes('timeout')) { - tip = 'Check your internet connection and try again.'; - } - - return { - message: `${error.registryId}: ${error.message}`, - tip, - }; - }), - } : undefined, - }); - } } diff --git a/packages/cli/src/services/skill/skill-builtins.ts b/packages/cli/src/services/skill/skill-builtins.ts index f1661fa7..645f98da 100644 --- a/packages/cli/src/services/skill/skill-builtins.ts +++ b/packages/cli/src/services/skill/skill-builtins.ts @@ -1,6 +1,4 @@ import { isValidSkillName } from './skill-validation.js'; -import { getErrorMessage } from '../../util/text.js'; -import { ui } from '../../util/terminal-ui.js'; const BUILTIN_SKILLS_URL = 'https://raw.githubusercontent.com/codeaholicguy/ai-devkit/main/skills/built-in.json'; @@ -60,10 +58,7 @@ async function loadBuiltinSkillNames(): Promise { } return manifest; - } catch (error: unknown) { - ui.warning( - `Failed to load built-in skills manifest: ${getErrorMessage(error)}. Using bundled fallback.` - ); + } catch { return FALLBACK_BUILTIN_SKILL_NAMES; } } diff --git a/packages/cli/src/services/skill/skill.service.ts b/packages/cli/src/services/skill/skill.service.ts index 0c0f84ed..ebe150f6 100644 --- a/packages/cli/src/services/skill/skill.service.ts +++ b/packages/cli/src/services/skill/skill.service.ts @@ -1,6 +1,5 @@ import { ConfigManager } from '../../lib/Config.js'; import { GlobalConfigManager } from '../../lib/GlobalConfig.js'; -import { EnvironmentSelector } from '../../lib/EnvironmentSelector.js'; import { SkillInstallerService } from './installer/skill-installer.service.js'; import { SkillIndexService } from './index/skill-index.service.js'; import { SkillRegistryService } from './registry/skill-registry.service.js'; @@ -12,10 +11,13 @@ import type { RegistrySkillChoice, RemoveSkillOptions, RemoveSkillRegistryCommandOptions, + SkillInstallResult, + SkillRemoveResult, } from './skill.types.js'; import type { SkillEntry } from './index/skill-index.service.js'; import type { SkillRegistryAddStatus } from './registry/skill-registry-source.js'; import type { UpdateSummary } from './registry/skill-registry.service.js'; +import type { SkillIndexRebuildResult } from './index/skill-index.service.js'; export class SkillService { private readonly installer: SkillInstallerService; @@ -24,19 +26,18 @@ export class SkillService { constructor( configManager: ConfigManager, - environmentSelector: EnvironmentSelector = new EnvironmentSelector(), globalConfigManager: GlobalConfigManager = new GlobalConfigManager(), ) { this.registry = new SkillRegistryService(configManager, globalConfigManager); this.index = new SkillIndexService(this.registry); - this.installer = new SkillInstallerService(configManager, this.registry, environmentSelector); + this.installer = new SkillInstallerService(configManager, this.registry); } addSkill( registryId: string, skillName: string, options: AddSkillOptions = {}, - ): Promise<'installed' | 'matched'> { + ): Promise { return this.installer.addSkill(registryId, skillName, options); } @@ -44,7 +45,7 @@ export class SkillService { registryId: string, skillNames: string[], options: AddSkillOptions = {}, - ): Promise<'installed' | 'matched'> { + ): Promise { return this.installer.addSkills(registryId, skillNames, options); } @@ -60,7 +61,7 @@ export class SkillService { return this.installer.listGlobalSkills(envCodes); } - removeSkill(skillName: string, options: RemoveSkillOptions = {}): Promise { + removeSkill(skillName: string, options: RemoveSkillOptions = {}): Promise { return this.installer.removeSkill(skillName, options); } @@ -93,7 +94,7 @@ export class SkillService { return this.index.findSkills(keyword, options); } - rebuildIndex(outputPath?: string): Promise { + rebuildIndex(outputPath?: string): Promise { return this.index.rebuildIndex(outputPath); } } diff --git a/packages/cli/src/services/skill/skill.types.ts b/packages/cli/src/services/skill/skill.types.ts index 9f60a83d..375e4696 100644 --- a/packages/cli/src/services/skill/skill.types.ts +++ b/packages/cli/src/services/skill/skill.types.ts @@ -20,11 +20,34 @@ export interface AddSkillOptions { environments?: string[]; } +export type SkillInstallAction = 'symlinked' | 'copied' | 'skipped'; + +export interface SkillInstallItem { + skillName: string; + target: string; + action: SkillInstallAction; +} + +export interface SkillInstallResult { + status: 'installed' | 'matched'; + registryId: string; + installMode: 'global' | 'project'; + environments: string[]; + items: SkillInstallItem[]; +} + export interface RemoveSkillOptions { global?: boolean; environments?: string[]; } +export interface SkillRemoveResult { + skillName: string; + scope: 'global' | 'project'; + removedTargets: string[]; + failures: string[]; +} + export interface AddSkillRegistryCommandOptions { global?: boolean; force?: boolean;