Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ function logHelpMessage(
extraSkills?: ExtraSkill[],
) {
const toolsList = [...BUILTIN_TOOLS];
// Keep help output exhaustive for discoverability. `skill.when` only gates
// the interactive prompt, not the documented list of available skills.
const skillsList = (extraSkills ?? [])
.map((skill) => skill.value)
.filter(Boolean);
Expand Down Expand Up @@ -234,10 +236,13 @@ async function getTools(
function filterExtraSkills(
extraSkills: ExtraSkill[] | undefined,
templateName?: string,
tools: string[] = [],
) {
// `skill.when` only affects the interactive prompt. Explicit `--skill`
// values are handled separately in `getSkills`.
return extraSkills?.filter((extraSkill) => {
const when = extraSkill.when ?? (() => true);
return templateName ? when(templateName) : true;
return templateName ? when({ templateName, tools }) : true;
});
}

Expand All @@ -257,14 +262,17 @@ async function getSkills(
{ skill, dir, template }: Argv,
extraSkills?: ExtraSkill[],
templateName?: string,
tools: string[] = [],
promptMultiselect: typeof multiselect = multiselect,
) {
const parsedSkills = parseSkillsOption(skill);
const filteredExtraSkills = filterExtraSkills(extraSkills, templateName);
const filteredExtraSkills = filterExtraSkills(extraSkills, templateName, tools);

if (parsedSkills !== null) {
// Treat explicit `--skill` values as authoritative as long as they refer to
// a declared skill. `skill.when` only hides options from the prompt.
return parsedSkills.filter((value: string) =>
filteredExtraSkills?.some((extraSkill) => extraSkill.value === value),
extraSkills?.some((extraSkill) => extraSkill.value === value),
);
}

Expand Down Expand Up @@ -367,7 +375,12 @@ type ExtraSkill = {
label: string;
source: string;
skill?: string;
when?: (templateName: string) => boolean;
/**
* Controls whether the skill is shown in the interactive prompt for the
* selected template/tools. Explicit `--skill` values and `--help` remain
* unfiltered so CLI input stays authoritative and help stays discoverable.
*/
when?: (context: { templateName: string; tools: string[] }) => boolean;
order?: 'pre' | 'post';
};

Expand Down Expand Up @@ -566,13 +579,7 @@ export async function create({

const templateName = await getTemplateName(argv);
const tools = await getTools(argv, extraTools, templateName);
const filteredExtraSkills = filterExtraSkills(extraSkills, templateName);
const skills = await getSkills(
argv,
filteredExtraSkills,
templateName,
multiselect,
);
const skills = await getSkills(argv, extraSkills, templateName, tools, multiselect);

const srcFolder = path.join(root, `template-${templateName}`);
const commonFolder = path.join(root, 'template-common');
Expand All @@ -598,7 +605,7 @@ export async function create({
});

const skillsByValue = new Map(
(filteredExtraSkills ?? []).map((extraSkill) => [extraSkill.value, extraSkill]),
(extraSkills ?? []).map((extraSkill) => [extraSkill.value, extraSkill]),
);
let currentSkillBatch: ExtraSkill[] = [];

Expand Down
52 changes: 52 additions & 0 deletions test/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,55 @@ test('help message includes optional skills', async () => {
expect(logOutput).toContain('Optional skills:');
expect(logOutput).toContain('git-url');
});

test('help message lists all optional skills even when template and tools are provided', async () => {
const logs: string[] = [];
const originalLog = logger.log;

logger.override({
log: (message?: unknown) => {
logs.push(String(message ?? ''));
},
});

try {
await create({
name: 'test',
root: '.',
templates: ['vanilla', 'react'],
getTemplateName: async () => 'vanilla',
extraSkills: [
{
value: 'shared-docs',
label: 'Shared Docs',
source: 'acme/skills',
when: ({ templateName }) => templateName === 'vanilla',
},
{
value: 'react-docs',
label: 'React Docs',
source: 'acme/skills',
when: ({ templateName }) => templateName === 'react',
},
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
source: 'rstackjs/agent-skills',
when: ({ tools }) => tools.includes('rstest'),
},
],
argv: ['node', 'test', '--help', '--template', 'vanilla', '--tools', 'biome'],
});
} finally {
logger.override({
log: originalLog,
});
}

const logOutput = logs.join('\n');
expect(logOutput).toContain('--skill <skill>');
expect(logOutput).toContain('Optional skills:');
expect(logOutput).toContain('shared-docs');
expect(logOutput).toContain('react-docs');
expect(logOutput).toContain('rstest-best-practices');
});
140 changes: 137 additions & 3 deletions test/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ test('should prove --skill skips the skills prompt even without --dir and --temp
});
});

test('should filter extra skills by template and install using skill override', async () => {
test('should honor explicit --skill values even when they are hidden by template gating', async () => {
const projectDir = path.join(testDir, 'skills-template-filtering');
const calls = createExecCommand();

Expand All @@ -746,14 +746,14 @@ test('should filter extra skills by template and install using skill override',
label: 'React Docs',
source: 'acme/skills',
skill: 'docs/react',
when: (templateName) => templateName === 'react',
when: ({ templateName }) => templateName === 'react',
},
{
value: 'shared-docs',
label: 'Shared Docs',
source: 'acme/skills',
skill: 'docs/shared',
when: (templateName) => templateName === 'vanilla',
when: ({ templateName }) => templateName === 'vanilla',
},
],
argv: [
Expand All @@ -770,6 +770,140 @@ test('should filter extra skills by template and install using skill override',

expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
args: [
'-y',
'skills',
'add',
'acme/skills',
'--agent',
'universal',
'--yes',
'--copy',
'--skill',
'docs/react',
'--skill',
'docs/shared',
],
command: 'npx',
options: expect.objectContaining({
nodeOptions: expect.objectContaining({
cwd: projectDir,
stdio: 'pipe',
}),
}),
});
});

test('should show tool-gated skills in the prompt when the required tool is selected', async () => {
const projectDir = path.join(testDir, 'skills-tools-filtering-prompt');

await create({
name: 'test',
root: fixturesDir,
templates: ['vanilla'],
getTemplateName: async () => 'vanilla',
extraTools: [
{
value: 'rstest',
label: 'Rstest',
},
],
extraSkills: [
{
value: 'shared-docs',
label: 'Shared Docs',
source: 'acme/skills',
},
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
source: 'rstackjs/agent-skills',
when: ({ tools }) => tools.includes('rstest'),
},
],
argv: ['node', 'test', projectDir, '--tools', 'rstest'],
});

expect(mocks.state.promptOptions).toEqual([
{
value: 'shared-docs',
label: 'Shared Docs',
hint: 'acme/skills',
},
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
hint: 'rstackjs/agent-skills',
},
]);
});

