Skip to content

Commit 4327a3c

Browse files
committed
feat(cli): support targeted global skill install with env prompt
1 parent 43eb83c commit 4327a3c

File tree

8 files changed

+266
-124
lines changed

8 files changed

+266
-124
lines changed

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,38 @@ describe('EnvironmentSelector', () => {
220220
expect(choices).toHaveLength(2);
221221
});
222222
});
223+
224+
describe('selectGlobalSkillEnvironments', () => {
225+
it('should create choices from global-skill-capable environments', async () => {
226+
mockPrompt.mockResolvedValue({ environments: ['claude'] });
227+
228+
await selector.selectGlobalSkillEnvironments();
229+
230+
expect(mockPrompt).toHaveBeenCalledWith([
231+
expect.objectContaining({
232+
type: 'checkbox',
233+
name: 'environments',
234+
message: 'Select AI environments for global skill installation (use space to select, enter to confirm):',
235+
choices: expect.arrayContaining([
236+
expect.objectContaining({ value: 'cursor' }),
237+
expect.objectContaining({ value: 'claude' }),
238+
expect.objectContaining({ value: 'codex' }),
239+
expect.objectContaining({ value: 'gemini' }),
240+
expect.objectContaining({ value: 'opencode' }),
241+
expect.objectContaining({ value: 'antigravity' })
242+
]),
243+
validate: expect.any(Function)
244+
})
245+
]);
246+
});
247+
248+
it('should return selected global-skill environments', async () => {
249+
const selectedEnvs = ['claude', 'codex'];
250+
mockPrompt.mockResolvedValue({ environments: selectedEnvs });
251+
252+
const result = await selector.selectGlobalSkillEnvironments();
253+
254+
expect(result).toEqual(selectedEnvs);
255+
});
256+
});
223257
});

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ describe("SkillManager", () => {
121121
mockConfigManager.read.mockResolvedValue({
122122
environments: ["cursor", "claude"],
123123
} as any);
124+
mockEnvironmentSelector.selectGlobalSkillEnvironments.mockResolvedValue([
125+
"cursor",
126+
"claude",
127+
]);
124128
});
125129

126130
it("should successfully add a skill", async () => {
@@ -139,6 +143,61 @@ describe("SkillManager", () => {
139143
});
140144
});
141145

