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
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
+ }
@@ -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,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../cli/runCli'
3
+
4
+ runCli(process.argv).catch((error) => {
5
+ console.error(error instanceof Error ? error.message : error)
6
+ process.exitCode = 1
7
+ })
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../cli/runCli'
3
+
4
+ runCli(process.argv).catch((error) => {
5
+ console.error(error instanceof Error ? error.message : error)
6
+ process.exitCode = 1
7
+ })
@@ -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,5 @@
1
+ import { installSkills } from '../install/installSkills'
2
+
3
+ export async function installCommand(options: { cwd: string }) {
4
+ return installSkills(options.cwd)
5
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export type SkillInfo = {
2
+ name: string
3
+ description: string
4
+ path: string
5
+ }
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'