test('should honor explicit --skill values even when the required tool is not selected', async () => {
const projectDir = path.join(testDir, 'skills-tools-filtering-cli');
const calls = createExecCommand();

await create({
name: 'test',
root: fixturesDir,
templates: ['vanilla'],
getTemplateName: async () => 'vanilla',
extraTools: [
{
value: 'rstest',
label: 'Rstest',
},
],
extraSkills: [
{
value: 'shared-docs',
label: 'Shared Docs',
source: 'acme/skills',
skill: 'docs/shared',
},
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
source: 'rstackjs/agent-skills',
when: ({ tools }) => tools.includes('rstest'),
},
],
argv: [
'node',
'test',
'--dir',
projectDir,
'--template',
'vanilla',
'--tools',
'biome',
'--skill',
'rstest-best-practices,shared-docs',
],
});

expect(calls).toHaveLength(2);
expect(calls[0]).toEqual({
args: [
'-y',
'skills',
'add',
'rstackjs/agent-skills',
'--agent',
'universal',
'--yes',
'--copy',
'--skill',
'rstest-best-practices',
],
command: 'npx',
options: expect.objectContaining({
nodeOptions: expect.objectContaining({
cwd: projectDir,
stdio: 'pipe',
}),
}),
});
expect(calls[1]).toEqual({
args: [
'-y',
'skills',
Expand Down
Loading