146+
it("should install to home directory when global option is enabled", async () => {
147+
(mockedFs.pathExists as any).mockImplementation((checkPath: string) => {
148+
if (
149+
checkPath === path.join(os.homedir(), ".cursor", "skills", mockSkillName)
150+
|| checkPath === path.join(os.homedir(), ".claude", "skills", mockSkillName)
151+
) {
152+
return Promise.resolve(false);
153+
}
154+
return Promise.resolve(true);
155+
});
156+
157+
await skillManager.addSkill(mockRegistryId, mockSkillName, { global: true });
158+
159+
expect(mockedFs.symlink).toHaveBeenCalledWith(
160+
expect.any(String),
161+
path.join(os.homedir(), ".cursor", "skills", mockSkillName),
162+
"dir",
163+
);
164+
expect(mockEnvironmentSelector.selectGlobalSkillEnvironments).toHaveBeenCalled();
165+
expect(mockConfigManager.read).not.toHaveBeenCalled();
166+
expect(mockConfigManager.create).not.toHaveBeenCalled();
167+
expect(mockConfigManager.addSkill).not.toHaveBeenCalled();
168+
});
169+
170+
it("should throw error when global env does not support skills", async () => {
171+
await expect(
172+
skillManager.addSkill(mockRegistryId, mockSkillName, { global: true, environments: ["windsurf"] }),
173+
).rejects.toThrow("Global skill installation is not supported for: windsurf");
174+
});
175+
176+
it("should throw error when env is provided without global option", async () => {
177+
await expect(
178+
skillManager.addSkill(mockRegistryId, mockSkillName, { environments: ["claude"] }),
179+
).rejects.toThrow("--env can only be used with --global");
180+
});
181+
182+
it("should install only selected global environments", async () => {
183+
(mockedFs.pathExists as any).mockImplementation((checkPath: string) => {
184+
if (checkPath === path.join(os.homedir(), ".claude", "skills", mockSkillName)) {
185+
return Promise.resolve(false);
186+
}
187+
return Promise.resolve(true);
188+
});
189+
190+
await skillManager.addSkill(mockRegistryId, mockSkillName, { global: true, environments: ["claude"] });
191+
192+
expect(mockedFs.symlink).toHaveBeenCalledTimes(1);
193+
expect(mockedFs.symlink).toHaveBeenCalledWith(
194+
expect.any(String),
195+
path.join(os.homedir(), ".claude", "skills", mockSkillName),
196+
"dir",
197+
);
198+
expect(mockEnvironmentSelector.selectGlobalSkillEnvironments).not.toHaveBeenCalled();
199+
});
200+
142201
it("should fetch registry using fetch API", async () => {
143202
const originalFetch = global.fetch;
144203
global.fetch = jest.fn().mockResolvedValue({
@@ -431,6 +490,23 @@ describe("SkillManager", () => {
431490
});
432491
});
433492

493+
it("should select environments when config exists but has no environments", async () => {
494+
mockConfigManager.read.mockResolvedValue({
495+
environments: [],
496+
} as any);
497+
mockEnvironmentSelector.selectSkillEnvironments.mockResolvedValue([
498+
"claude",
499+
]);
500+
501+
await skillManager.addSkill(mockRegistryId, mockSkillName);
502+
503+
expect(mockConfigManager.create).not.toHaveBeenCalled();
504+
expect(mockEnvironmentSelector.selectSkillEnvironments).toHaveBeenCalled();
505+
expect(mockConfigManager.update).toHaveBeenCalledWith({
506+
environments: ["claude"],
507+
});
508+
});
509+
434510
it("should throw error if no skill-capable environments configured", async () => {
435511
mockConfigManager.read.mockResolvedValue({
436512
environments: ["windsurf", "gemini"],

packages/cli/src/__tests__/util/env.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getGlobalCapableEnvironments,
1212
hasGlobalSupport,
1313
getSkillPath,
14+
getGlobalSkillPath,
1415
getSkillCapableEnvironments
1516
} from '../../util/env';
1617
import { EnvironmentCode } from '../../types';
@@ -39,7 +40,8 @@ describe('Environment Utilities', () => {
3940
name: 'Cursor',
4041
contextFileName: 'AGENTS.md',
4142
commandPath: '.cursor/commands',
42-
skillPath: '.cursor/skills'
43+
skillPath: '.cursor/skills',
44+
globalSkillPath: '.cursor/skills'
4345
});
4446
});
4547

@@ -300,6 +302,25 @@ describe('Environment Utilities', () => {
300302
});
301303
});
302304

305+
describe('getGlobalSkillPath', () => {
306+
it('should return global skill path for cursor', () => {
307+
expect(getGlobalSkillPath('cursor')).toBe('.cursor/skills');
308+
});
309+
310+
it('should return global skill path for codex', () => {
311+
expect(getGlobalSkillPath('codex')).toBe('.codex/skills');
312+
});
313+
314+
it('should return global skill path for gemini', () => {
315+
expect(getGlobalSkillPath('gemini')).toBe('.gemini/skills');
316+
});
317+
318+
it('should return undefined for environments without global skill support', () => {
319+
expect(getGlobalSkillPath('windsurf')).toBeUndefined();
320+
expect(getGlobalSkillPath('github')).toBeUndefined();
321+
});
322+
});
323+
303324
describe('getSkillCapableEnvironments', () => {
304325
it('should return only environments with skillPath defined', () => {
305326
const skillEnvs = getSkillCapableEnvironments();
@@ -353,4 +374,5 @@ describe('Environment Utilities', () => {
353374
expect(skillEnvs).toHaveLength(5);
354375
});
355376
});
377+
356378
});

packages/cli/src/commands/skill.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ export function registerSkillCommand(program: Command): void {
1313
skillCommand
1414
.command('add <registry-repo> <skill-name>')
1515
.description('Install a skill from a registry (e.g., ai-devkit skill add anthropics/skills frontend-design)')
16-
.action(async (registryRepo: string, skillName: string) => {
16+
.option('-g, --global', 'Install skill into configured global skill paths (~/<path>)')
17+
.option('-e, --env <environment...>', 'Target environment(s) for global install (e.g., --global --env claude)')
18+
.action(async (registryRepo: string, skillName: string, options: { global?: boolean; env?: string[] }) => {
1719
try {
1820
const configManager = new ConfigManager();
1921
const skillManager = new SkillManager(configManager);
2022

21-
await skillManager.addSkill(registryRepo, skillName);
23+
await skillManager.addSkill(registryRepo, skillName, {
24+
global: options.global,
25+
environments: options.env,
26+
});
2227
} catch (error: any) {
2328
ui.error(`Failed to add skill: ${error.message}`);
2429
process.exit(1);
Lines changed: 36 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import inquirer from "inquirer";
2-
import { EnvironmentCode } from "../types";
2+
import { EnvironmentCode, EnvironmentDefinition } from "../types";
33
import {
44
getAllEnvironments,
55
getEnvironmentDisplayName,
@@ -8,8 +8,15 @@ import {
88
} from "../util/env";
99

1010
export class EnvironmentSelector {
11-
async selectEnvironments(): Promise<EnvironmentCode[]> {
12-
const environments = getAllEnvironments();
11+
private async selectFromEnvironments(
12+
environments: EnvironmentDefinition[],
13+
message: string,
14+
emptyMessage: string
15+
): Promise<EnvironmentCode[]> {
16+
if (environments.length === 0) {
17+
console.log(emptyMessage);
18+
return [];
19+
}
1320

1421
const choices = environments.map((env) => ({
1522
name: env.name,
@@ -21,8 +28,7 @@ export class EnvironmentSelector {
2128
{
2229
type: "checkbox",
2330
name: "environments",
24-
message:
25-
"Select AI environments to set up (use space to select, enter to confirm):",
31+
message,
2632
choices,
2733
pageSize: 10,
2834
validate: (input: EnvironmentCode[]) => {
@@ -37,6 +43,14 @@ export class EnvironmentSelector {
3743
return answers.environments;
3844
}
3945

46+
async selectEnvironments(): Promise<EnvironmentCode[]> {
47+
return this.selectFromEnvironments(
48+
getAllEnvironments(),
49+
"Select AI environments to set up (use space to select, enter to confirm):",
50+
"No environments available."
51+
);
52+
}
53+
4054
async confirmOverride(conflicts: EnvironmentCode[]): Promise<boolean> {
4155
if (conflicts.length === 0) {
4256
return true;
@@ -70,70 +84,26 @@ export class EnvironmentSelector {
7084
}
7185

7286
async selectGlobalEnvironments(): Promise<EnvironmentCode[]> {
73-
const globalCapableEnvs = getGlobalCapableEnvironments();
74-
75-
if (globalCapableEnvs.length === 0) {
76-
console.log("No environments support global setup.");
77-
return [];
78-
}
79-
80-
const choices = globalCapableEnvs.map((env) => ({
81-
name: env.name,
82-
value: env.code as EnvironmentCode,
83-
short: env.name,
84-
}));
85-
86-
const answers = await inquirer.prompt([
87-
{
88-
type: "checkbox",
89-
name: "environments",
90-
message:
91-
"Select AI environments for global setup (use space to select, enter to confirm):",
92-
choices,
93-
pageSize: 10,
94-
validate: (input: EnvironmentCode[]) => {
95-
if (input.length === 0) {
96-
return "Please select at least one environment.";
97-
}
98-
return true;
99-
},
100-
},
101-
]);
102-
103-
return answers.environments;
87+
return this.selectFromEnvironments(
88+
getGlobalCapableEnvironments(),
89+
"Select AI environments for global setup (use space to select, enter to confirm):",
90+
"No environments support global setup."
91+
);
10492
}
10593

10694
async selectSkillEnvironments(): Promise<EnvironmentCode[]> {
107-
const skillCapableEnvs = getSkillCapableEnvironments();
108-
109-
if (skillCapableEnvs.length === 0) {
110-
console.log("No environments support skills.");
111-
return [];
112-
}
113-
114-
const choices = skillCapableEnvs.map((env) => ({
115-
name: env.name,
116-
value: env.code as EnvironmentCode,
117-
short: env.name,
118-
}));
119-
120-
const answers = await inquirer.prompt([
121-
{
122-
type: "checkbox",
123-
name: "environments",
124-
message:
125-
"Select AI environments for skill installation (use space to select, enter to confirm):",
126-
choices,
127-
pageSize: 10,
128-
validate: (input: EnvironmentCode[]) => {
129-
if (input.length === 0) {
130-
return "Please select at least one environment.";
131-
}
132-
return true;
133-
},
134-
},
135-
]);
95+
return this.selectFromEnvironments(
96+
getSkillCapableEnvironments(),
97+
"Select AI environments for skill installation (use space to select, enter to confirm):",
98+
"No environments support skills."
99+
);
100+
}
136101

137-
return answers.environments;
102+
async selectGlobalSkillEnvironments(): Promise<EnvironmentCode[]> {
103+
return this.selectFromEnvironments(
104+
getAllEnvironments().filter(env => env.globalSkillPath !== undefined),
105+
"Select AI environments for global skill installation (use space to select, enter to confirm):",
106+
"No environments support global skill installation."
107+
);
138108
}
139109
}

0 commit comments

Comments
 (0)