Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
45 changes: 31 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,25 +410,32 @@ async function runCommand(command: string, cwd: string, packageManager: string)
}

async function runSkillCommand(
skill: ExtraSkill,
skills: ExtraSkill[],
cwd: string,
) {
const [firstSkill] = skills;
// `skills add` accepts repeated `--skill` flags for a single source.
const installArgs = skills.flatMap((skill) => [
'--skill',
skill.skill ?? skill.value,
]);
const args = [
'-y',
'skills',
'add',
skill.source,
firstSkill.source,
'--agent',
'universal',
'--yes',
'--copy',
'--skill',
skill.skill ?? skill.value,
...installArgs,
];
const command = `npx ${args.join(' ')}`;
log.info(`Running skill install command: ${color.dim(command)}`);
const skillLabel = skills.map((skill) => skill.value).join(', ');
const skillNoun = skills.length === 1 ? 'skill' : 'skills';
const installationTaskLog = taskLog({
title: `Installing skill ${skill.value}`,
title: `Installing ${skillNoun} ${skillLabel}`,
});

const proc = x('npx', args, {
Expand All @@ -445,12 +452,13 @@ async function runSkillCommand(
const result = await proc;

if (result.exitCode !== 0) {
const message = `Failed to install skill "${skill.value}" from "${skill.source}" using command: ${command}`;
const quotedSkillLabel = skills.map((skill) => `"${skill.value}"`).join(', ');
const message = `Failed to install ${skillNoun} ${quotedSkillLabel} from "${firstSkill.source}" using command: ${command}`;
installationTaskLog.error(message);
throw new Error(message);
}

installationTaskLog.success(`Installed skill ${skill.value}`);
installationTaskLog.success(`Installed ${skillNoun} ${skillLabel}`);
}

export async function create({
Expand Down Expand Up @@ -589,13 +597,22 @@ export async function create({
skipFiles,
});

for (const skillValue of skills) {
const matchedSkill = filteredExtraSkills?.find(
(extraSkill) => extraSkill.value === skillValue,
);
if (matchedSkill) {
await runSkillCommand(matchedSkill, distFolder);
}
const selectedExtraSkills = skills
.map((skillValue) =>
filteredExtraSkills?.find((extraSkill) => extraSkill.value === skillValue),
)
.filter((skill): skill is ExtraSkill => Boolean(skill));

// Group selected skills by source so each repository is installed once.
const skillGroups = new Map<string, ExtraSkill[]>();
for (const skill of selectedExtraSkills) {
const currentSkills = skillGroups.get(skill.source) ?? [];
currentSkills.push(skill);
skillGroups.set(skill.source, currentSkills);
Comment thread
chenjiahan marked this conversation as resolved.
Outdated
}

for (const groupedSkills of skillGroups.values()) {
await runSkillCommand(groupedSkills, distFolder);
}

const packageRoot = path.resolve(__dirname, '..');
Expand Down
115 changes: 115 additions & 0 deletions test/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,74 @@ async function getCreateError(action: Promise<unknown>) {
}

test('should install selected extra skills from comma separated --skill option', async () => {
Comment thread
chenjiahan marked this conversation as resolved.
Outdated
const projectDir = path.join(testDir, 'skills-comma-separated-same-source');
const calls = createExecCommand();
const taskLogEvents = mocks.state.taskLogEvents;
const commandLogs = mocks.state.commandLogs;

await create({
name: 'test',
root: fixturesDir,
templates: ['vanilla'],
getTemplateName: async () => 'vanilla',
extraSkills: [
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
source: 'rstackjs/agent-skills',
},
{
value: 'rsbuild-best-practices',
label: 'Rsbuild Best Practices',
source: 'rstackjs/agent-skills',
},
],
argv: [
'node',
'test',
'--dir',
projectDir,
'--template',
'vanilla',
'--skill',
'rstest-best-practices,rsbuild-best-practices',
],
});

expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
args: [
'-y',
'skills',
'add',
'rstackjs/agent-skills',
'--agent',
'universal',
'--yes',
'--copy',
'--skill',
'rstest-best-practices',
'--skill',
'rsbuild-best-practices',
],
command: 'npx',
options: expect.objectContaining({
nodeOptions: expect.objectContaining({
cwd: projectDir,
stdio: 'pipe',
}),
}),
});
expect(taskLogEvents).toEqual([
'create:Installing skills rstest-best-practices, rsbuild-best-practices',
'success:Installing skills rstest-best-practices, rsbuild-best-practices:Installed skills rstest-best-practices, rsbuild-best-practices',
]);
expect(commandLogs).toContain(
`Running skill install command: ${color.dim('npx -y skills add rstackjs/agent-skills --agent universal --yes --copy --skill rstest-best-practices --skill rsbuild-best-practices')}`,
);
});

test('should install selected extra skills from comma separated --skill option across different sources', async () => {
const projectDir = path.join(testDir, 'skills-comma-separated');
const calls = createExecCommand();
const taskLogEvents = mocks.state.taskLogEvents;
Expand Down Expand Up @@ -663,6 +731,53 @@ test('should throw the install command context when installation fails', async (
);
});

test('should throw grouped install command context when batched installation fails', async () => {
const projectDir = path.join(testDir, 'skills-batched-install-failure');
createExecCommand(() => {
return {
stdout: '',
stderr: 'install failed',
exitCode: 1,
};
});

const error = await getCreateError(
create({
name: 'test',
root: fixturesDir,
templates: ['vanilla'],
getTemplateName: async () => 'vanilla',
extraSkills: [
{
value: 'rstest-best-practices',
label: 'Rstest Best Practices',
source: 'rstackjs/agent-skills',
},
{
value: 'rsbuild-best-practices',
label: 'Rsbuild Best Practices',
source: 'rstackjs/agent-skills',
},
],
argv: [
'node',
'test',
'--dir',
projectDir,
'--template',
'vanilla',
'--skill',
'rstest-best-practices,rsbuild-best-practices',
],
}),
);

expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(
'Failed to install skills "rstest-best-practices", "rsbuild-best-practices" from "rstackjs/agent-skills" using command: npx -y skills add rstackjs/agent-skills --agent universal --yes --copy --skill rstest-best-practices --skill rsbuild-best-practices',
);
});

test('should omit noisy skills cli output from install errors', async () => {
const projectDir = path.join(testDir, 'skills-install-noisy-error');
const rawStdout = `███████╗██╗ ██╗██╗██╗ ██╗ ███████╗
Expand Down
Loading