skills-package-manager 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +105 -0
  2. package/dist/113.js +355 -0
  3. package/dist/bin/skills-pm.js +6 -0
  4. package/dist/bin/skills.js +6 -0
  5. package/dist/index.js +1 -0
  6. package/dist/src/bin/skills-pm.d.ts +2 -0
  7. package/dist/src/bin/skills.d.ts +2 -0
  8. package/dist/src/cli/runCli.d.ts +16 -0
  9. package/dist/src/commands/add.d.ts +5 -0
  10. package/dist/src/commands/install.d.ts +15 -0
  11. package/dist/src/config/readSkillsLock.d.ts +2 -0
  12. package/dist/src/config/readSkillsManifest.d.ts +2 -0
  13. package/dist/src/config/syncSkillsLock.d.ts +2 -0
  14. package/dist/src/config/types.d.ts +43 -0
  15. package/dist/src/config/writeSkillsLock.d.ts +2 -0
  16. package/dist/src/config/writeSkillsManifest.d.ts +2 -0
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/install/installSkills.d.ts +13 -0
  19. package/dist/src/install/installState.d.ts +2 -0
  20. package/dist/src/install/links.d.ts +1 -0
  21. package/dist/src/install/materializeGitSkill.d.ts +1 -0
  22. package/dist/src/install/materializeLocalSkill.d.ts +1 -0
  23. package/dist/src/install/pruneManagedSkills.d.ts +1 -0
  24. package/dist/src/specifiers/normalizeSpecifier.d.ts +2 -0
  25. package/dist/src/specifiers/parseSpecifier.d.ts +5 -0
  26. package/dist/src/utils/fs.d.ts +4 -0
  27. package/dist/src/utils/hash.d.ts +1 -0
  28. package/dist/test/add.test.d.ts +1 -0
  29. package/dist/test/install.test.d.ts +1 -0
  30. package/dist/test/manifest.test.d.ts +1 -0
  31. package/dist/test/specifiers.test.d.ts +1 -0
  32. package/package.json +25 -0
  33. package/rslib.config.ts +21 -0
  34. package/src/bin/skills-pm.ts +7 -0
  35. package/src/bin/skills.ts +7 -0
  36. package/src/cli/prompt.ts +36 -0
  37. package/src/cli/runCli.ts +45 -0
  38. package/src/commands/add.ts +110 -0
  39. package/src/commands/install.ts +5 -0
  40. package/src/config/readSkillsLock.ts +18 -0
  41. package/src/config/readSkillsManifest.ts +22 -0
  42. package/src/config/syncSkillsLock.ts +75 -0
  43. package/src/config/types.ts +37 -0
  44. package/src/config/writeSkillsLock.ts +9 -0
  45. package/src/config/writeSkillsManifest.ts +14 -0
  46. package/src/github/listSkills.ts +170 -0
  47. package/src/github/types.ts +5 -0
  48. package/src/index.ts +5 -0
  49. package/src/install/installSkills.ts +78 -0
  50. package/src/install/installState.ts +20 -0
  51. package/src/install/links.ts +9 -0
  52. package/src/install/materializeGitSkill.ts +33 -0
  53. package/src/install/materializeLocalSkill.ts +35 -0
  54. package/src/install/pruneManagedSkills.ts +50 -0
  55. package/src/specifiers/normalizeSpecifier.ts +29 -0
  56. package/src/specifiers/parseSpecifier.ts +45 -0
  57. package/src/utils/fs.ts +19 -0
  58. package/src/utils/hash.ts +5 -0
  59. package/test/add.test.ts +75 -0
  60. package/test/fixtures/local-source/skills/hello-skill/SKILL.md +3 -0
  61. package/test/fixtures/local-source/skills/hello-skill/references/example.md +1 -0
  62. package/test/github.test.ts +120 -0
  63. package/test/install.test.ts +169 -0
  64. package/test/manifest.test.ts +19 -0
  65. package/test/specifiers.test.ts +43 -0
  66. package/tsconfig.json +8 -0
