11import assert from 'node:assert/strict'
2+ import { createHash } from 'node:crypto'
3+ import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from 'node:fs'
4+ import { tmpdir } from 'node:os'
5+ import { join } from 'node:path'
26import { test } from 'bun:test'
37
48import type { Command } from '../../types/command.js'
9+ import { skillsInstallHandler } from './skillsInstall.ts'
510import {
611 formatSkillsListForDisplay ,
712 formatSkillsListJson ,
813} from './skillsListFormat.ts'
914
1015type SkillCommand = Command & { type : 'prompt' }
1116
17+ const VALID_SKILL = `---
18+ name: sample-skill
19+ title: Sample Skill
20+ description: Sample skill used by install tests.
21+ version: 0.1.0
22+ category: test
23+ author: OpenClaude Tests
24+ license: MIT
25+ trust: local
26+ ---
27+
28+ # Sample Skill
29+
30+ Use this skill for install tests.
31+ Document token scopes without storing secret values.
32+ `
33+
1234function skill (
1335 name : string ,
1436 description : string | undefined ,
@@ -30,6 +52,29 @@ function skill(
3052 }
3153}
3254
55+ function writeSkillDir ( root : string ) : string {
56+ const skillDir = join ( root , 'sample-skill' )
57+ mkdirSync ( skillDir , { recursive : true } )
58+ writeFileSync ( join ( skillDir , 'SKILL.md' ) , VALID_SKILL , 'utf8' )
59+ return skillDir
60+ }
61+
62+ function sha256OfSkillSource ( text : string ) : string {
63+ return createHash ( 'sha256' )
64+ . update ( text . replace ( / \r \n / g, '\n' ) , 'utf8' )
65+ . digest ( 'hex' )
66+ }
67+
68+ async function withTempDir < T > ( fn : ( tempDir : string ) => Promise < T > ) : Promise < T > {
69+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'openclaude-skill-install-test-' ) )
70+ try {
71+ return await fn ( tempDir )
72+ } finally {
73+ process . exitCode = 0
74+ rmSync ( tempDir , { recursive : true , force : true } )
75+ }
76+ }
77+
3378test ( 'formats skills list as an aligned human table' , ( ) => {
3479 const output = formatSkillsListForDisplay (
3580 [
@@ -103,3 +148,86 @@ test('formats skills list json as machine-readable metadata', () => {
103148 assert . equal ( parsed . skills [ 0 ] ?. source , 'project' )
104149 assert . equal ( parsed . skills [ 0 ] ?. description , description )
105150} )
151+
152+ test . serial ( 'installs a local skill directory into project skills by default' , async ( ) => {
153+ await withTempDir ( async tempDir => {
154+ const cwd = join ( tempDir , 'project' )
155+ const source = writeSkillDir ( join ( tempDir , 'source' ) )
156+ mkdirSync ( cwd , { recursive : true } )
157+
158+ await skillsInstallHandler ( source , { projectDir : cwd } )
159+
160+ const installed = readFileSync (
161+ join ( cwd , '.openclaude' , 'skills' , 'sample-skill' , 'SKILL.md' ) ,
162+ 'utf8' ,
163+ )
164+ assert . equal ( installed , VALID_SKILL )
165+ } )
166+ } )
167+
168+ test . serial ( 'refuses to overwrite installed skills without --force' , async ( ) => {
169+ await withTempDir ( async tempDir => {
170+ const cwd = join ( tempDir , 'project' )
171+ const source = writeSkillDir ( join ( tempDir , 'source' ) )
172+ mkdirSync ( join ( cwd , '.openclaude' , 'skills' , 'sample-skill' ) , {
173+ recursive : true ,
174+ } )
175+ writeFileSync (
176+ join ( cwd , '.openclaude' , 'skills' , 'sample-skill' , 'SKILL.md' ) ,
177+ 'existing skill content' ,
178+ 'utf8' ,
179+ )
180+
181+ await skillsInstallHandler ( source , { projectDir : cwd } )
182+
183+ const installed = readFileSync (
184+ join ( cwd , '.openclaude' , 'skills' , 'sample-skill' , 'SKILL.md' ) ,
185+ 'utf8' ,
186+ )
187+ assert . equal ( installed , 'existing skill content' )
188+ } )
189+ } )
190+
191+ test . serial ( 'installs a registry skill by id from a local registry file' , async ( ) => {
192+ await withTempDir ( async tempDir => {
193+ const cwd = join ( tempDir , 'project' )
194+ const sourceDir = writeSkillDir ( join ( tempDir , 'registry-source' ) )
195+ const registryPath = join ( tempDir , 'registry.json' )
196+ mkdirSync ( cwd , { recursive : true } )
197+ writeFileSync (
198+ registryPath ,
199+ JSON . stringify ( [
200+ {
201+ id : 'gitlawb/sample-skill' ,
202+ name : 'sample-skill' ,
203+ title : 'Sample Skill' ,
204+ description : 'Sample skill used by install tests.' ,
205+ trust : 'official' ,
206+ version : '0.1.0' ,
207+ license : 'MIT' ,
208+ author : 'OpenClaude Tests' ,
209+ source : join ( sourceDir , 'SKILL.md' ) ,
210+ repo : 'https://github.com/Gitlawb/openclaude-skills' ,
211+ path : 'skills/sample-skill/SKILL.md' ,
212+ homepage : 'https://github.com/Gitlawb/openclaude-skills/tree/main/skills/sample-skill' ,
213+ sha256 : sha256OfSkillSource ( VALID_SKILL ) ,
214+ } ,
215+ ] ) ,
216+ 'utf8' ,
217+ )
218+
219+ await skillsInstallHandler ( 'sample-skill' , {
220+ projectDir : cwd ,
221+ registry : registryPath ,
222+ } )
223+
224+ const installedMetadata = JSON . parse (
225+ readFileSync (
226+ join ( cwd , '.openclaude' , 'skills' , 'sample-skill' , 'skill.json' ) ,
227+ 'utf8' ,
228+ ) ,
229+ ) as { trust : string ; sha256 : string }
230+ assert . equal ( installedMetadata . trust , 'official' )
231+ assert . equal ( installedMetadata . sha256 , sha256OfSkillSource ( VALID_SKILL ) )
232+ } )
233+ } )
0 commit comments