Skip to content

Commit 13e05c5

Browse files
committed
Add skill find command
1 parent b4141d0 commit 13e05c5

6 files changed

Lines changed: 548 additions & 70 deletions

File tree

packages/cli/src/__tests__/lib/SkillManager.test.ts

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as fs from "fs-extra";
2-
import * as https from "https";
32
import * as os from "os";
43
import * as path from "path";
54
import { SkillManager } from "../../lib/SkillManager";
@@ -8,7 +7,6 @@ import { EnvironmentSelector } from "../../lib/EnvironmentSelector";
87
import { GlobalConfigManager } from "../../lib/GlobalConfig";
98
import * as gitUtil from "../../util/git";
109
import * as skillUtil from "../../util/skill";
11-
import { EventEmitter } from "events";
1210

1311
jest.mock("fs-extra", () => ({
1412
pathExists: jest.fn(),
@@ -19,8 +17,8 @@ jest.mock("fs-extra", () => ({
1917
readdir: jest.fn(),
2018
realpath: jest.fn(),
2119
readJson: jest.fn(),
20+
writeJson: jest.fn(),
2221
}));
23-
jest.mock("https");
2422
jest.mock("../../lib/Config");
2523
jest.mock("../../lib/EnvironmentSelector");
2624
jest.mock("../../lib/GlobalConfig");
@@ -39,7 +37,6 @@ jest.mock("ora", () => {
3937
});
4038

4139
const mockedFs = fs as jest.Mocked<typeof fs>;
42-
const mockedHttps = https as jest.Mocked<typeof https>;
4340
const MockedConfigManager = ConfigManager as jest.MockedClass<
4441
typeof ConfigManager
4542
>;
@@ -52,6 +49,17 @@ const MockedGlobalConfigManager = GlobalConfigManager as jest.MockedClass<
5249
const mockedGitUtil = gitUtil as jest.Mocked<typeof gitUtil>;
5350
const mockedSkillUtil = skillUtil as jest.Mocked<typeof skillUtil>;
5451

52+
function mockFetch(response: any) {
53+
global.fetch = jest.fn().mockResolvedValue({
54+
ok: true,
55+
json: () => Promise.resolve(response)
56+
});
57+
}
58+
59+
function mockFetchError(error: Error) {
60+
global.fetch = jest.fn().mockRejectedValue(error);
61+
}
62+
5563
describe("SkillManager", () => {
5664
let skillManager: SkillManager;
5765
let mockConfigManager: jest.Mocked<ConfigManager>;
@@ -97,7 +105,7 @@ describe("SkillManager", () => {
97105
);
98106

99107
beforeEach(() => {
100-
mockHttpsGet({
108+
mockFetch({
101109
registries: {
102110
[mockRegistryId]: mockGitUrl,
103111
},
@@ -127,33 +135,43 @@ describe("SkillManager", () => {
127135
expect(mockedGitUtil.ensureGitInstalled).toHaveBeenCalled();
128136
});
129137

130-
it("should fetch registry from GitHub", async () => {
138+
it("should fetch registry using fetch API", async () => {
139+
const originalFetch = global.fetch;
140+
global.fetch = jest.fn().mockResolvedValue({
141+
ok: true,
142+
json: () => Promise.resolve({ registries: { [mockRegistryId]: mockGitUrl } })
143+
});
144+
131145
await skillManager.addSkill(mockRegistryId, mockSkillName);
132146

133-
expect(mockedHttps.get).toHaveBeenCalled();
134-
const getCall = (mockedHttps.get as jest.Mock).mock.calls[0][0];
135-
expect(getCall).toContain("raw.githubusercontent.com");
136-
expect(getCall).toContain("registry.json");
147+
expect(global.fetch).toHaveBeenCalledWith(
148+
expect.stringContaining("registry.json")
149+
);
150+
151+
global.fetch = originalFetch;
137152
});
138153

139154
it("should throw error if registry ID not found", async () => {
140-
mockHttpsGet({
141-
registries: {
142-
"other/repo": "https://github.com/other/repo.git",
143-
},
155+
const originalFetch = global.fetch;
156+
global.fetch = jest.fn().mockResolvedValue({
157+
ok: true,
158+
json: () => Promise.resolve({
159+
registries: {
160+
"other/repo": "https://github.com/other/repo.git",
161+
},
162+
})
144163
});
145164

146165
(mockedFs.pathExists as any).mockImplementation((checkPath: string) => {
147-
if (checkPath.includes(mockRegistryId)) {
148-
return Promise.resolve(false);
149-
}
150-
166+
if (checkPath.includes(mockRegistryId)) return Promise.resolve(false);
151167
return Promise.resolve(true);
152168
});
153169

154170
await expect(
155171
skillManager.addSkill(mockRegistryId, mockSkillName),
156172
).rejects.toThrow(`Registry "${mockRegistryId}" not found`);
173+
174+
global.fetch = originalFetch;
157175
});
158176

159177
it("should prefer custom registry URL over default", async () => {
@@ -203,7 +221,7 @@ describe("SkillManager", () => {
203221
const realGlobalConfigManager = new RealGlobalConfigManager();
204222

205223
mockGlobalConfigManager.getSkillRegistries.mockResolvedValue({});
206-
mockHttpsGet({ registries: {} });
224+
mockFetch({ registries: {} });
207225

208226
(mockedFs.pathExists as any).mockImplementation((checkPath: string) => {
209227
if (checkPath.includes(`${path.sep}skills${path.sep}${mockSkillName}`)) {
@@ -245,7 +263,6 @@ describe("SkillManager", () => {
245263
});
246264

247265
it("should use cached registry when remote fetch fails", async () => {
248-
mockHttpsGetError(new Error("offline"));
249266
mockGlobalConfigManager.getSkillRegistries.mockResolvedValue({});
250267

251268
(mockedFs.pathExists as any).mockImplementation((checkPath: string) => {
@@ -827,33 +844,120 @@ describe("SkillManager", () => {
827844
expect(result.failed).toBe(1);
828845
});
829846
});
830-
});
831847

832-
function mockHttpsGet(responseData: any) {
833-
(mockedHttps.get as jest.Mock).mockImplementation(
834-
(url: string, callback: any) => {
835-
const response = new EventEmitter() as any;
836-
response.statusCode = 200;
848+
describe("findSkills", () => {
849+
const mockSkillIndex = {
850+
meta: {
851+
version: 1,
852+
createdAt: Date.now() - 1000,
853+
updatedAt: Date.now() - 1000,
854+
registriesHash: "repo1|repo2",
855+
registryHeads: {
856+
"anthropics/skills": "abc123",
857+
"vercel-labs/agent-skills": "def456",
858+
},
859+
},
860+
skills: [
861+
{
862+
name: "typescript-helper",
863+
registry: "anthropics/skills",
864+
path: "skills/typescript-helper",
865+
description: "TypeScript development utilities",
866+
lastIndexed: Date.now(),
867+
},
868+
{
869+
name: "react-components",
870+
registry: "vercel-labs/agent-skills",
871+
path: "skills/react-components",
872+
description: "Build React components with best practices",
873+
lastIndexed: Date.now(),
874+
},
875+
{
876+
name: "frontend-design",
877+
registry: "anthropics/skills",
878+
path: "skills/frontend-design",
879+
description: "Frontend design patterns and components",
880+
lastIndexed: Date.now(),
881+
},
882+
],
883+
};
884+
885+
beforeEach(() => {
886+
mockGlobalConfigManager.getSkillRegistries.mockResolvedValue({});
837887

838-
process.nextTick(() => {
839-
callback(response);
840-
response.emit("data", JSON.stringify(responseData));
841-
response.emit("end");
888+
mockedGitUtil.fetchGitHead.mockImplementation(async (url: string) => {
889+
if (url.includes('anthropics')) return 'abc123';
890+
if (url.includes('vercel')) return 'def456';
891+
return '000000';
842892
});
893+
});
843894

844-
return {
845-
on: jest.fn(),
846-
};
847-
},
848-
);
849-
}
895+
it("should throw error if keyword is empty", async () => {
896+
await expect(skillManager.findSkills("")).rejects.toThrow("Keyword is required");
897+
await expect(skillManager.findSkills(" ")).rejects.toThrow("Keyword is required");
898+
});
850899

851-
function mockHttpsGetError(error: Error) {
852-
(mockedHttps.get as jest.Mock).mockImplementation(() => ({
853-
on: (event: string, handler: (err: Error) => void) => {
854-
if (event === "error") {
855-
process.nextTick(() => handler(error));
856-
}
857-
},
858-
}));
859-
}
900+
it("should load and use fresh index when available", async () => {
901+
(mockedFs.pathExists as any).mockResolvedValue(true);
902+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
903+
904+
const results = await skillManager.findSkills("typescript");
905+
906+
expect(mockedFs.readJson).toHaveBeenCalledWith(
907+
expect.stringContaining("skills.json")
908+
);
909+
expect(results).toHaveLength(1);
910+
expect(results[0].name).toBe("typescript-helper");
911+
});
912+
913+
it("should search by skill name", async () => {
914+
(mockedFs.pathExists as any).mockResolvedValue(true);
915+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
916+
917+
const results = await skillManager.findSkills("react");
918+
919+
expect(results).toHaveLength(1);
920+
expect(results[0].name).toBe("react-components");
921+
});
922+
923+
it("should search by description", async () => {
924+
(mockedFs.pathExists as any).mockResolvedValue(true);
925+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
926+
927+
const results = await skillManager.findSkills("design");
928+
929+
expect(results).toHaveLength(1);
930+
expect(results[0].name).toBe("frontend-design");
931+
});
932+
933+
it("should be case-insensitive", async () => {
934+
(mockedFs.pathExists as any).mockResolvedValue(true);
935+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
936+
937+
const results = await skillManager.findSkills("TYPESCRIPT");
938+
939+
expect(results).toHaveLength(1);
940+
expect(results[0].name).toBe("typescript-helper");
941+
});
942+
943+
it("should return multiple matches", async () => {
944+
(mockedFs.pathExists as any).mockResolvedValue(true);
945+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
946+
947+
const results = await skillManager.findSkills("component");
948+
949+
expect(results).toHaveLength(2);
950+
expect(results.map(r => r.name)).toContain("react-components");
951+
expect(results.map(r => r.name)).toContain("frontend-design");
952+
});
953+
954+
it("should return empty array when no matches found", async () => {
955+
(mockedFs.pathExists as any).mockResolvedValue(true);
956+
(mockedFs.readJson as any).mockResolvedValue(mockSkillIndex);
957+
958+
const results = await skillManager.findSkills("nonexistent");
959+
960+
expect(results).toEqual([]);
961+
});
962+
});
963+
});

packages/cli/src/commands/skill.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,40 @@ export function registerSkillCommand(program: Command): void {
8888
process.exit(1);
8989
}
9090
});
91+
92+
skillCommand
93+
.command('find <keyword>')
94+
.description('Search for skills across all registries')
95+
.option('--refresh', 'Force rebuild the skill index')
96+
.action(async (keyword: string, options: { refresh?: boolean }) => {
97+
try {
98+
const configManager = new ConfigManager();
99+
const skillManager = new SkillManager(configManager);
100+
101+
const results = await skillManager.findSkills(keyword, { refresh: options.refresh });
102+
103+
if (results.length === 0) {
104+
ui.warning(`No skills found matching "${keyword}"`);
105+
ui.info('Try a different keyword or use --refresh to update the skill index');
106+
return;
107+
}
108+
109+
ui.text(`Found ${results.length} skill(s) matching "${keyword}":`, { breakline: true });
110+
111+
ui.table({
112+
headers: ['Skill Name', 'Registry', 'Description'],
113+
rows: results.map(skill => [
114+
skill.name,
115+
skill.registry,
116+
skill.description.length > 60 ? skill.description.substring(0, 57) + '...' : skill.description
117+
]),
118+
columnStyles: [chalk.cyan, chalk.dim, chalk.white]
119+
});
120+
121+
ui.text(`\nInstall with: ai-devkit skill add <registry> <skill-name>`, { breakline: true });
122+
} catch (error: any) {
123+
ui.error(`Failed to search skills: ${error.message}`);
124+
process.exit(1);
125+
}
126+
});
91127
}

0 commit comments

Comments
 (0)