Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions packages/skills-package-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ spm init [--yes]
Add skills to your project.

```bash
# Interactive — clone repo, discover skills, select via multiselect prompt
# Interactive — inspect repo, discover skills, select via multiselect prompt
spm add owner/repo
spm add https://github.com/owner/repo

Expand All @@ -42,11 +42,11 @@ After `spm add`, the newly added skills are resolved, materialized into `install

When given `owner/repo` or a GitHub URL:

1. Shallow-clones the repository into a temp directory
1. Reuses or creates a cached git mirror under the user cache directory
2. Scans for `SKILL.md` files (checks root, then `skills/`, `.agents/skills/`, etc.)
3. Presents an interactive multiselect prompt (powered by [@clack/prompts](https://github.com/bombshell-dev/clack))
4. Writes selected skills to `skills.json` and resolves `skills-lock.yaml`
5. Cleans up the temp directory
5. Cleans up the temporary worktree used for discovery

### `spm init`

Expand Down Expand Up @@ -86,6 +86,8 @@ spm install

This resolves each skill from its specifier, materializes it into `installDir` (default `.agents/skills/`), and creates symlinks for each `linkTarget`.

Remote git repositories, npm metadata, and npm tarballs are cached globally for reuse across commands and projects. Set `SPM_CACHE_DIR` to override the default cache location.

### `spm update`

Refresh resolvable skills declared in `skills.json` without changing the manifest:
Expand Down Expand Up @@ -118,7 +120,7 @@ await addCommand({
// Install all skills from skills.json
await installCommand({ cwd: process.cwd() })

// List skills in a GitHub repo (clone + scan)
// List skills in a GitHub repo (cached mirror + scan)
const skills = await listRepoSkills('vercel-labs', 'skills')
// => [{ name: 'find-skills', description: '...', path: '/skills/find-skills' }]
```
Expand All @@ -140,10 +142,10 @@ link: link:<path-to-skill-dir>

### Resolution Types

- **`git`** — Clones the repo, resolves commit hash, copies skill files
- **`git`** — Reuses a cached mirror, resolves a commit hash, and copies skill files from a temporary worktree
- **`link`** — Reads from a local directory and copies the selected skill
- **`file`** — Extracts a local `tgz` package and copies the selected skill
- **`npm`** — Resolves a package from the configured npm registry, locks the tarball URL/version/integrity, and installs from the downloaded tarball
- **`npm`** — Resolves a package from the configured npm registry, caches metadata and tarballs globally, and installs from the cached tarball

`npm:` reads `registry` and scoped `@scope:registry` values from `.npmrc`. Matching `:_authToken`, `:_auth`, or `username` + `:_password` entries are also used for private registry requests.

Expand All @@ -155,7 +157,8 @@ src/
├── cli/ # CLI runner and interactive prompts
├── commands/ # add, install command implementations
├── config/ # skills.json / skills-lock.yaml read/write
├── github/ # Git clone + skill discovery (listSkills)
├── cache/ # Global cache directories, locks, and git mirror helpers
├── github/ # Cached git discovery (listSkills)
├── install/ # Skill materialization, linking, pruning
├── specifiers/ # Specifier parsing and normalization
└── utils/ # Hashing, filesystem helpers
Expand All @@ -172,4 +175,3 @@ pnpm build # Builds with Rslib (ESM output + DTS)
```bash
pnpm test # Runs tests with Rstest
```
``
185 changes: 185 additions & 0 deletions packages/skills-package-manager/src/cache/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { execFile } from 'node:child_process'
import { access, mkdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
import { ErrorCode, GitError } from '../errors'
import { createCacheTempDir, getCacheKeyPath, getCachePaths, withCacheLock } from './index'

const execFileAsync = promisify(execFile)

function getGitEnv() {
return { ...process.env, GIT_TERMINAL_PROMPT: '0' }
}

function isFullCommitHash(ref: string): boolean {
return /^[0-9a-f]{40}$/i.test(ref)
}

function getRepoLockKey(repoUrl: string): string {
return `git-repo:${repoUrl}`
}

async function getMirrorPath(repoUrl: string): Promise<string> {
const cache = await getCachePaths()
return path.join(getCacheKeyPath(cache.reposDir, repoUrl), 'mirror.git')
}

async function runGit(args: string[], options?: { cwd?: string; timeout?: number }) {
return execFileAsync('git', args, {
cwd: options?.cwd,
env: getGitEnv(),
timeout: options?.timeout ?? 60_000,
})
}

async function ensureMirrorExists(repoUrl: string, mirrorPath: string): Promise<void> {
try {
await access(path.join(mirrorPath, 'HEAD'))
return
} catch {}

await mkdir(path.dirname(mirrorPath), { recursive: true })

try {
await runGit(['clone', '--mirror', repoUrl, mirrorPath])
} catch (error) {
await rm(path.dirname(mirrorPath), { recursive: true, force: true }).catch(() => {})
throw new GitError({
code: ErrorCode.GIT_CLONE_FAILED,
operation: 'clone',
repoUrl,
message: `Failed to clone repository ${repoUrl}`,
cause: error as Error,
})
}
}

async function updateMirror(repoUrl: string, mirrorPath: string): Promise<void> {
try {
await runGit(['--git-dir', mirrorPath, 'remote', 'update', '--prune', 'origin'])
} catch (error) {
throw new GitError({
code: ErrorCode.GIT_FETCH_FAILED,
operation: 'fetch',
repoUrl,
message: `Failed to fetch repository ${repoUrl}`,
cause: error as Error,
})
}
}

async function tryResolveCommit(mirrorPath: string, target: string): Promise<string | null> {
try {
const { stdout } = await runGit([
'--git-dir',
mirrorPath,
'rev-parse',
'--verify',
`${target}^{commit}`,
])
return stdout.trim().split('\n')[0]?.trim() || null
} catch {
return null
}
}

async function fetchCommit(repoUrl: string, mirrorPath: string, commit: string): Promise<void> {
try {
await runGit(['--git-dir', mirrorPath, 'fetch', 'origin', commit])
} catch (error) {
throw new GitError({
code: ErrorCode.GIT_FETCH_FAILED,
operation: 'fetch',
repoUrl,
ref: commit,
message: `Failed to fetch commit ${commit} from ${repoUrl}`,
cause: error as Error,
})
}
}

export async function resolveGitCommitFromMirror(
repoUrl: string,
ref: string | null,
): Promise<string> {
const target = ref ?? 'HEAD'

return withCacheLock(getRepoLockKey(repoUrl), async () => {
const mirrorPath = await getMirrorPath(repoUrl)
await ensureMirrorExists(repoUrl, mirrorPath)

if (isFullCommitHash(target)) {
const existingCommit = await tryResolveCommit(mirrorPath, target)
if (existingCommit) {
return existingCommit
}

await fetchCommit(repoUrl, mirrorPath, target)
} else {
await updateMirror(repoUrl, mirrorPath)
}

const resolvedCommit = await tryResolveCommit(mirrorPath, target)
if (resolvedCommit) {
return resolvedCommit
}

throw new GitError({
code: ErrorCode.GIT_REF_NOT_FOUND,
operation: 'resolve-ref',
repoUrl,
ref: target,
message: `Unable to resolve git ref "${target}" for ${repoUrl}`,
})
})
}

async function addWorktree(
mirrorPath: string,
worktreePath: string,
target: string,
): Promise<void> {
await runGit(['--git-dir', mirrorPath, 'worktree', 'add', '--detach', worktreePath, target])
}

async function removeWorktree(mirrorPath: string, worktreePath: string): Promise<void> {
try {
await runGit(['--git-dir', mirrorPath, 'worktree', 'remove', '--force', worktreePath])
} catch {}
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
}

export async function createGitWorktree(
repoUrl: string,
ref: string | null,
): Promise<{ worktreePath: string; resolvedCommit: string; cleanup: () => Promise<void> }> {
const worktreePath = await createCacheTempDir('skills-pm-git-worktree-')
const resolvedCommit = await resolveGitCommitFromMirror(repoUrl, ref)
const mirrorPath = await getMirrorPath(repoUrl)
Comment on lines +156 to +158
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createGitWorktree creates worktreePath before resolving the commit; if resolveGitCommitFromMirror() throws (e.g., network/auth/ref error), the newly created temp directory will be leaked under the cache tmp dir. Resolve the commit first (or wrap resolve in a try/catch that removes worktreePath on failure) before proceeding to add the worktree.

Suggested change
const worktreePath = await createCacheTempDir('skills-pm-git-worktree-')
const resolvedCommit = await resolveGitCommitFromMirror(repoUrl, ref)
const mirrorPath = await getMirrorPath(repoUrl)
const resolvedCommit = await resolveGitCommitFromMirror(repoUrl, ref)
const mirrorPath = await getMirrorPath(repoUrl)
const worktreePath = await createCacheTempDir('skills-pm-git-worktree-')

Copilot uses AI. Check for mistakes.

await withCacheLock(getRepoLockKey(repoUrl), async () => {
await addWorktree(mirrorPath, worktreePath, resolvedCommit)
}).catch(async (error) => {
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
throw new GitError({
code: ErrorCode.GIT_CHECKOUT_FAILED,
operation: 'checkout',
repoUrl,
ref: resolvedCommit,
message: `Failed to checkout commit ${resolvedCommit}`,
cause: error as Error,
})
})

return {
worktreePath,
resolvedCommit,
cleanup: async () => {
await withCacheLock(getRepoLockKey(repoUrl), async () => {
await removeWorktree(mirrorPath, worktreePath)
}).catch(async () => {
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
})
},
}
}
Loading