diff --git a/package.json b/package.json index 95694cb..f3e4f98 100644 --- a/package.json +++ b/package.json @@ -44,12 +44,10 @@ "@rslib/core": "0.20.1", "@rslint/core": "^0.3.3", "@rstest/core": "0.9.5", - "@types/cross-spawn": "^6.0.6", "@types/fs-extra": "^11.0.4", "@types/minimist": "^1.2.5", "@types/node": "24.12.0", "@vercel/detect-agent": "^1.2.1", - "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "fs-extra": "^11.3.4", "minimist": "^1.2.8", @@ -57,6 +55,7 @@ "rimraf": "^6.1.3", "rslog": "^2.1.1", "simple-git-hooks": "^2.13.1", + "tinyexec": "^1.0.4", "typescript": "^6.0.2" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef47ee..cc208cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@rstest/core': specifier: 0.9.5 version: 0.9.5(core-js@3.47.0) - '@types/cross-spawn': - specifier: ^6.0.6 - version: 6.0.6 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -38,9 +35,6 @@ importers: '@vercel/detect-agent': specifier: ^1.2.1 version: 1.2.1 - cross-spawn: - specifier: ^7.0.6 - version: 7.0.6 deepmerge: specifier: ^4.3.1 version: 4.3.1 @@ -62,6 +56,9 @@ importers: simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 + tinyexec: + specifier: ^1.0.4 + version: 1.0.4 typescript: specifier: ^6.0.2 version: 6.0.2 @@ -346,9 +343,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/cross-spawn@6.0.6': - resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -405,10 +399,6 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -453,9 +443,6 @@ packages: resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} @@ -494,10 +481,6 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -548,14 +531,6 @@ packages: engines: {node: '>=10'} hasBin: true - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} hasBin: true @@ -586,6 +561,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -610,11 +589,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -890,10 +864,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/cross-spawn@6.0.6': - dependencies: - '@types/node': 24.12.0 - '@types/deep-eql@4.0.2': {} '@types/fs-extra@11.0.4': @@ -943,12 +913,6 @@ snapshots: core-js@3.47.0: optional: true - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - deepmerge@4.3.1: {} diff@8.0.2: {} @@ -985,8 +949,6 @@ snapshots: dependencies: hasown: 2.0.2 - isexe@2.0.0: {} - jju@1.4.0: {} json-schema-traverse@1.0.0: {} @@ -1019,8 +981,6 @@ snapshots: package-json-from-dist@1.0.1: {} - path-key@3.1.1: {} - path-parse@1.0.7: {} path-scurry@2.0.2: @@ -1057,12 +1017,6 @@ snapshots: dependencies: lru-cache: 6.0.0 - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - simple-git-hooks@2.13.1: {} sisteransi@1.0.5: {} @@ -1081,6 +1035,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tinyexec@1.0.4: {} + tinypool@2.1.0: {} tslib@2.8.1: {} @@ -1093,8 +1049,4 @@ snapshots: universalify@2.0.1: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - yallist@4.0.0: {} diff --git a/src/index.ts b/src/index.ts index 9f9c168..b2594e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,17 +4,19 @@ import { fileURLToPath } from 'node:url'; import { cancel, isCancel, + log, multiselect, note, outro, select, + spinner, text, } from '@clack/prompts'; import { determineAgent } from '@vercel/detect-agent'; -import spawn from 'cross-spawn'; import deepmerge from 'deepmerge'; import minimist from 'minimist'; import { color, logger } from 'rslog'; +import { x } from 'tinyexec'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -87,12 +89,26 @@ function parseToolsOption(tools: Argv['tools']) { .filter(Boolean); } +function parseSkillsOption(skills: Argv['skill']) { + if (typeof skills === 'undefined') { + return null; + } + + const skillsArr = Array.isArray(skills) ? skills : [skills]; + + return skillsArr + .flatMap((skill) => skill.split(',')) + .map((skill) => skill.trim()) + .filter(Boolean); +} + export type Argv = { help?: boolean; dir?: string; template?: string; override?: boolean; tools?: string | string[]; + skill?: string | string[]; packageName?: string; 'package-name'?: string; }; @@ -103,8 +119,13 @@ function logHelpMessage( name: string, templates: string[], extraTools?: ExtraTool[], + extraSkills?: ExtraSkill[], ) { const toolsList = [...BUILTIN_TOOLS]; + const skillsList = (extraSkills ?? []) + .map((skill) => skill.value) + .filter(Boolean); + const hasSkills = skillsList.length > 0; if (extraTools) { for (const tool of extraTools) { if (!tool.value) { @@ -118,23 +139,32 @@ function logHelpMessage( } } + const skillsOptionLine = hasSkills + ? ' --skill add optional skills, comma separated\n' + : ''; + const optionalSkillsSection = hasSkills + ? ` + Optional skills: + ${skillsList.join(', ')}` + : ''; + logger.log(` Usage: create-${name} [dir] [options] Options: -h, --help display help for command - -d, --dir create project in specified directory - -t, --template specify the template to use - --tools add additional tools, comma separated - --override override files in target directory - --packageName specify the package name - - Available templates: - ${templates.join(', ')} - - Optional tools: - ${toolsList.join(', ')} + -d, --dir create project in specified directory + -t, --template specify the template to use + --tools add additional tools, comma separated +${skillsOptionLine} --override override files in target directory + --packageName specify the package name + + Available templates: + ${templates.join(', ')} + + Optional tools: + ${toolsList.join(', ')}${optionalSkillsSection} `); } @@ -199,6 +229,66 @@ async function getTools( ); } +function filterExtraSkills( + extraSkills: ExtraSkill[] | undefined, + templateName?: string, +) { + return extraSkills?.filter((extraSkill) => { + const when = extraSkill.when ?? (() => true); + return templateName ? when(templateName) : true; + }); +} + +function orderExtraSkills(extraSkills: ExtraSkill[] | undefined) { + if (!extraSkills) { + return []; + } + + return [ + ...extraSkills.filter((extraSkill) => extraSkill.order === 'pre'), + ...extraSkills.filter((extraSkill) => typeof extraSkill.order === 'undefined'), + ...extraSkills.filter((extraSkill) => extraSkill.order === 'post'), + ]; +} + +async function getSkills( + { skill, dir, template }: Argv, + extraSkills?: ExtraSkill[], + templateName?: string, + promptMultiselect: typeof multiselect = multiselect, +) { + const parsedSkills = parseSkillsOption(skill); + const filteredExtraSkills = filterExtraSkills(extraSkills, templateName); + + if (parsedSkills !== null) { + return parsedSkills.filter((value: string) => + filteredExtraSkills?.some((extraSkill) => extraSkill.value === value), + ); + } + + if (dir && template) { + return []; + } + + if (!filteredExtraSkills?.length) { + return []; + } + + const orderedExtraSkills = orderExtraSkills(filteredExtraSkills); + + return checkCancel( + await promptMultiselect({ + message: 'Select optional skills (Use to select, to continue)', + options: orderedExtraSkills.map((extraSkill) => ({ + value: extraSkill.value, + label: extraSkill.label, + hint: extraSkill.source, + })), + required: false, + }), + ); +} + function upperFirst(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } @@ -270,7 +360,16 @@ type ExtraTool = { when?: (templateName: string) => boolean; }; -function runCommand(command: string, cwd: string, packageManager: string) { +type ExtraSkill = { + value: string; + label: string; + source: string; + skill?: string; + when?: (templateName: string) => boolean; + order?: 'pre' | 'post'; +}; + +async function runCommand(command: string, cwd: string, packageManager: string) { // Replace `npm create` with the equivalent command for the detected package manager if (command.startsWith('npm create ')) { const createReplacements: Record = { @@ -292,11 +391,59 @@ function runCommand(command: string, cwd: string, packageManager: string) { } } - const [bin, ...args] = command.split(' '); - spawn.sync(bin, args, { - stdio: 'inherit', - cwd, + const result = await x(command, [], { + nodeOptions: { + shell: true, + stdio: 'inherit', + cwd, + }, }); + + if (result.exitCode !== 0) { + const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); + throw new Error( + `Failed to run command: ${command}${details ? `\n${details}` : ''}`, + ); + } +} + +async function runSkillCommand( + skill: ExtraSkill, + cwd: string, +) { + const args = [ + '-y', + 'skills', + 'add', + skill.source, + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + skill.skill ?? skill.value, + ]; + const command = `npx ${args.join(' ')}`; + log.info(`Running skill install command: ${color.dim(command)}`); + const installationSpinner = spinner(); + installationSpinner.start(`Installing skill ${skill.value}`); + + const result = await x('npx', args, { + nodeOptions: { + cwd, + stdio: 'pipe', + }, + }); + + if (result.exitCode !== 0) { + installationSpinner.error(`Failed to install skill ${skill.value}`); + const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); + throw new Error( + `Failed to install skill "${skill.value}" from "${skill.source}" using command: ${command}${details ? `\n${details}` : ''}`, + ); + } + + installationSpinner.stop(`Installed skill ${skill.value}`); } export async function create({ @@ -309,6 +456,7 @@ export async function create({ version, noteInformation, extraTools, + extraSkills, argv: processArgv = process.argv, }: { name: string; @@ -330,6 +478,7 @@ export async function create({ * Specify additional tools. */ extraTools?: ExtraTool[]; + extraSkills?: ExtraSkill[]; /** * For test purpose, override the default argv (process.argv). */ @@ -348,7 +497,7 @@ export async function create({ const argv = parseArgv(processArgv); if (argv.help) { - logHelpMessage(name, templates, extraTools); + logHelpMessage(name, templates, extraTools, extraSkills); return; } @@ -402,6 +551,13 @@ 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 srcFolder = path.join(root, `template-${templateName}`); const commonFolder = path.join(root, 'template-common'); @@ -426,6 +582,15 @@ 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 packageRoot = path.resolve(__dirname, '..'); const agentsMdSearchDirs = [commonFolder, srcFolder]; @@ -445,7 +610,7 @@ export async function create({ }); } if (matchedTool.command) { - runCommand(matchedTool.command, distFolder, packageManager); + await runCommand(matchedTool.command, distFolder, packageManager); } continue; } diff --git a/test/custom-tools.test.ts b/test/custom-tools.test.ts index 40620fb..a564523 100644 --- a/test/custom-tools.test.ts +++ b/test/custom-tools.test.ts @@ -1,15 +1,55 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { beforeEach, expect, test } from '@rstest/core'; -import fse from 'fs-extra'; +import { beforeEach, expect, rs, test } from '@rstest/core'; import { create } from '../src'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, 'fixtures', 'basic'); const testDir = path.join(fixturesDir, 'test-temp-output'); +const mocks = rs.hoisted(() => { + const state = { + xCalls: [] as Array<{ + command: string; + args: string[]; + options: unknown; + }>, + }; + + return { + state, + x: rs.fn(async (command: string, args: string[], options: unknown) => { + state.xCalls.push({ command, args, options }); + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }) as any, + xSync: rs.fn(() => ({ stdout: '', stderr: '', exitCode: 0 })) as any, + }; +}); + +rs.mock('tinyexec', () => ({ + x: mocks.x, + xSync: mocks.xSync, +})); + beforeEach(() => { + mocks.state.xCalls.length = 0; + rs.mocked(mocks.x).mockReset(); + rs.mocked(mocks.x).mockImplementation( + async (command: string, args: string[], options: unknown) => { + mocks.state.xCalls.push({ command, args, options }); + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }, + ); + if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -61,9 +101,6 @@ test('should run extra tool command', async () => { const projectDir = path.join(testDir, 'extra-tool-command'); const testFile = path.join(__dirname, 'node_modules', 'test.txt'); - await fse.outputFile(testFile, ''); - expect(fs.existsSync(testFile)).toBe(true); - await create({ name: 'test', root: fixturesDir, @@ -88,7 +125,100 @@ test('should run extra tool command', async () => { ], }); - expect(fs.existsSync(testFile)).toBe(false); + expect(mocks.state.xCalls).toEqual([ + { + command: `npx rimraf ${testFile}`, + args: [], + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + shell: true, + stdio: 'inherit', + }), + }), + }, + ]); +}); + +test('should preserve quoted extra tool command arguments', async () => { + const projectDir = path.join(testDir, 'extra-tool-quoted-command'); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraTools: [ + { + value: 'quoted-command', + label: 'Quoted Command', + command: 'npx some-tool --name "my app" --flag', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--tools', + 'quoted-command', + ], + }); + + expect(mocks.state.xCalls).toContainEqual({ + command: 'npx some-tool --name "my app" --flag', + args: [], + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + shell: true, + stdio: 'inherit', + }), + }), + }); +}); + +test('should fail when extra tool command exits non-zero', async () => { + const projectDir = path.join(testDir, 'extra-tool-command-failure'); + + rs.mocked(mocks.x).mockImplementation( + async (command: string, args: string[], options: unknown) => { + mocks.state.xCalls.push({ command, args, options }); + return { + stdout: '', + stderr: 'tool failed', + exitCode: 1, + }; + }, + ); + + await expect( + create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraTools: [ + { + value: 'failing-command', + label: 'Failing Command', + command: 'npx broken-tool', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--tools', + 'failing-command', + ], + }), + ).rejects.toThrow('Failed to run command: npx broken-tool'); }); test('should filter extra tools based on template name', async () => { diff --git a/test/help.test.ts b/test/help.test.ts index 96a417f..bd6455a 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -30,3 +30,69 @@ test('help message includes extra tools', async () => { const logOutput = logs.join('\n'); expect(logOutput).toContain('biome, eslint, prettier, custom-tool'); }); + +test('help message hides skill help when no optional skills are configured', 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'], + getTemplateName: async () => 'vanilla', + argv: ['node', 'test', '--help'], + }); + } finally { + logger.override({ + log: originalLog, + }); + } + + const logOutput = logs.join('\n'); + expect(logOutput).not.toContain('--skill '); + expect(logOutput).not.toContain('Optional skills:'); +}); + +test('help message includes optional skills', 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'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + ], + argv: ['node', 'test', '--help'], + }); + } finally { + logger.override({ + log: originalLog, + }); + } + + const logOutput = logs.join('\n'); + expect(logOutput).toContain('--skill '); + expect(logOutput).toContain('Optional skills:'); + expect(logOutput).toContain('git-url'); +}); diff --git a/test/skills.test.ts b/test/skills.test.ts new file mode 100644 index 0000000..be2b9da --- /dev/null +++ b/test/skills.test.ts @@ -0,0 +1,843 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as promptsActual from '@clack/prompts' with { rstest: 'importActual' }; +import { beforeEach, expect, rs, test } from '@rstest/core'; +import { color } from 'rslog'; +import { create } from '../src'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, 'fixtures', 'basic'); +const testDir = path.join(fixturesDir, 'test-temp-output-skills'); + +const mocks = rs.hoisted(() => { + type ExecCall = { + command: string; + args: string[]; + options: unknown; + }; + + const state = { + xCalls: [] as ExecCall[], + spinnerEvents: [] as string[], + commandLogs: [] as string[], + promptOptions: [] as Array<{ value: string; label?: string; hint?: string }>, + }; + + const x = rs.fn(async (command: string, args: string[], options: unknown) => { + state.xCalls.push({ command, args, options }); + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }) as any; + + const xSync = rs.fn((command: string, args: string[], options: unknown) => { + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }) as any; + + const spinner = (() => ({ + start: (message?: string) => { + state.spinnerEvents.push(`start:${message ?? ''}`); + }, + stop: (message?: string) => { + state.spinnerEvents.push(`stop:${message ?? ''}`); + }, + cancel: (message?: string) => { + state.spinnerEvents.push(`cancel:${message ?? ''}`); + }, + error: (message?: string) => { + state.spinnerEvents.push(`error:${message ?? ''}`); + }, + message: () => {}, + clear: () => {}, + isCancelled: false, + })) as typeof promptsActual.spinner; + + const multiselect = rs.fn(async (options: { + message?: string; + options?: Array<{ value: unknown; label?: string; hint?: string }>; + }) => { + if (options.message?.includes('Select optional skills')) { + state.promptOptions = (options.options ?? []) as Array<{ + value: string; + label?: string; + hint?: string; + }>; + } + return []; + }) as typeof promptsActual.multiselect; + + return { + state, + x, + xSync, + spinner, + multiselect, + }; +}); + +rs.mock('tinyexec', () => ({ + x: mocks.x, + xSync: mocks.xSync, +})); + +rs.mock('@clack/prompts', () => ({ + ...promptsActual, + multiselect: mocks.multiselect, + spinner: mocks.spinner, + log: { + ...promptsActual.log, + info: (message: string) => { + mocks.state.commandLogs.push(message); + }, + }, +})); + +beforeEach(() => { + mocks.state.xCalls.length = 0; + mocks.state.spinnerEvents.length = 0; + mocks.state.commandLogs.length = 0; + mocks.state.promptOptions.length = 0; + rs.mocked(mocks.x).mockReset(); + rs.mocked(mocks.x).mockImplementation( + async (command: string, args: string[], options: unknown) => { + mocks.state.xCalls.push({ command, args, options }); + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }, + ); + rs.mocked(mocks.multiselect).mockReset(); + rs.mocked(mocks.multiselect).mockImplementation(async (options) => { + if (options.message?.includes('Select optional skills')) { + mocks.state.promptOptions = (options.options ?? []) as Array<{ + value: string; + label?: string; + hint?: string; + }>; + } + return []; + }); + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + return () => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }; +}); + +function createExecCommand( + handler?: (context: { + command: string; + args: string[]; + options: unknown; + }) => Promise<{ stdout: string; stderr: string; exitCode: number }> | { stdout: string; stderr: string; exitCode: number }, +) { + rs.mocked(mocks.x).mockImplementation( + async (command: string, args: string[], options: unknown) => { + mocks.state.xCalls.push({ command, args, options }); + if (handler) { + return await handler({ command, args, options }); + } + return { + stdout: '', + stderr: '', + exitCode: 0, + }; + }, + ); + return mocks.state.xCalls; +} + +async function getCreateError(action: Promise) { + try { + await action; + } catch (error) { + return error; + } + + throw new Error('Expected create() to throw'); +} + +test('should install selected extra skills from comma separated --skill option', async () => { + const projectDir = path.join(testDir, 'skills-comma-separated'); + const calls = createExecCommand(); + const spinnerEvents = mocks.state.spinnerEvents; + const commandLogs = mocks.state.commandLogs; + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + { + value: 'docs-writer', + label: 'Docs Writer', + source: 'acme/skills', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'git-url,docs-writer', + ], + }); + + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'vercel-labs/agent-skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'git-url', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); + expect(calls[1]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'acme/skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'docs-writer', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); + expect(spinnerEvents).toEqual([ + 'start:Installing skill git-url', + 'stop:Installed skill git-url', + 'start:Installing skill docs-writer', + 'stop:Installed skill docs-writer', + ]); + expect(commandLogs).toContain( + `Running skill install command: ${color.dim('npx -y skills add vercel-labs/agent-skills --agent universal --yes --copy --skill git-url')}`, + ); + expect(commandLogs).toContain( + `Running skill install command: ${color.dim('npx -y skills add acme/skills --agent universal --yes --copy --skill docs-writer')}`, + ); +}); + +test('should install selected extra skills from repeated --skill flags', async () => { + const projectDir = path.join(testDir, 'skills-repeated-flags'); + const calls = createExecCommand(); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + { + value: 'docs-writer', + label: 'Docs Writer', + source: 'acme/skills', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'git-url', + '--skill', + 'docs-writer', + ], + }); + + expect(calls).toHaveLength(2); + expect(calls[0]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'vercel-labs/agent-skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'git-url', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); + expect(calls[1]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'acme/skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'docs-writer', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); +}); + +test('should skip the skills prompt when --skill is provided', async () => { + const projectDir = path.join(testDir, 'skills-skip-prompt-with-cli-option'); + const calls = createExecCommand(); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'git-url', + ], + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'vercel-labs/agent-skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'git-url', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); +}); + +test('should skip skill installation when --dir and --template are used without --skill', async () => { + const projectDir = path.join(testDir, 'skills-non-interactive-no-skill'); + const calls = createExecCommand(); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + ], + }); + + expect(calls).toHaveLength(0); +}); + +test('should prove --skill skips the skills prompt even without --dir and --template', async () => { + const calls = createExecCommand(); + let skillsPromptReached = false; + const projectDir = path.join(testDir, 'skills-skip-proof'); + const guardedSkillPrompt = async ({ message }: { message?: string }) => { + if (message?.includes('Select optional skills')) { + skillsPromptReached = true; + throw new Error('skills prompt should not be reached'); + } + return []; + }; + rs.mocked(mocks.multiselect).mockImplementation( + guardedSkillPrompt as typeof mocks.multiselect, + ); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'git-url', + label: 'Git URL', + source: 'vercel-labs/agent-skills', + }, + ], + argv: [ + 'node', + 'test', + projectDir, + '--tools', + '', + '--skill', + 'git-url', + ], + }); + + expect(skillsPromptReached).toBe(false); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'vercel-labs/agent-skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'git-url', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); +}); + +test('should filter extra skills by template and install using skill override', async () => { + const projectDir = path.join(testDir, 'skills-template-filtering'); + const calls = createExecCommand(); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla', 'react'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'react-docs', + label: 'React Docs', + source: 'acme/skills', + skill: 'docs/react', + when: (templateName) => templateName === 'react', + }, + { + value: 'shared-docs', + label: 'Shared Docs', + source: 'acme/skills', + skill: 'docs/shared', + when: (templateName) => templateName === 'vanilla', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'react-docs,shared-docs', + ], + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + '-y', + 'skills', + 'add', + 'acme/skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'docs/shared', + ], + command: 'npx', + options: expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + }); +}); + +test('should throw with skill context when installation fails', async () => { + const projectDir = path.join(testDir, 'skills-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: 'shared-docs', + label: 'Shared Docs', + source: 'acme/skills', + skill: 'docs/shared', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'shared-docs', + ], + }), + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + 'Failed to install skill "shared-docs" from "acme/skills" using command: npx -y skills add acme/skills --agent universal --yes --copy --skill docs/shared\ninstall failed', + ); +}); + +test('should trim noisy skills cli output in install errors', async () => { + const projectDir = path.join(testDir, 'skills-install-noisy-error'); + const rawStdout = `███████╗██╗ ██╗██╗██╗ ██╗ ███████╗ +┌ skills +│ +│ Tip: use the --yes (-y) and --global (-g) flags to install without prompts. +│ +◇ Source: https://github.com/vercel-labs/agent-skills.git +│ +◇ Repository cloned +│ +◇ Found 6 skills +│ +■ No matching skills found for: non-existent-skill`; + createExecCommand(() => ({ + stdout: rawStdout, + stderr: '', + exitCode: 1, + })); + + const error = await getCreateError( + create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'missing-skill', + label: 'Missing Skill', + source: 'vercel-labs/agent-skills', + skill: 'non-existent-skill', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'missing-skill', + ], + }), + ); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect( + message.match(/Failed to install skill "missing-skill"/g)?.length ?? 0, + ).toBe(1); + expect(message).toContain( + 'Failed to install skill "missing-skill" from "vercel-labs/agent-skills" using command: npx -y skills add vercel-labs/agent-skills --agent universal --yes --copy --skill non-existent-skill', + ); + expect(message).toContain(rawStdout); +}); + +test('should include spawn errors when skill installation cannot start', async () => { + const projectDir = path.join(testDir, 'skills-install-spawn-error'); + createExecCommand(({ command, args, options }) => { + expect(command).toBe('npx'); + expect(args).toEqual([ + '-y', + 'skills', + 'add', + 'acme/skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'docs/shared', + ]); + expect(options).toEqual( + expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + ); + throw new Error('spawn npx ENOENT'); + }); + + const error = await getCreateError( + create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'shared-docs', + label: 'Shared Docs', + source: 'acme/skills', + skill: 'docs/shared', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'shared-docs', + ], + }), + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('spawn npx ENOENT'); +}); + +test('should install skills with async spawn so spinner can render during installation', async () => { + const projectDir = path.join(testDir, 'skills-async-install'); + const execEvents: string[] = []; + const spinnerEvents = mocks.state.spinnerEvents; + createExecCommand(async ({ command, args, options }) => { + expect(command).toBe('npx'); + expect(args).toEqual([ + '-y', + 'skills', + 'add', + 'acme/skills', + '--agent', + 'universal', + '--yes', + '--copy', + '--skill', + 'docs/shared', + ]); + expect(options).toEqual( + expect.objectContaining({ + nodeOptions: expect.objectContaining({ + cwd: projectDir, + stdio: 'pipe', + }), + }), + ); + execEvents.push('started'); + await Promise.resolve(); + execEvents.push('resolved'); + return { + stdout: 'installing...', + stderr: '', + exitCode: 0, + }; + }); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'shared-docs', + label: 'Shared Docs', + source: 'acme/skills', + skill: 'docs/shared', + }, + ], + argv: [ + 'node', + 'test', + '--dir', + projectDir, + '--template', + 'vanilla', + '--skill', + 'shared-docs', + ], + }); + + expect(spinnerEvents).toEqual([ + 'start:Installing skill shared-docs', + 'stop:Installed skill shared-docs', + ]); + expect(execEvents).toEqual(['started', 'resolved']); +}); + +test('should order skill prompt options using pre, default, and post order', async () => { + const projectDir = path.join(testDir, 'skills-ordering-proof'); + rs.mocked(mocks.multiselect).mockImplementation(async ({ + message, + options, + }: { + message?: string; + options?: Array<{ value: Value; label?: string; hint?: string }>; + }) => { + if (message?.includes('Select optional skills')) { + mocks.state.promptOptions = (options ?? []) as Array<{ + value: string; + label?: string; + hint?: string; + }>; + return []; + } + return []; + }); + + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + extraSkills: [ + { + value: 'post-skill', + label: 'Post Skill', + source: 'acme/skills', + order: 'post', + }, + { + value: 'pre-skill', + label: 'Pre Skill', + source: 'acme/skills', + order: 'pre', + }, + { + value: 'default-skill', + label: 'Default Skill', + source: 'acme/skills', + }, + ], + argv: ['node', 'test', projectDir, '--tools', ''], + }); + + expect(mocks.state.promptOptions).toEqual([ + { + value: 'pre-skill', + label: 'Pre Skill', + hint: 'acme/skills', + }, + { + value: 'default-skill', + label: 'Default Skill', + hint: 'acme/skills', + }, + { + value: 'post-skill', + label: 'Post Skill', + hint: 'acme/skills', + }, + ]); +});