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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/rolecraft.js +98 -0
- package/bin/rolecraft.test.js +174 -0
- package/package.json +45 -0
- package/src/commands/install.js +55 -0
- package/src/commands/install.test.js +90 -0
- package/src/commands/list.js +26 -0
- package/src/commands/list.test.js +92 -0
- package/src/commands/remove.js +33 -0
- package/src/commands/remove.test.js +81 -0
- package/src/utils/installer.js +75 -0
- package/src/utils/installer.test.js +100 -0
- package/src/utils/lockfile.js +57 -0
- package/src/utils/lockfile.test.js +69 -0
- package/src/utils/resolver.js +151 -0
- package/src/utils/resolver.test.js +228 -0
|
@@ -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
|
+
}
|