diff --git a/src/utils/create-app-task-install-skills.ts b/src/utils/create-app-task-install-skills.ts index 22285f87..49ba2aa4 100644 --- a/src/utils/create-app-task-install-skills.ts +++ b/src/utils/create-app-task-install-skills.ts @@ -2,7 +2,7 @@ import { log } from '@clack/prompts' import { GetArgsResult } from './get-args-result' import { getPackageJson } from './get-package-json' import { initScriptKey } from './init-script-schema' -import { execAndWait } from './vendor/child-process-utils' +import { CreateAppError, execAndWait } from './vendor/child-process-utils' import { Task } from './vendor/clack-tasks' const defaultSkills = ['https://github.com/solana-foundation/solana-dev-skill'] @@ -18,13 +18,18 @@ export function createAppTaskInstallSkills(args: GetArgsResult): Task { return result({ message: 'Skipped skill installation' }) } - const installed = await installSkills(args, skills) + const failures = await installSkills(args, skills) + const installed = skills.length - failures.length + + for (const { reason, skill } of failures) { + log.warn(`Failed to install skill ${skill}: ${reason}`) + } if (installed === 0) { return result({ message: 'Failed to install skills' }) } - if (installed < skills.length) { + if (failures.length > 0) { return result({ message: `Installed ${installed}/${skills.length} skills` }) } @@ -35,27 +40,41 @@ export function createAppTaskInstallSkills(args: GetArgsResult): Task { } async function installSkills(args: GetArgsResult, skills: string[]) { - const installs: boolean[] = [] + const failures: { reason: string; skill: string }[] = [] for (const skill of skills) { - installs.push(await installSkill(args, skill)) + const reason = await installSkill(args, skill) + if (reason !== undefined) { + failures.push({ reason, skill }) + } } - return installs.filter(Boolean).length + return failures } -async function installSkill(args: GetArgsResult, skill: string) { +// Resolves to undefined when the install succeeds, or to the failure reason +async function installSkill(args: GetArgsResult, skill: string): Promise { try { if (args.verbose) { log.warn(`Installing skill ${skill}`) } await execAndWait(`npx -y skills add ${shellQuote(skill)} --skill "*" -y`, args.targetDirectory) - return true + return undefined } catch (error) { - if (args.verbose) { - log.error(`Error installing skill ${skill}: ${error}`) - } - return false + return describeSkillInstallError(error) + } +} + +// The skills CLI explains a failed install on stderr (e.g. a SKILL.md it can't parse), so the +// first line of that output is the reason; the log file written by execAndWait has the rest. +function describeSkillInstallError(error: unknown): string { + if (error instanceof CreateAppError) { + const firstLine = error.logMessage + .split('\n') + .find((line) => line.trim().length > 0) + ?.trim() + return firstLine ? `${firstLine} (full log: ${error.logFile})` : `see ${error.logFile}` } + return `${error}` } function shellQuote(value: string) { diff --git a/test/create-app-task-install-skills.test.ts b/test/create-app-task-install-skills.test.ts index a48bf053..d65f8f6d 100644 --- a/test/create-app-task-install-skills.test.ts +++ b/test/create-app-task-install-skills.test.ts @@ -4,10 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createAppTaskInstallSkills } from '../src/utils/create-app-task-install-skills' import { GetArgsResult } from '../src/utils/get-args-result' import { initScriptKey } from '../src/utils/init-script-schema' -import { execAndWait } from '../src/utils/vendor/child-process-utils' +import { CreateAppError, execAndWait } from '../src/utils/vendor/child-process-utils' vi.mock('node:fs') -vi.mock('../src/utils/vendor/child-process-utils', () => ({ +vi.mock('../src/utils/vendor/child-process-utils', async (importOriginal) => ({ + ...(await importOriginal()), execAndWait: vi.fn(), })) vi.mock('@clack/prompts', () => ({ @@ -87,18 +88,34 @@ describe('createAppTaskInstallSkills', () => { expect(execAndWait).not.toHaveBeenCalled() }) - it('should continue after a skill install fails', async () => { + it('should continue after a skill install fails and report which skill failed', async () => { writePackageJson({ skills: ['https://github.com/example/alpha-skill', 'https://github.com/example/beta-skill'], }) vi.mocked(execAndWait).mockRejectedValueOnce(new Error('install failed')) - const result = await runTask({ ...baseArgs, verbose: true }) + const result = await runTask() expect(result).toEqual({ message: 'Installed 1/2 skills' }) expect(execAndWait).toHaveBeenCalledTimes(2) - expect(log.error).toHaveBeenCalledWith( - 'Error installing skill https://github.com/example/alpha-skill: Error: install failed', + expect(log.warn).toHaveBeenCalledWith( + 'Failed to install skill https://github.com/example/alpha-skill: Error: install failed', + ) + }) + + it('should report the reason and log file when the skills CLI fails', async () => { + writePackageJson({ + skills: ['https://github.com/example/alpha-skill'], + }) + vi.mocked(execAndWait).mockRejectedValueOnce( + new CreateAppError('Skipped SKILL.md — YAML parse error\nmore details', 1, '/template.error.log'), + ) + + const result = await runTask() + + expect(result).toEqual({ message: 'Failed to install skills' }) + expect(log.warn).toHaveBeenCalledWith( + 'Failed to install skill https://github.com/example/alpha-skill: Skipped SKILL.md — YAML parse error (full log: /template.error.log)', ) })