Skip to content
Open
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
43 changes: 31 additions & 12 deletions src/utils/create-app-task-install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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` })
}

Expand All @@ -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<string | undefined> {
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) {
Expand Down
29 changes: 23 additions & 6 deletions test/create-app-task-install-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../src/utils/vendor/child-process-utils')>()),
execAndWait: vi.fn(),
}))
vi.mock('@clack/prompts', () => ({
Expand Down Expand Up @@ -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)',
)
})

Expand Down
Loading