rolecraft 0.1.0

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.
@@ -0,0 +1,92 @@
1
+ import { describe, it, before, after, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync } from 'node:fs'
4
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ let tempDir, listModule
9
+
10
+ before(async () => {
11
+ tempDir = mkdtempSync(join(tmpdir(), 'rolecraft-list-test-'))
12
+ process.env.HOME = tempDir
13
+ await mkdir(join(tempDir, '.agents'), { recursive: true })
14
+ listModule = await import('./list.js')
15
+ })
16
+
17
+ after(async () => {
18
+ await rm(tempDir, { recursive: true, force: true })
19
+ })
20
+
21
+ function captureLogs() {
22
+ const logs = []
23
+ mock.method(console, 'log', (...args) => {
24
+ if (args.length) logs.push(String(args[0]))
25
+ })
26
+ return logs
27
+ }
28
+
29
+ describe('list command', () => {
30
+ it('shows no skills message when lock is empty', async () => {
31
+ const logs = captureLogs()
32
+
33
+ await listModule.listCommand()
34
+
35
+ assert.ok(logs.some(l => l.includes('No skills installed')))
36
+ })
37
+
38
+ it('lists installed skills with details', async () => {
39
+ await writeFile(join(tempDir, '.agents', '.skill-lock.json'), JSON.stringify({
40
+ version: 3,
41
+ skills: {
42
+ 'test/skill': {
43
+ installedAt: '2025-01-15T10:00:00.000Z',
44
+ source: 'owner/repo',
45
+ sourceType: 'github',
46
+ },
47
+ },
48
+ dismissed: {},
49
+ lastSelectedAgents: [],
50
+ }))
51
+
52
+ const logs = captureLogs()
53
+
54
+ await listModule.listCommand()
55
+
56
+ assert.ok(logs.some(l => l.includes('test/skill')))
57
+ assert.ok(logs.some(l => l.includes('Source: owner/repo')))
58
+ assert.ok(logs.some(l => l.includes('Type: github')))
59
+ assert.ok(logs.some(l => l.includes('1 skill(s)')))
60
+ })
61
+
62
+ it('handles skill without optional fields', async () => {
63
+ await writeFile(join(tempDir, '.agents', '.skill-lock.json'), JSON.stringify({
64
+ version: 3,
65
+ skills: { 'minimal/skill': { installedAt: '2025-01-01T00:00:00.000Z' } },
66
+ dismissed: {},
67
+ lastSelectedAgents: [],
68
+ }))
69
+
70
+ const logs = captureLogs()
71
+
72
+ await listModule.listCommand()
73
+
74
+ assert.ok(logs.some(l => l.includes('minimal/skill')))
75
+ assert.ok(logs.some(l => l.includes('1 skill(s)')))
76
+ })
77
+
78
+ it('handles skill with unknown installedAt', async () => {
79
+ await writeFile(join(tempDir, '.agents', '.skill-lock.json'), JSON.stringify({
80
+ version: 3,
81
+ skills: { 'nodate/skill': {} },
82
+ dismissed: {},
83
+ lastSelectedAgents: [],
84
+ }))
85
+
86
+ const logs = captureLogs()
87
+
88
+ await listModule.listCommand()
89
+
90
+ assert.ok(logs.some(l => l.includes('nodate/skill')))
91
+ })
92
+ })
@@ -0,0 +1,33 @@
1
+ import { rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { readLock, removeSkillFromLock, getAgentsDir } from '../utils/lockfile.js'
4
+
5
+ function normalizeSlug(slug) {
6
+ return slug.replace(/\//g, '-')
7
+ }
8
+
9
+ export async function removeCommand(slug) {
10
+ const lock = await readLock()
11
+
12
+ let actualSlug = slug
13
+ if (!lock.skills[slug]) {
14
+ const normalized = normalizeSlug(slug)
15
+ const found = Object.keys(lock.skills).find(k => normalizeSlug(k) === normalized)
16
+ if (found) {
17
+ actualSlug = found
18
+ } else {
19
+ console.error(`Skill "${slug}" not found.`)
20
+ process.exit(1)
21
+ }
22
+ }
23
+
24
+ const dir = join(getAgentsDir(), normalizeSlug(actualSlug))
25
+ try {
26
+ await rm(dir, { recursive: true, force: true })
27
+ } catch {
28
+ // directory might not exist
29
+ }
30
+
31
+ await removeSkillFromLock(actualSlug)
32
+ console.log(`Removed ${actualSlug}.`)
33
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, before, after, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { mkdir, rm, readFile, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ let tempDir, removeModule
9
+
10
+ before(async () => {
11
+ tempDir = mkdtempSync(join(tmpdir(), 'rolecraft-remove-test-'))
12
+ process.env.HOME = tempDir
13
+
14
+ await mkdir(join(tempDir, '.agents', 'skills', 'test-skill'), { recursive: true })
15
+
16
+ const lockPath = join(tempDir, '.agents', '.skill-lock.json')
17
+ await writeFile(lockPath, JSON.stringify({
18
+ version: 3,
19
+ skills: {
20
+ 'test/skill': { name: 'Test Skill' },
21
+ 'other/skill': { name: 'Other Skill' },
22
+ 'exact/skill': { name: 'Exact Skill' },
23
+ },
24
+ dismissed: {},
25
+ lastSelectedAgents: [],
26
+ }))
27
+
28
+ removeModule = await import('./remove.js')
29
+ })
30
+
31
+ after(async () => {
32
+ await rm(tempDir, { recursive: true, force: true })
33
+ })
34
+
35
+ describe('remove command', () => {
36
+ it('removes an installed skill by exact slug', async () => {
37
+ const logs = []
38
+ mock.method(console, 'log', (...args) => {
39
+ if (args.length) logs.push(String(args[0]))
40
+ })
41
+
42
+ await removeModule.removeCommand('exact/skill')
43
+
44
+ const lock = JSON.parse(await readFile(join(tempDir, '.agents', '.skill-lock.json'), 'utf-8'))
45
+ assert.ok(!lock.skills['exact/skill'])
46
+ assert.ok(logs.some(l => l.includes('Removed')))
47
+ })
48
+
49
+ it('matches via normalized slug (replacing / with -)', async () => {
50
+ mkdirSync(join(tempDir, '.agents', 'skills', 'other-skill'), { recursive: true })
51
+
52
+ const logs = []
53
+ mock.method(console, 'log', (...args) => {
54
+ if (args.length) logs.push(String(args[0]))
55
+ })
56
+
57
+ await removeModule.removeCommand('other-skill')
58
+
59
+ const lock = JSON.parse(await readFile(join(tempDir, '.agents', '.skill-lock.json'), 'utf-8'))
60
+ assert.ok(!lock.skills['other/skill'])
61
+ assert.ok(logs.some(l => l.includes('Removed')))
62
+ })
63
+
64
+ it('exits with error when skill not found', async () => {
65
+ const origExit = process.exit
66
+ const origError = console.error
67
+ const errors = []
68
+ console.error = (msg) => errors.push(msg)
69
+ process.exit = (code) => { throw new Error(`exit:${code}`) }
70
+
71
+ await assert.rejects(
72
+ () => removeModule.removeCommand('nonexistent'),
73
+ /exit:1/,
74
+ )
75
+
76
+ assert.ok(errors.some(e => e.includes('not found')))
77
+ process.exit = origExit
78
+ console.error = origError
79
+ })
80
+
81
+ })
@@ -0,0 +1,75 @@
1
+ import { mkdir, cp, writeFile, readFile, access, stat } from 'node:fs/promises'
2
+ import { join, basename } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { getAgentsDir, getClaudeDir, addSkillToLock, getGlobalLockPath, getProjectLockPath } from './lockfile.js'
5
+
6
+ function normalizeSlug(slug) {
7
+ return slug.replace(/\//g, '-')
8
+ }
9
+
10
+ export async function installSkill(resolved, targets) {
11
+ const slug = resolved.slug
12
+ const results = []
13
+
14
+ for (const target of targets) {
15
+ let baseDir
16
+ let label
17
+
18
+ switch (target) {
19
+ case 'agents': {
20
+ baseDir = getAgentsDir()
21
+ label = '~/.agents/skills/'
22
+ break
23
+ }
24
+ case 'claude': {
25
+ baseDir = getClaudeDir()
26
+ label = '~/.claude/skills/'
27
+ break
28
+ }
29
+ case 'project': {
30
+ baseDir = join(process.cwd(), '.agents', 'skills')
31
+ label = './.agents/skills/'
32
+ break
33
+ }
34
+ default:
35
+ continue
36
+ }
37
+
38
+ const slugDir = join(baseDir, normalizeSlug(slug))
39
+ const destSkillPath = join(slugDir, 'SKILL.md')
40
+
41
+ await mkdir(slugDir, { recursive: true })
42
+
43
+ for (const file of resolved.files) {
44
+ const dst = join(slugDir, file)
45
+ if (resolved.fileContents?.[file]) {
46
+ await writeFile(dst, resolved.fileContents[file])
47
+ } else if (resolved.skillDir) {
48
+ const src = join(resolved.skillDir, file)
49
+ try {
50
+ await stat(src)
51
+ await cp(src, dst, { recursive: true, force: true })
52
+ } catch {
53
+ // skip files that don't exist
54
+ }
55
+ }
56
+ }
57
+
58
+ const lockPath = target === 'project'
59
+ ? getProjectLockPath(process.cwd())
60
+ : getGlobalLockPath()
61
+
62
+ await addSkillToLock(slug, {
63
+ slug,
64
+ contentSha: resolved.contentSha || 'local',
65
+ installedAt: new Date().toISOString(),
66
+ agents: ['opencode', 'claude-code'],
67
+ source: resolved.sourcePath,
68
+ sourceType: resolved.sourceType,
69
+ }, lockPath)
70
+
71
+ results.push({ target, path: slugDir, label })
72
+ }
73
+
74
+ return results
75
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
4
+ import { mkdir, rm } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ let tempDir, installerModule, resolvedSkill
9
+
10
+ before(async () => {
11
+ tempDir = mkdtempSync(join(tmpdir(), 'rolecraft-install-test-'))
12
+ process.env.HOME = tempDir
13
+
14
+ const sourceDir = join(tempDir, 'source-skill')
15
+ mkdirSync(sourceDir, { recursive: true })
16
+ writeFileSync(join(sourceDir, 'SKILL.md'), '# slug: test/my-skill\nname: my-skill\n# owner: test\nContent')
17
+ writeFileSync(join(sourceDir, 'helper.js'), 'module.exports = {}\n')
18
+
19
+ await mkdir(join(tempDir, '.agents'), { recursive: true })
20
+
21
+ resolvedSkill = {
22
+ slug: 'test/my-skill',
23
+ name: 'my-skill',
24
+ owner: 'test',
25
+ content: '# slug: test/my-skill\nname: my-skill\nContent',
26
+ files: ['SKILL.md', 'helper.js'],
27
+ skillDir: sourceDir,
28
+ sourcePath: sourceDir,
29
+ sourceType: 'local',
30
+ }
31
+
32
+ installerModule = await import('./installer.js')
33
+ })
34
+
35
+ after(async () => {
36
+ await rm(tempDir, { recursive: true, force: true })
37
+ })
38
+
39
+ describe('installer', () => {
40
+ it('installs skill to agents directory (global)', async () => {
41
+ const results = await installerModule.installSkill(resolvedSkill, ['agents'])
42
+
43
+ assert.equal(results.length, 1)
44
+ assert.equal(results[0].target, 'agents')
45
+
46
+ const skillDir = join(tempDir, '.agents', 'skills', 'test-my-skill')
47
+ assert.ok(existsSync(join(skillDir, 'SKILL.md')))
48
+ assert.ok(existsSync(join(skillDir, 'helper.js')))
49
+
50
+ const lock = JSON.parse(readFileSync(join(tempDir, '.agents', '.skill-lock.json'), 'utf-8'))
51
+ assert.ok(lock.skills['test/my-skill'])
52
+ })
53
+
54
+ it('installs skill to claude directory', async () => {
55
+ const results = await installerModule.installSkill(resolvedSkill, ['claude'])
56
+
57
+ assert.equal(results.length, 1)
58
+ assert.equal(results[0].target, 'claude')
59
+
60
+ const skillDir = join(tempDir, '.claude', 'skills', 'test-my-skill')
61
+ assert.ok(existsSync(join(skillDir, 'SKILL.md')))
62
+ })
63
+
64
+ it('installs skill to project directory', async () => {
65
+ const origCwd = process.cwd
66
+ process.cwd = () => tempDir
67
+
68
+ const results = await installerModule.installSkill(resolvedSkill, ['project'])
69
+
70
+ assert.equal(results.length, 1)
71
+ assert.equal(results[0].target, 'project')
72
+
73
+ const skillDir = join(tempDir, '.agents', 'skills', 'test-my-skill')
74
+ assert.ok(existsSync(join(skillDir, 'SKILL.md')))
75
+
76
+ process.cwd = origCwd
77
+ })
78
+
79
+ it('installs to multiple targets', async () => {
80
+ const results = await installerModule.installSkill(resolvedSkill, ['agents', 'project'])
81
+ assert.equal(results.length, 2)
82
+ })
83
+
84
+ it('skips unknown targets', async () => {
85
+ const results = await installerModule.installSkill(resolvedSkill, ['unknown'])
86
+ assert.equal(results.length, 0)
87
+ })
88
+
89
+ it('handles missing source files gracefully', async () => {
90
+ const badResolved = {
91
+ ...resolvedSkill,
92
+ files: ['SKILL.md', 'nonexistent.js'],
93
+ }
94
+ const results = await installerModule.installSkill(badResolved, ['agents'])
95
+ assert.equal(results.length, 1)
96
+ const skillDir = join(tempDir, '.agents', 'skills', 'test-my-skill')
97
+ assert.ok(existsSync(join(skillDir, 'SKILL.md')))
98
+ assert.ok(!existsSync(join(skillDir, 'nonexistent.js')))
99
+ })
100
+ })
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const GLOBAL_AGENTS_DIR = join(homedir(), '.agents')
6
+ const GLOBAL_LOCK_PATH = join(GLOBAL_AGENTS_DIR, '.skill-lock.json')
7
+ const GLOBAL_AGENTS_SKILLS_DIR = join(GLOBAL_AGENTS_DIR, 'skills')
8
+ const CLAUDE_DIR = join(homedir(), '.claude', 'skills')
9
+
10
+ export function getGlobalLockPath() {
11
+ return GLOBAL_LOCK_PATH
12
+ }
13
+
14
+ export function getAgentsDir() {
15
+ return GLOBAL_AGENTS_SKILLS_DIR
16
+ }
17
+
18
+ export function getClaudeDir() {
19
+ return CLAUDE_DIR
20
+ }
21
+
22
+ export function getProjectLockPath(cwd) {
23
+ return join(cwd, '.agents', '.skill-lock.json')
24
+ }
25
+
26
+ async function ensureParentDir(filePath) {
27
+ await mkdir(dirname(filePath), { recursive: true })
28
+ }
29
+
30
+ export async function readLock(lockPath = GLOBAL_LOCK_PATH) {
31
+ try {
32
+ await access(lockPath)
33
+ const raw = await readFile(lockPath, 'utf-8')
34
+ return JSON.parse(raw)
35
+ } catch {
36
+ return { version: 3, skills: {}, dismissed: {}, lastSelectedAgents: [] }
37
+ }
38
+ }
39
+
40
+ export async function writeLock(data, lockPath = GLOBAL_LOCK_PATH) {
41
+ await ensureParentDir(lockPath)
42
+ await writeFile(lockPath, JSON.stringify(data, null, 2) + '\n', 'utf-8')
43
+ }
44
+
45
+ export async function addSkillToLock(slug, entry, lockPath = GLOBAL_LOCK_PATH) {
46
+ const lock = await readLock(lockPath)
47
+ lock.skills[slug] = { ...entry, installedAt: new Date().toISOString() }
48
+ await writeLock(lock, lockPath)
49
+ return lock
50
+ }
51
+
52
+ export async function removeSkillFromLock(slug, lockPath = GLOBAL_LOCK_PATH) {
53
+ const lock = await readLock(lockPath)
54
+ delete lock.skills[slug]
55
+ await writeLock(lock, lockPath)
56
+ return lock
57
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, readFileSync } from 'node:fs'
4
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+
8
+ let tempDir, lockModule
9
+
10
+ before(async () => {
11
+ tempDir = mkdtempSync(join(tmpdir(), 'rolecraft-lock-test-'))
12
+ const oldHome = process.env.HOME
13
+ process.env.HOME = tempDir
14
+ await mkdir(join(tempDir, '.agents'), { recursive: true })
15
+ lockModule = await import('./lockfile.js')
16
+ })
17
+
18
+ after(async () => {
19
+ await rm(tempDir, { recursive: true, force: true })
20
+ })
21
+
22
+ describe('lockfile', () => {
23
+ it('getGlobalLockPath returns path inside homedir', () => {
24
+ assert.equal(lockModule.getGlobalLockPath(), join(tempDir, '.agents', '.skill-lock.json'))
25
+ })
26
+
27
+ it('getAgentsDir returns path inside homedir', () => {
28
+ assert.equal(lockModule.getAgentsDir(), join(tempDir, '.agents', 'skills'))
29
+ })
30
+
31
+ it('getClaudeDir returns path inside homedir', () => {
32
+ assert.equal(lockModule.getClaudeDir(), join(tempDir, '.claude', 'skills'))
33
+ })
34
+
35
+ it('readLock returns default when no file exists', async () => {
36
+ const lock = await lockModule.readLock()
37
+ assert.deepEqual(lock, {
38
+ version: 3, skills: {}, dismissed: {}, lastSelectedAgents: [],
39
+ })
40
+ })
41
+
42
+ it('readLock parses existing lock file', async () => {
43
+ const data = { version: 3, skills: { test: { name: 'x' } }, dismissed: {}, lastSelectedAgents: [] }
44
+ await writeFile(join(tempDir, '.agents', '.skill-lock.json'), JSON.stringify(data))
45
+ const lock = await lockModule.readLock()
46
+ assert.deepEqual(lock, data)
47
+ })
48
+
49
+ it('writeLock writes lock file', async () => {
50
+ const data = { version: 3, skills: { w: {} }, dismissed: {}, lastSelectedAgents: [] }
51
+ await lockModule.writeLock(data)
52
+ const written = JSON.parse(readFileSync(join(tempDir, '.agents', '.skill-lock.json'), 'utf-8'))
53
+ assert.deepEqual(written, data)
54
+ })
55
+
56
+ it('addSkillToLock adds entry and sets installedAt', async () => {
57
+ await lockModule.addSkillToLock('test/skill', { name: 'Test' })
58
+ const lock = await lockModule.readLock()
59
+ assert.equal(lock.skills['test/skill'].name, 'Test')
60
+ assert.ok(lock.skills['test/skill'].installedAt)
61
+ })
62
+
63
+ it('removeSkillFromLock removes entry', async () => {
64
+ await lockModule.addSkillToLock('to-remove', {})
65
+ await lockModule.removeSkillFromLock('to-remove')
66
+ const lock = await lockModule.readLock()
67
+ assert.ok(!lock.skills['to-remove'])
68
+ })
69
+ })
@@ -0,0 +1,151 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises'
2
+ import { join, dirname, basename } from 'node:path'
3
+ import { tmpdir, homedir } from 'node:os'
4
+ import { execSync } from 'node:child_process'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { readdirSync, readFileSync } from 'node:fs'
7
+
8
+ function isGitHubRef(source) {
9
+ return /^[\w.-]+\/[\w.-]+$/.test(source) && !source.startsWith('/') && !source.startsWith('.')
10
+ }
11
+
12
+ function isLocalPath(source) {
13
+ return source.startsWith('/') || source.startsWith('.') || source.startsWith('~')
14
+ }
15
+
16
+ function parseMetadata(content) {
17
+ const slugMatch = content.match(/^# slug:\s*(\S+)$/m)
18
+ const nameMatch = content.match(/^name:\s*(\S+)$/m)
19
+ const ownerMatch = content.match(/^# owner:\s*(\S+)$/m)
20
+
21
+ const name = nameMatch?.[1] || slugMatch?.[1]?.split('/')?.[1] || 'unknown'
22
+ const slug = slugMatch?.[1] || name
23
+ const owner = ownerMatch?.[1] || 'local'
24
+
25
+ return { name, slug, owner }
26
+ }
27
+
28
+ function scanForSkill(dir, maxDepth = 3) {
29
+ const results = []
30
+
31
+ function scan(currentDir, depth = 0) {
32
+ if (depth > maxDepth) return
33
+ let entries
34
+ try {
35
+ entries = readdirSync(currentDir, { withFileTypes: true })
36
+ } catch {
37
+ return
38
+ }
39
+ for (const entry of entries) {
40
+ if (entry.name === '.git') continue
41
+ const fullPath = join(currentDir, entry.name)
42
+ if (entry.isDirectory()) {
43
+ scan(fullPath, depth + 1)
44
+ } else if (entry.name === 'SKILL.md') {
45
+ try {
46
+ const c = readFileSync(fullPath, 'utf-8')
47
+ const meta = parseMetadata(c)
48
+ results.push({ dir: dirname(fullPath), ...meta, content: c })
49
+ } catch {
50
+ // skip unreadable files
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ scan(dir)
57
+ return results
58
+ }
59
+
60
+ async function resolveLocal(source) {
61
+ const expanded = source.replace(/^~/, homedir())
62
+ const st = await stat(expanded)
63
+
64
+ let skillDir
65
+ if (st.isDirectory()) {
66
+ skillDir = expanded
67
+ } else if (st.isFile() && basename(expanded) === 'SKILL.md') {
68
+ skillDir = dirname(expanded)
69
+ } else {
70
+ throw new Error(`Source must be a SKILL.md file or a directory containing one`)
71
+ }
72
+
73
+ const directPath = join(skillDir, 'SKILL.md')
74
+ try {
75
+ await stat(directPath)
76
+ const content = await readFile(directPath, 'utf-8')
77
+ const meta = parseMetadata(content)
78
+ const dirEntries = await readdir(skillDir, { withFileTypes: true })
79
+ const files = dirEntries.filter(e => e.name !== '.git').map(e => e.name)
80
+ return { ...meta, content, files, skillDir, sourcePath: source, sourceType: 'local' }
81
+ } catch {
82
+ // direct SKILL.md not found, scan recursively
83
+ }
84
+
85
+ const found = scanForSkill(skillDir)
86
+ if (found.length === 0) {
87
+ throw new Error(`No SKILL.md found in ${skillDir}`)
88
+ }
89
+
90
+ const skill = found[0]
91
+ const files = readdirSync(skill.dir, { withFileTypes: true })
92
+ .filter(e => e.name !== '.git')
93
+ .map(e => e.name)
94
+
95
+ return { ...skill, files, skillDir: skill.dir, sourcePath: source, sourceType: 'local' }
96
+ }
97
+
98
+ async function resolveGitHub(source) {
99
+ const tmpDir = join(tmpdir(), `rolecraft-${randomUUID().slice(0, 8)}`)
100
+ const url = `https://github.com/${source}.git`
101
+
102
+ try {
103
+ execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, { stdio: 'pipe', timeout: 30000 })
104
+ } catch {
105
+ throw new Error(`Failed to clone GitHub repo ${source}`)
106
+ }
107
+
108
+ const found = scanForSkill(tmpDir)
109
+
110
+ if (found.length === 0) {
111
+ execSync(`rm -rf "${tmpDir}"`)
112
+ throw new Error(`No SKILL.md found in GitHub repo ${source}`)
113
+ }
114
+
115
+ const skill = found[0]
116
+ const files = readdirSync(skill.dir, { withFileTypes: true })
117
+ .filter(e => e.name !== '.git')
118
+ .map(e => e.name)
119
+
120
+ const fileContents = {}
121
+ for (const f of files) {
122
+ try {
123
+ fileContents[f] = readFileSync(join(skill.dir, f), 'utf-8')
124
+ } catch {
125
+ // skip unreadable files
126
+ }
127
+ }
128
+
129
+ execSync(`rm -rf "${tmpDir}"`)
130
+
131
+ const owner = source.split('/')[0]
132
+ return {
133
+ ...skill,
134
+ owner: skill.owner === 'local' ? owner : skill.owner,
135
+ slug: skill.slug === 'unknown' || skill.slug === skill.name ? `${owner}/${skill.name}` : skill.slug,
136
+ files,
137
+ fileContents,
138
+ sourcePath: source,
139
+ sourceType: 'github',
140
+ }
141
+ }
142
+
143
+ export async function resolveSource(source) {
144
+ if (isGitHubRef(source)) {
145
+ return await resolveGitHub(source)
146
+ }
147
+ if (isLocalPath(source)) {
148
+ return await resolveLocal(source)
149
+ }
150
+ throw new Error(`Invalid source: "${source}". Use a local path (./, /, ~) or GitHub ref (owner/repo)`)
151
+ }