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.
- package/README.md +105 -0
- package/dist/113.js +355 -0
- package/dist/bin/skills-pm.js +6 -0
- package/dist/bin/skills.js +6 -0
- package/dist/index.js +1 -0
- package/dist/src/bin/skills-pm.d.ts +2 -0
- package/dist/src/bin/skills.d.ts +2 -0
- package/dist/src/cli/runCli.d.ts +16 -0
- package/dist/src/commands/add.d.ts +5 -0
- package/dist/src/commands/install.d.ts +15 -0
- package/dist/src/config/readSkillsLock.d.ts +2 -0
- package/dist/src/config/readSkillsManifest.d.ts +2 -0
- package/dist/src/config/syncSkillsLock.d.ts +2 -0
- package/dist/src/config/types.d.ts +43 -0
- package/dist/src/config/writeSkillsLock.d.ts +2 -0
- package/dist/src/config/writeSkillsManifest.d.ts +2 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/install/installSkills.d.ts +13 -0
- package/dist/src/install/installState.d.ts +2 -0
- package/dist/src/install/links.d.ts +1 -0
- package/dist/src/install/materializeGitSkill.d.ts +1 -0
- package/dist/src/install/materializeLocalSkill.d.ts +1 -0
- package/dist/src/install/pruneManagedSkills.d.ts +1 -0
- package/dist/src/specifiers/normalizeSpecifier.d.ts +2 -0
- package/dist/src/specifiers/parseSpecifier.d.ts +5 -0
- package/dist/src/utils/fs.d.ts +4 -0
- package/dist/src/utils/hash.d.ts +1 -0
- package/dist/test/add.test.d.ts +1 -0
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/manifest.test.d.ts +1 -0
- package/dist/test/specifiers.test.d.ts +1 -0
- package/package.json +25 -0
- package/rslib.config.ts +21 -0
- package/src/bin/skills-pm.ts +7 -0
- package/src/bin/skills.ts +7 -0
- package/src/cli/prompt.ts +36 -0
- package/src/cli/runCli.ts +45 -0
- package/src/commands/add.ts +110 -0
- package/src/commands/install.ts +5 -0
- package/src/config/readSkillsLock.ts +18 -0
- package/src/config/readSkillsManifest.ts +22 -0
- package/src/config/syncSkillsLock.ts +75 -0
- package/src/config/types.ts +37 -0
- package/src/config/writeSkillsLock.ts +9 -0
- package/src/config/writeSkillsManifest.ts +14 -0
- package/src/github/listSkills.ts +170 -0
- package/src/github/types.ts +5 -0
- package/src/index.ts +5 -0
- package/src/install/installSkills.ts +78 -0
- package/src/install/installState.ts +20 -0
- package/src/install/links.ts +9 -0
- package/src/install/materializeGitSkill.ts +33 -0
- package/src/install/materializeLocalSkill.ts +35 -0
- package/src/install/pruneManagedSkills.ts +50 -0
- package/src/specifiers/normalizeSpecifier.ts +29 -0
- package/src/specifiers/parseSpecifier.ts +45 -0
- package/src/utils/fs.ts +19 -0
- package/src/utils/hash.ts +5 -0
- package/test/add.test.ts +75 -0
- package/test/fixtures/local-source/skills/hello-skill/SKILL.md +3 -0
- package/test/fixtures/local-source/skills/hello-skill/references/example.md +1 -0
- package/test/github.test.ts +120 -0
- package/test/install.test.ts +169 -0
- package/test/manifest.test.ts +19 -0
- package/test/specifiers.test.ts +43 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skills-package-manager",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"skills-pm": "dist/bin/skills-pm.js",
|
|
7
|
+
"skills": "dist/bin/skills.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@clack/prompts": "^1.1.0",
|
|
14
|
+
"picocolors": "^1.1.1",
|
|
15
|
+
"yaml": "^2.8.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@rslib/core": "^0.20.0",
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"typescript": "^5.8.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "rslib build"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/rslib.config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from '@rslib/core'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
lib: [
|
|
5
|
+
{
|
|
6
|
+
format: 'esm',
|
|
7
|
+
dts: true,
|
|
8
|
+
source: {
|
|
9
|
+
entry: {
|
|
10
|
+
index: './src/index.ts',
|
|
11
|
+
'bin/skills-pm': './src/bin/skills-pm.ts',
|
|
12
|
+
'bin/skills': './src/bin/skills.ts',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
output: {
|
|
16
|
+
target: 'node',
|
|
17
|
+
cleanDistPath: true,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as p from '@clack/prompts'
|
|
2
|
+
import type { SkillInfo } from '../github/types'
|
|
3
|
+
|
|
4
|
+
export async function promptSkillSelection(skills: SkillInfo[]): Promise<SkillInfo[]> {
|
|
5
|
+
if (skills.length === 0) {
|
|
6
|
+
throw new Error('No skills found in repository')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Single skill — auto-select
|
|
10
|
+
if (skills.length === 1) {
|
|
11
|
+
return skills
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const options = skills.map((skill) => ({
|
|
15
|
+
value: skill,
|
|
16
|
+
label: skill.name,
|
|
17
|
+
hint: skill.description
|
|
18
|
+
? skill.description.length > 60
|
|
19
|
+
? `${skill.description.slice(0, 57)}...`
|
|
20
|
+
: skill.description
|
|
21
|
+
: undefined,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const selected = await p.multiselect({
|
|
25
|
+
message: 'Select skills to install',
|
|
26
|
+
options,
|
|
27
|
+
required: true,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
if (p.isCancel(selected)) {
|
|
31
|
+
p.cancel('Installation cancelled')
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return selected as SkillInfo[]
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { addCommand } from '../commands/add'
|
|
2
|
+
import { installCommand } from '../commands/install'
|
|
3
|
+
|
|
4
|
+
function parseArgs(args: string[]): { positionals: string[]; flags: Record<string, string> } {
|
|
5
|
+
const positionals: string[] = []
|
|
6
|
+
const flags: Record<string, string> = {}
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i]
|
|
10
|
+
if (arg.startsWith('--')) {
|
|
11
|
+
const key = arg.slice(2)
|
|
12
|
+
const next = args[i + 1]
|
|
13
|
+
if (next && !next.startsWith('--')) {
|
|
14
|
+
flags[key] = next
|
|
15
|
+
i++
|
|
16
|
+
} else {
|
|
17
|
+
flags[key] = 'true'
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
positionals.push(arg)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { positionals, flags }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runCli(argv: string[]) {
|
|
28
|
+
const [, , command, ...rest] = argv
|
|
29
|
+
const cwd = process.cwd()
|
|
30
|
+
|
|
31
|
+
if (command === 'add') {
|
|
32
|
+
const { positionals, flags } = parseArgs(rest)
|
|
33
|
+
const specifier = positionals[0]
|
|
34
|
+
if (!specifier) {
|
|
35
|
+
throw new Error('Missing required specifier')
|
|
36
|
+
}
|
|
37
|
+
return addCommand({ cwd, specifier, skill: flags.skill })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (command === 'install') {
|
|
41
|
+
return installCommand({ cwd })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error(`Unknown command: ${command}`)
|
|
45
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as p from '@clack/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { readSkillsLock } from '../config/readSkillsLock'
|
|
4
|
+
import { readSkillsManifest } from '../config/readSkillsManifest'
|
|
5
|
+
import { syncSkillsLock } from '../config/syncSkillsLock'
|
|
6
|
+
import { writeSkillsLock } from '../config/writeSkillsLock'
|
|
7
|
+
import { writeSkillsManifest } from '../config/writeSkillsManifest'
|
|
8
|
+
import type { AddCommandOptions } from '../config/types'
|
|
9
|
+
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
|
|
10
|
+
import { listRepoSkills, parseOwnerRepo, parseGitHubUrl } from '../github/listSkills'
|
|
11
|
+
import { promptSkillSelection } from '../cli/prompt'
|
|
12
|
+
|
|
13
|
+
function isProtocolSpecifier(specifier: string): boolean {
|
|
14
|
+
return /^[a-z]+:/.test(specifier)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildGitHubSpecifier(owner: string, repo: string, skillPath: string): string {
|
|
18
|
+
return `https://github.com/${owner}/${repo}.git#path:${skillPath}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function addSingleSkill(
|
|
22
|
+
cwd: string,
|
|
23
|
+
specifier: string,
|
|
24
|
+
): Promise<{ skillName: string; specifier: string }> {
|
|
25
|
+
const normalized = normalizeSpecifier(specifier)
|
|
26
|
+
const existingManifest = (await readSkillsManifest(cwd)) ?? {
|
|
27
|
+
installDir: '.agents/skills',
|
|
28
|
+
linkTargets: [],
|
|
29
|
+
skills: {},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const existing = existingManifest.skills[normalized.skillName]
|
|
33
|
+
if (existing && existing !== normalized.normalized) {
|
|
34
|
+
throw new Error(`Skill ${normalized.skillName} already exists with a different specifier`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
existingManifest.skills[normalized.skillName] = normalized.normalized
|
|
38
|
+
await writeSkillsManifest(cwd, existingManifest)
|
|
39
|
+
|
|
40
|
+
const existingLock = await readSkillsLock(cwd)
|
|
41
|
+
const lockfile = await syncSkillsLock(cwd, existingManifest, existingLock)
|
|
42
|
+
await writeSkillsLock(cwd, lockfile)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
skillName: normalized.skillName,
|
|
46
|
+
specifier: normalized.normalized,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function addCommand(options: AddCommandOptions) {
|
|
51
|
+
const { cwd, specifier, skill } = options
|
|
52
|
+
|
|
53
|
+
// Try owner/repo shorthand first
|
|
54
|
+
const shorthand = parseOwnerRepo(specifier)
|
|
55
|
+
// Try GitHub URL (https://github.com/owner/repo)
|
|
56
|
+
const githubUrl = !shorthand ? parseGitHubUrl(specifier) : null
|
|
57
|
+
const parsed = shorthand ?? githubUrl
|
|
58
|
+
|
|
59
|
+
if (parsed) {
|
|
60
|
+
const { owner, repo } = parsed
|
|
61
|
+
const source = `${owner}/${repo}`
|
|
62
|
+
|
|
63
|
+
p.intro(pc.bgCyan(pc.black(' skills-pm ')))
|
|
64
|
+
|
|
65
|
+
const spinner = p.spinner()
|
|
66
|
+
|
|
67
|
+
// --skill flag provided — non-interactive, need to discover path
|
|
68
|
+
if (skill) {
|
|
69
|
+
spinner.start(`Cloning ${source}...`)
|
|
70
|
+
const skills = await listRepoSkills(owner, repo)
|
|
71
|
+
spinner.stop(`Found ${pc.green(String(skills.length))} skill${skills.length !== 1 ? 's' : ''}`)
|
|
72
|
+
|
|
73
|
+
const found = skills.find((s) => s.name === skill)
|
|
74
|
+
const skillPath = found?.path ?? `/${skill}`
|
|
75
|
+
const gitSpecifier = buildGitHubSpecifier(owner, repo, skillPath)
|
|
76
|
+
const result = await addSingleSkill(cwd, gitSpecifier)
|
|
77
|
+
p.outro(`Added ${pc.cyan(result.skillName)}`)
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Interactive — clone, discover, prompt
|
|
82
|
+
spinner.start(`Cloning ${source}...`)
|
|
83
|
+
const skills = await listRepoSkills(owner, repo)
|
|
84
|
+
|
|
85
|
+
if (skills.length === 0) {
|
|
86
|
+
spinner.stop(pc.red('No skills found'))
|
|
87
|
+
p.outro(pc.red(`No valid skills found in ${source}`))
|
|
88
|
+
throw new Error(`No skills found in ${source}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
spinner.stop(`Found ${pc.green(String(skills.length))} skill${skills.length !== 1 ? 's' : ''}`)
|
|
92
|
+
|
|
93
|
+
const selected = await promptSkillSelection(skills)
|
|
94
|
+
const results: { skillName: string; specifier: string }[] = []
|
|
95
|
+
|
|
96
|
+
for (const s of selected) {
|
|
97
|
+
const gitSpecifier = buildGitHubSpecifier(owner, repo, s.path)
|
|
98
|
+
const result = await addSingleSkill(cwd, gitSpecifier)
|
|
99
|
+
results.push(result)
|
|
100
|
+
p.log.success(`Added ${pc.cyan(result.skillName)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
p.outro('Done')
|
|
104
|
+
return results.length === 1 ? results[0] : results
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Protocol specifier (file:, npm:, git URL with fragment, etc.) — direct add
|
|
108
|
+
return addSingleSkill(cwd, specifier)
|
|
109
|
+
}
|
|
110
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import YAML from 'yaml'
|
|
4
|
+
import type { SkillsLock } from './types'
|
|
5
|
+
|
|
6
|
+
export async function readSkillsLock(rootDir: string): Promise<SkillsLock | null> {
|
|
7
|
+
const filePath = path.join(rootDir, 'skills-lock.yaml')
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(filePath, 'utf8')
|
|
11
|
+
return YAML.parse(raw) as SkillsLock
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { SkillsManifest } from './types'
|
|
4
|
+
|
|
5
|
+
export async function readSkillsManifest(rootDir: string): Promise<SkillsManifest | null> {
|
|
6
|
+
const filePath = path.join(rootDir, 'skills.json')
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(filePath, 'utf8')
|
|
10
|
+
const json = JSON.parse(raw) as SkillsManifest
|
|
11
|
+
return {
|
|
12
|
+
installDir: json.installDir ?? '.agents/skills',
|
|
13
|
+
linkTargets: json.linkTargets ?? [],
|
|
14
|
+
skills: json.skills ?? {},
|
|
15
|
+
}
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SkillsLock, SkillsLockEntry, SkillsManifest } from './types'
|
|
2
|
+
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
|
|
3
|
+
import { sha256 } from '../utils/hash'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
|
|
10
|
+
async function resolveGitCommit(url: string, ref: string | null): Promise<string> {
|
|
11
|
+
const target = ref ?? 'HEAD'
|
|
12
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', url, target])
|
|
13
|
+
const line = stdout.trim().split('\n')[0]
|
|
14
|
+
const commit = line?.split('\t')[0]?.trim()
|
|
15
|
+
|
|
16
|
+
if (!commit) {
|
|
17
|
+
throw new Error(`Unable to resolve git ref ${target} for ${url}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return commit
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function createLockEntry(cwd: string, specifier: string): Promise<{ skillName: string; entry: SkillsLockEntry }> {
|
|
24
|
+
const normalized = normalizeSpecifier(specifier)
|
|
25
|
+
|
|
26
|
+
if (normalized.type === 'file') {
|
|
27
|
+
const sourceRoot = path.resolve(cwd, normalized.source.slice('file:'.length))
|
|
28
|
+
return {
|
|
29
|
+
skillName: normalized.skillName,
|
|
30
|
+
entry: {
|
|
31
|
+
specifier: normalized.normalized,
|
|
32
|
+
resolution: {
|
|
33
|
+
type: 'file',
|
|
34
|
+
path: sourceRoot,
|
|
35
|
+
},
|
|
36
|
+
digest: sha256(`${sourceRoot}:${normalized.path}`),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalized.type === 'git') {
|
|
42
|
+
const commit = await resolveGitCommit(normalized.source, normalized.ref)
|
|
43
|
+
return {
|
|
44
|
+
skillName: normalized.skillName,
|
|
45
|
+
entry: {
|
|
46
|
+
specifier: normalized.normalized,
|
|
47
|
+
resolution: {
|
|
48
|
+
type: 'git',
|
|
49
|
+
url: normalized.source,
|
|
50
|
+
commit,
|
|
51
|
+
path: normalized.path,
|
|
52
|
+
},
|
|
53
|
+
digest: sha256(`${normalized.source}:${commit}:${normalized.path}`),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(`Unsupported specifier type in 0.1.0 core flow: ${normalized.type}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function syncSkillsLock(cwd: string, manifest: SkillsManifest, existingLock: SkillsLock | null): Promise<SkillsLock> {
|
|
62
|
+
const nextSkills: Record<string, SkillsLockEntry> = {}
|
|
63
|
+
|
|
64
|
+
for (const specifier of Object.values(manifest.skills)) {
|
|
65
|
+
const { skillName, entry } = await createLockEntry(cwd, specifier)
|
|
66
|
+
nextSkills[skillName] = entry
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
lockfileVersion: '0.1',
|
|
71
|
+
installDir: manifest.installDir ?? '.agents/skills',
|
|
72
|
+
linkTargets: manifest.linkTargets ?? [],
|
|
73
|
+
skills: nextSkills,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type SkillsManifest = {
|
|
2
|
+
$schema?: string
|
|
3
|
+
installDir?: string
|
|
4
|
+
linkTargets?: string[]
|
|
5
|
+
skills: Record<string, string>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type NormalizedSpecifier = {
|
|
9
|
+
type: 'git' | 'file' | 'npm'
|
|
10
|
+
source: string
|
|
11
|
+
ref: string | null
|
|
12
|
+
path: string
|
|
13
|
+
normalized: string
|
|
14
|
+
skillName: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SkillsLockEntry = {
|
|
18
|
+
specifier: string
|
|
19
|
+
resolution:
|
|
20
|
+
| { type: 'file'; path: string }
|
|
21
|
+
| { type: 'git'; url: string; commit: string; path: string }
|
|
22
|
+
| { type: 'npm'; packageName: string; version: string; path: string; integrity?: string }
|
|
23
|
+
digest: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SkillsLock = {
|
|
27
|
+
lockfileVersion: '0.1'
|
|
28
|
+
installDir: string
|
|
29
|
+
linkTargets: string[]
|
|
30
|
+
skills: Record<string, SkillsLockEntry>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AddCommandOptions = {
|
|
34
|
+
cwd: string
|
|
35
|
+
specifier: string
|
|
36
|
+
skill?: string
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import YAML from 'yaml'
|
|
4
|
+
import type { SkillsLock } from './types'
|
|
5
|
+
|
|
6
|
+
export async function writeSkillsLock(rootDir: string, lockfile: SkillsLock): Promise<void> {
|
|
7
|
+
const filePath = path.join(rootDir, 'skills-lock.yaml')
|
|
8
|
+
await writeFile(filePath, YAML.stringify(lockfile), 'utf8')
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { SkillsManifest } from './types'
|
|
4
|
+
|
|
5
|
+
export async function writeSkillsManifest(rootDir: string, manifest: SkillsManifest): Promise<void> {
|
|
6
|
+
const filePath = path.join(rootDir, 'skills.json')
|
|
7
|
+
const nextManifest = {
|
|
8
|
+
installDir: manifest.installDir ?? '.agents/skills',
|
|
9
|
+
linkTargets: manifest.linkTargets ?? [],
|
|
10
|
+
skills: manifest.skills,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
await writeFile(filePath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8')
|
|
14
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readdir, readFile, rm, stat, mkdtemp } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import type { SkillInfo } from './types'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
|
|
10
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__'])
|
|
11
|
+
|
|
12
|
+
function parseSkillFrontmatter(content: string): { name: string; description: string } {
|
|
13
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
14
|
+
if (!fmMatch) {
|
|
15
|
+
return { name: '', description: '' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fm = fmMatch[1]
|
|
19
|
+
const nameMatch = fm.match(/^name:\s*(.+)$/m)
|
|
20
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m)
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
name: nameMatch?.[1]?.trim() ?? '',
|
|
24
|
+
description: descMatch?.[1]?.trim() ?? '',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hasSkillMd(dir: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const s = await stat(join(dir, 'SKILL.md'))
|
|
31
|
+
return s.isFile()
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function parseSkillDir(dir: string, relativePath: string): Promise<SkillInfo | null> {
|
|
38
|
+
try {
|
|
39
|
+
const content = await readFile(join(dir, 'SKILL.md'), 'utf8')
|
|
40
|
+
const meta = parseSkillFrontmatter(content)
|
|
41
|
+
const dirName = dir.split('/').pop() ?? ''
|
|
42
|
+
return {
|
|
43
|
+
name: meta.name || dirName,
|
|
44
|
+
description: meta.description,
|
|
45
|
+
path: relativePath,
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scan a directory for subdirs containing SKILL.md.
|
|
54
|
+
* Returns skills found in that directory level.
|
|
55
|
+
*/
|
|
56
|
+
async function scanForSkills(baseDir: string, subDir: string): Promise<SkillInfo[]> {
|
|
57
|
+
const searchDir = subDir ? join(baseDir, subDir) : baseDir
|
|
58
|
+
const skills: SkillInfo[] = []
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const entries = await readdir(searchDir, { withFileTypes: true })
|
|
62
|
+
const dirs = entries.filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name))
|
|
63
|
+
|
|
64
|
+
const checks = dirs.map(async (entry) => {
|
|
65
|
+
const fullPath = join(searchDir, entry.name)
|
|
66
|
+
if (await hasSkillMd(fullPath)) {
|
|
67
|
+
const relativePath = subDir ? `/${subDir}/${entry.name}` : `/${entry.name}`
|
|
68
|
+
return parseSkillDir(fullPath, relativePath)
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const results = await Promise.all(checks)
|
|
74
|
+
for (const r of results) {
|
|
75
|
+
if (r) skills.push(r)
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// directory doesn't exist
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return skills
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clone a git repo (shallow) into a temp dir, discover skills, then clean up.
|
|
86
|
+
*/
|
|
87
|
+
export async function cloneAndDiscover(gitUrl: string, ref?: string): Promise<{ skills: SkillInfo[]; cleanup: () => Promise<void> }> {
|
|
88
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'skills-pm-discover-'))
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const cloneArgs = ref
|
|
92
|
+
? ['clone', '--depth', '1', '--branch', ref, gitUrl, tempDir]
|
|
93
|
+
: ['clone', '--depth', '1', gitUrl, tempDir]
|
|
94
|
+
|
|
95
|
+
await execFileAsync('git', cloneArgs, {
|
|
96
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
97
|
+
timeout: 60_000,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const skills = await discoverSkillsInDir(tempDir)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
skills,
|
|
104
|
+
cleanup: async () => {
|
|
105
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
110
|
+
throw error
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Discover skills in a local directory by scanning for SKILL.md files.
|
|
116
|
+
* Checks root-level dirs first, then common subdirs (skills/, .agents/skills/, etc.)
|
|
117
|
+
*/
|
|
118
|
+
export async function discoverSkillsInDir(baseDir: string): Promise<SkillInfo[]> {
|
|
119
|
+
// Scan root directory first
|
|
120
|
+
const rootSkills = await scanForSkills(baseDir, '')
|
|
121
|
+
if (rootSkills.length > 0) {
|
|
122
|
+
rootSkills.sort((a, b) => a.name.localeCompare(b.name))
|
|
123
|
+
return rootSkills
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Try common skill directories
|
|
127
|
+
const commonDirs = [
|
|
128
|
+
'skills',
|
|
129
|
+
'.agents/skills',
|
|
130
|
+
'.claude/skills',
|
|
131
|
+
'.github/skills',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
for (const dir of commonDirs) {
|
|
135
|
+
const skills = await scanForSkills(baseDir, dir)
|
|
136
|
+
if (skills.length > 0) {
|
|
137
|
+
skills.sort((a, b) => a.name.localeCompare(b.name))
|
|
138
|
+
return skills
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return []
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List skills in a GitHub repo by cloning and scanning.
|
|
147
|
+
* This avoids GitHub API rate limits.
|
|
148
|
+
*/
|
|
149
|
+
export async function listRepoSkills(owner: string, repo: string, ref?: string): Promise<SkillInfo[]> {
|
|
150
|
+
const gitUrl = `https://github.com/${owner}/${repo}.git`
|
|
151
|
+
const { skills, cleanup } = await cloneAndDiscover(gitUrl, ref)
|
|
152
|
+
await cleanup()
|
|
153
|
+
return skills
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseOwnerRepo(input: string): { owner: string; repo: string } | null {
|
|
157
|
+
const match = input.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/)
|
|
158
|
+
if (!match) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
return { owner: match[1], repo: match[2] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseGitHubUrl(input: string): { owner: string; repo: string } | null {
|
|
165
|
+
const match = input.match(/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/)
|
|
166
|
+
if (!match) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
return { owner: match[1], repo: match[2] }
|
|
170
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { addCommand } from './commands/add'
|
|
2
|
+
export { installCommand } from './commands/install'
|
|
3
|
+
export { runCli } from './cli/runCli'
|
|
4
|
+
export { listRepoSkills, cloneAndDiscover, discoverSkillsInDir, parseOwnerRepo, parseGitHubUrl } from './github/listSkills'
|
|
5
|
+
export type { SkillInfo } from './github/types'
|