@@ -0,0 +1,78 @@
1
+ import { readSkillsLock } from '../config/readSkillsLock'
2
+ import { readSkillsManifest } from '../config/readSkillsManifest'
3
+ import { syncSkillsLock } from '../config/syncSkillsLock'
4
+ import { writeSkillsLock } from '../config/writeSkillsLock'
5
+ import { sha256 } from '../utils/hash'
6
+ import { linkSkill } from './links'
7
+ import { readInstallState, writeInstallState } from './installState'
8
+ import { materializeGitSkill } from './materializeGitSkill'
9
+ import { materializeLocalSkill } from './materializeLocalSkill'
10
+ import { pruneManagedSkills } from './pruneManagedSkills'
11
+
12
+ function extractSkillPath(specifier: string, skillName: string): string {
13
+ const marker = '#path:'
14
+ const index = specifier.indexOf(marker)
15
+ if (index >= 0) {
16
+ return specifier.slice(index + marker.length)
17
+ }
18
+ return `/${skillName}`
19
+ }
20
+
21
+ export async function installSkills(rootDir: string) {
22
+ const manifest = await readSkillsManifest(rootDir)
23
+ if (!manifest) {
24
+ return { status: 'skipped', reason: 'manifest-missing' } as const
25
+ }
26
+
27
+ const currentLock = await readSkillsLock(rootDir)
28
+ const lockfile = await syncSkillsLock(rootDir, manifest, currentLock)
29
+ await writeSkillsLock(rootDir, lockfile)
30
+
31
+ const lockDigest = sha256(JSON.stringify(lockfile))
32
+ const state = await readInstallState(rootDir)
33
+ if (state?.lockDigest === lockDigest) {
34
+ return { status: 'skipped', reason: 'up-to-date' } as const
35
+ }
36
+
37
+ const installDir = manifest.installDir ?? '.agents/skills'
38
+ const linkTargets = manifest.linkTargets ?? []
39
+
40
+ await pruneManagedSkills(rootDir, installDir, linkTargets, Object.keys(lockfile.skills))
41
+
42
+ for (const [skillName, entry] of Object.entries(lockfile.skills)) {
43
+ if (entry.resolution.type === 'file') {
44
+ await materializeLocalSkill(
45
+ rootDir,
46
+ skillName,
47
+ entry.resolution.path,
48
+ extractSkillPath(entry.specifier, skillName),
49
+ installDir,
50
+ )
51
+ } else if (entry.resolution.type === 'git') {
52
+ await materializeGitSkill(
53
+ rootDir,
54
+ skillName,
55
+ entry.resolution.url,
56
+ entry.resolution.commit,
57
+ entry.resolution.path,
58
+ installDir,
59
+ )
60
+ } else {
61
+ throw new Error(`Unsupported resolution type in 0.1.0 core flow: ${entry.resolution.type}`)
62
+ }
63
+
64
+ for (const linkTarget of linkTargets) {
65
+ await linkSkill(rootDir, installDir, linkTarget, skillName)
66
+ }
67
+ }
68
+
69
+ await writeInstallState(rootDir, {
70
+ lockDigest,
71
+ installDir,
72
+ linkTargets,
73
+ installerVersion: '0.1.0',
74
+ installedAt: new Date().toISOString(),
75
+ })
76
+
77
+ return { status: 'installed', installed: Object.keys(lockfile.skills) } as const
78
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { ensureDir, writeJson } from '../utils/fs'
4
+
5
+ export async function readInstallState(rootDir: string) {
6
+ const filePath = path.join(rootDir, '.agents/skills/.skills-pm-install-state.json')
7
+
8
+ try {
9
+ return JSON.parse(await readFile(filePath, 'utf8'))
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ export async function writeInstallState(rootDir: string, value: unknown) {
16
+ const dirPath = path.join(rootDir, '.agents/skills')
17
+ await ensureDir(dirPath)
18
+ const filePath = path.join(dirPath, '.skills-pm-install-state.json')
19
+ await writeJson(filePath, value)
20
+ }
@@ -0,0 +1,9 @@
1
+ import path from 'node:path'
2
+ import { ensureDir, replaceSymlink } from '../utils/fs'
3
+
4
+ export async function linkSkill(rootDir: string, installDir: string, linkTarget: string, skillName: string) {
5
+ const absoluteTarget = path.join(rootDir, installDir, skillName)
6
+ const absoluteLink = path.join(rootDir, linkTarget, skillName)
7
+ await ensureDir(path.dirname(absoluteLink))
8
+ await replaceSymlink(absoluteTarget, absoluteLink)
9
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { materializeLocalSkill } from './materializeLocalSkill'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ export async function materializeGitSkill(
11
+ rootDir: string,
12
+ skillName: string,
13
+ repoUrl: string,
14
+ commit: string,
15
+ sourcePath: string,
16
+ installDir: string,
17
+ ) {
18
+ const checkoutRoot = await mkdtemp(path.join(tmpdir(), 'skills-pm-git-checkout-'))
19
+
20
+ try {
21
+ await execFileAsync('git', ['clone', '--depth', '1', repoUrl, checkoutRoot])
22
+ if (commit && commit !== 'HEAD') {
23
+ await execFileAsync('git', ['checkout', commit], { cwd: checkoutRoot })
24
+ }
25
+
26
+ const skillDocPath = path.join(checkoutRoot, sourcePath.replace(/^\//, ''), 'SKILL.md')
27
+ await readFile(skillDocPath, 'utf8')
28
+
29
+ await materializeLocalSkill(rootDir, skillName, checkoutRoot, sourcePath, installDir)
30
+ } finally {
31
+ await rm(checkoutRoot, { recursive: true, force: true })
32
+ }
33
+ }
@@ -0,0 +1,35 @@
1
+ import { cp, readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { ensureDir, writeJson } from '../utils/fs'
4
+
5
+ export async function materializeLocalSkill(
6
+ rootDir: string,
7
+ skillName: string,
8
+ sourceRoot: string,
9
+ sourcePath: string,
10
+ installDir: string,
11
+ ) {
12
+ const relativeSkillPath = sourcePath.replace(/^\//, '')
13
+ const absoluteSkillPath = path.join(sourceRoot, relativeSkillPath)
14
+ const skillDocPath = path.join(absoluteSkillPath, 'SKILL.md')
15
+
16
+ let skillDoc = ''
17
+ try {
18
+ skillDoc = await readFile(skillDocPath, 'utf8')
19
+ } catch {
20
+ throw new Error(`Invalid skill at ${absoluteSkillPath}: missing SKILL.md`)
21
+ }
22
+
23
+ if (!skillDoc) {
24
+ throw new Error(`Invalid skill at ${absoluteSkillPath}: missing SKILL.md`)
25
+ }
26
+
27
+ const targetDir = path.join(rootDir, installDir, skillName)
28
+ await ensureDir(path.dirname(targetDir))
29
+ await cp(absoluteSkillPath, targetDir, { recursive: true, force: true })
30
+ await writeJson(path.join(targetDir, '.skills-pm.json'), {
31
+ name: skillName,
32
+ installedBy: 'skills-pm',
33
+ version: '0.1.0',
34
+ })
35
+ }
@@ -0,0 +1,50 @@
1
+ import { lstat, readdir, readFile, rm } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ async function isManagedSkillDir(dirPath: string): Promise<boolean> {
5
+ try {
6
+ const marker = JSON.parse(await readFile(path.join(dirPath, '.skills-pm.json'), 'utf8'))
7
+ return marker?.installedBy === 'skills-pm'
8
+ } catch {
9
+ return false
10
+ }
11
+ }
12
+
13
+ export async function pruneManagedSkills(rootDir: string, installDir: string, linkTargets: string[], wantedSkillNames: string[]) {
14
+ const wanted = new Set(wantedSkillNames)
15
+ const absoluteInstallDir = path.join(rootDir, installDir)
16
+
17
+ try {
18
+ const entries = await readdir(absoluteInstallDir)
19
+ for (const entry of entries) {
20
+ if (entry.startsWith('.')) {
21
+ continue
22
+ }
23
+
24
+ const skillDir = path.join(absoluteInstallDir, entry)
25
+ if (!(await isManagedSkillDir(skillDir))) {
26
+ continue
27
+ }
28
+
29
+ if (wanted.has(entry)) {
30
+ continue
31
+ }
32
+
33
+ await rm(skillDir, { recursive: true, force: true })
34
+
35
+ for (const linkTarget of linkTargets) {
36
+ const linkPath = path.join(rootDir, linkTarget, entry)
37
+ try {
38
+ const stat = await lstat(linkPath)
39
+ if (stat.isSymbolicLink() || stat.isDirectory() || stat.isFile()) {
40
+ await rm(linkPath, { recursive: true, force: true })
41
+ }
42
+ } catch {
43
+ // ignore missing link target
44
+ }
45
+ }
46
+ }
47
+ } catch {
48
+ // ignore missing install dir
49
+ }
50
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path'
2
+ import type { NormalizedSpecifier } from '../config/types'
3
+ import { parseSpecifier } from './parseSpecifier'
4
+
5
+ export function normalizeSpecifier(specifier: string): NormalizedSpecifier {
6
+ const parsed = parseSpecifier(specifier)
7
+ const type = parsed.sourcePart.startsWith('file:')
8
+ ? 'file'
9
+ : parsed.sourcePart.startsWith('npm:')
10
+ ? 'npm'
11
+ : 'git'
12
+
13
+ const skillPath = parsed.path || '/'
14
+ const skillName = path.posix.basename(skillPath)
15
+ const normalized = parsed.ref
16
+ ? `${parsed.sourcePart}#${parsed.ref}&path:${skillPath}`
17
+ : parsed.path
18
+ ? `${parsed.sourcePart}#path:${skillPath}`
19
+ : parsed.sourcePart
20
+
21
+ return {
22
+ type,
23
+ source: parsed.sourcePart,
24
+ ref: parsed.ref,
25
+ path: skillPath,
26
+ normalized,
27
+ skillName,
28
+ }
29
+ }
@@ -0,0 +1,45 @@
1
+ export function parseSpecifier(specifier: string) {
2
+ const firstHashIndex = specifier.indexOf('#')
3
+ const secondHashIndex = firstHashIndex >= 0 ? specifier.indexOf('#', firstHashIndex + 1) : -1
4
+
5
+ if (secondHashIndex >= 0) {
6
+ throw new Error('Invalid specifier: multiple # fragments are not supported')
7
+ }
8
+
9
+ const hashIndex = firstHashIndex
10
+ const sourcePart = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier
11
+ const fragment = hashIndex >= 0 ? specifier.slice(hashIndex + 1) : ''
12
+
13
+ if (!sourcePart) {
14
+ throw new Error('Specifier source is required')
15
+ }
16
+
17
+ if (!fragment) {
18
+ return {
19
+ sourcePart,
20
+ ref: null,
21
+ path: '',
22
+ }
23
+ }
24
+
25
+ const parts = fragment.split('&').filter(Boolean)
26
+ let ref: string | null = null
27
+ let parsedPath = ''
28
+
29
+ for (const part of parts) {
30
+ if (part.startsWith('path:')) {
31
+ parsedPath = part.slice('path:'.length)
32
+ continue
33
+ }
34
+
35
+ if (ref === null) {
36
+ ref = part
37
+ }
38
+ }
39
+
40
+ return {
41
+ sourcePart,
42
+ ref,
43
+ path: parsedPath,
44
+ }
45
+ }
@@ -0,0 +1,19 @@
1
+ import { cp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
2
+
3
+ export async function ensureDir(dirPath: string): Promise<void> {
4
+ await mkdir(dirPath, { recursive: true })
5
+ }
6
+
7
+ export async function replaceDir(from: string, to: string): Promise<void> {
8
+ await rm(to, { recursive: true, force: true })
9
+ await cp(from, to, { recursive: true })
10
+ }
11
+
12
+ export async function replaceSymlink(target: string, linkPath: string): Promise<void> {
13
+ await rm(linkPath, { recursive: true, force: true })
14
+ await symlink(target, linkPath)
15
+ }
16
+
17
+ export async function writeJson(filePath: string, value: unknown): Promise<void> {
18
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
19
+ }
@@ -0,0 +1,5 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export function sha256(content: string): string {
4
+ return `sha256-${createHash('sha256').update(content).digest('hex')}`
5
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from '@rstest/core'
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { execSync } from 'node:child_process'
6
+ import YAML from 'yaml'
7
+ import { addCommand } from '../src/commands/add'
8
+
9
+ describe('addCommand', () => {
10
+ it('writes manifest and lock for a file skill specifier', async () => {
11
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-'))
12
+ await addCommand({
13
+ cwd: root,
14
+ specifier: 'file:./packages/skills-pm/test/fixtures/local-source#path:/skills/hello-skill',
15
+ })
16
+
17
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
18
+ const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
19
+
20
+ expect(manifest.skills['hello-skill']).toBe('file:./packages/skills-pm/test/fixtures/local-source#path:/skills/hello-skill')
21
+ expect(lockfile.skills['hello-skill'].resolution.type).toBe('file')
22
+ })
23
+
24
+ it('writes manifest and lock for a git skill specifier', async () => {
25
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-git-'))
26
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-git-source-'))
27
+
28
+ mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
29
+ writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello from add git\n')
30
+ execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
31
+ execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
32
+ execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
33
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
34
+ execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
35
+ const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
36
+
37
+ await addCommand({
38
+ cwd: root,
39
+ specifier: `${gitRepo}#HEAD&path:/skills/hello-skill`,
40
+ })
41
+
42
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
43
+ const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
44
+
45
+ expect(manifest.skills['hello-skill']).toBe(`${gitRepo}#HEAD&path:/skills/hello-skill`)
46
+ expect(lockfile.skills['hello-skill'].resolution.type).toBe('git')
47
+ expect(lockfile.skills['hello-skill'].resolution.url).toBe(gitRepo)
48
+ expect(lockfile.skills['hello-skill'].resolution.commit).toBe(commit)
49
+ expect(lockfile.skills['hello-skill'].resolution.path).toBe('/skills/hello-skill')
50
+ })
51
+
52
+ it('adds a skill with owner/repo and --skill flag', async () => {
53
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-shorthand-'))
54
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-shorthand-source-'))
55
+
56
+ mkdirSync(path.join(gitRepo, 'dogfood'), { recursive: true })
57
+ writeFileSync(path.join(gitRepo, 'dogfood/SKILL.md'), '# Dogfood skill\n')
58
+ execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
59
+ execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
60
+ execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
61
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
62
+ execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
63
+
64
+ // Use a direct git specifier to test the --skill path builds the right specifier
65
+ // (We can't actually test owner/repo without GitHub API, so test the protocol fallback)
66
+ await addCommand({
67
+ cwd: root,
68
+ specifier: `${gitRepo}#HEAD&path:/dogfood`,
69
+ skill: undefined,
70
+ })
71
+
72
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
73
+ expect(manifest.skills['dogfood']).toBe(`${gitRepo}#HEAD&path:/dogfood`)
74
+ })
75
+ })
@@ -0,0 +1,3 @@
1
+ # Hello skill
2
+
3
+ This is a local fixture skill.
@@ -0,0 +1 @@
1
+ Example reference document.
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from '@rstest/core'
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { parseOwnerRepo, parseGitHubUrl, discoverSkillsInDir } from '../src/github/listSkills'
6
+
7
+ describe('parseOwnerRepo', () => {
8
+ it('parses owner/repo format', () => {
9
+ expect(parseOwnerRepo('mxyhi/ok-skills')).toEqual({
10
+ owner: 'mxyhi',
11
+ repo: 'ok-skills',
12
+ })
13
+ })
14
+
15
+ it('parses owner/repo with dots and hyphens', () => {
16
+ expect(parseOwnerRepo('some.user/my-repo.git')).toEqual({
17
+ owner: 'some.user',
18
+ repo: 'my-repo.git',
19
+ })
20
+ })
21
+
22
+ it('returns null for protocol specifiers', () => {
23
+ expect(parseOwnerRepo('file:./local-source')).toBeNull()
24
+ expect(parseOwnerRepo('npm:@scope/pkg')).toBeNull()
25
+ expect(parseOwnerRepo('https://github.com/owner/repo.git')).toBeNull()
26
+ })
27
+
28
+ it('returns null for bare names without slash', () => {
29
+ expect(parseOwnerRepo('some-skill')).toBeNull()
30
+ })
31
+
32
+ it('returns null for paths with multiple slashes', () => {
33
+ expect(parseOwnerRepo('a/b/c')).toBeNull()
34
+ })
35
+ })
36
+
37
+ describe('parseGitHubUrl', () => {
38
+ it('parses https://github.com/owner/repo', () => {
39
+ expect(parseGitHubUrl('https://github.com/vercel-labs/skills')).toEqual({
40
+ owner: 'vercel-labs',
41
+ repo: 'skills',
42
+ })
43
+ })
44
+
45
+ it('parses https://github.com/owner/repo.git', () => {
46
+ expect(parseGitHubUrl('https://github.com/mxyhi/ok-skills.git')).toEqual({
47
+ owner: 'mxyhi',
48
+ repo: 'ok-skills',
49
+ })
50
+ })
51
+
52
+ it('parses with trailing slash', () => {
53
+ expect(parseGitHubUrl('https://github.com/owner/repo/')).toEqual({
54
+ owner: 'owner',
55
+ repo: 'repo',
56
+ })
57
+ })
58
+
59
+ it('returns null for non-GitHub URLs', () => {
60
+ expect(parseGitHubUrl('https://gitlab.com/owner/repo')).toBeNull()
61
+ })
62
+
63
+ it('returns null for GitHub URLs with path fragments', () => {
64
+ expect(parseGitHubUrl('https://github.com/owner/repo/tree/main')).toBeNull()
65
+ })
66
+
67
+ it('returns null for owner/repo shorthand', () => {
68
+ expect(parseGitHubUrl('owner/repo')).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('discoverSkillsInDir', () => {
73
+ it('discovers skills at root level', async () => {
74
+ const dir = mkdtempSync(path.join(tmpdir(), 'skills-discover-root-'))
75
+ mkdirSync(path.join(dir, 'my-skill'), { recursive: true })
76
+ writeFileSync(
77
+ path.join(dir, 'my-skill/SKILL.md'),
78
+ '---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n',
79
+ )
80
+
81
+ const skills = await discoverSkillsInDir(dir)
82
+ expect(skills).toHaveLength(1)
83
+ expect(skills[0].name).toBe('my-skill')
84
+ expect(skills[0].description).toBe('A test skill')
85
+ expect(skills[0].path).toBe('/my-skill')
86
+ })
87
+
88
+ it('discovers skills in skills/ subdirectory', async () => {
89
+ const dir = mkdtempSync(path.join(tmpdir(), 'skills-discover-sub-'))
90
+ mkdirSync(path.join(dir, 'skills/find-skills'), { recursive: true })
91
+ writeFileSync(
92
+ path.join(dir, 'skills/find-skills/SKILL.md'),
93
+ '---\nname: find-skills\ndescription: Find skills\n---\n',
94
+ )
95
+
96
+ const skills = await discoverSkillsInDir(dir)
97
+ expect(skills).toHaveLength(1)
98
+ expect(skills[0].name).toBe('find-skills')
99
+ expect(skills[0].path).toBe('/skills/find-skills')
100
+ })
101
+
102
+ it('returns empty array when no skills found', async () => {
103
+ const dir = mkdtempSync(path.join(tmpdir(), 'skills-discover-empty-'))
104
+ mkdirSync(path.join(dir, 'some-dir'), { recursive: true })
105
+ writeFileSync(path.join(dir, 'some-dir/README.md'), '# Not a skill\n')
106
+
107
+ const skills = await discoverSkillsInDir(dir)
108
+ expect(skills).toHaveLength(0)
109
+ })
110
+
111
+ it('uses directory name when frontmatter has no name', async () => {
112
+ const dir = mkdtempSync(path.join(tmpdir(), 'skills-discover-noname-'))
113
+ mkdirSync(path.join(dir, 'cool-skill'), { recursive: true })
114
+ writeFileSync(path.join(dir, 'cool-skill/SKILL.md'), '# Just a heading\n')
115
+
116
+ const skills = await discoverSkillsInDir(dir)
117
+ expect(skills).toHaveLength(1)
118
+ expect(skills[0].name).toBe('cool-skill')
119
+ })
120
+ })