Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 4 additions & 4 deletions packages/pnpm-plugin-skills/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('preResolution', () => {
installDir: '.agents/skills',
linkTargets: ['.claude/skills'],
skills: {
'hello-skill': 'file:./skills-source#path:/skills/hello-skill',
'hello-skill': 'link:./skills-source/skills/hello-skill',
},
},
null,
Expand All @@ -36,10 +36,10 @@ describe('preResolution', () => {
' - .claude/skills',
'skills:',
' hello-skill:',
' specifier: file:./skills-source#path:/skills/hello-skill',
' specifier: link:./skills-source/skills/hello-skill',
' resolution:',
' type: file',
` path: ${JSON.stringify(path.join(root, 'skills-source'))}`,
' type: link',
` path: ${JSON.stringify(path.join(root, 'skills-source/skills/hello-skill'))}`,
' digest: test-digest',
].join('\n'),
)
Expand Down
23 changes: 15 additions & 8 deletions packages/skills-package-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ spm add owner/repo --skill find-skills

# Direct specifier — skip discovery
spm add https://github.com/owner/repo.git#path:/skills/my-skill
spm add file:./local-source#path:/skills/my-skill
spm add link:./local-source/skills/my-skill
spm add file:./skills-package.tgz#path:/skills/my-skill
spm add npm:@scope/skills-package#path:/skills/my-skill
```

After `spm add`, the newly added skills are resolved, materialized into `installDir`, and linked to each configured `linkTarget` immediately.
Expand Down Expand Up @@ -86,7 +88,7 @@ This resolves each skill from its specifier, materializes it into `installDir` (

### `spm update`

Refresh git-based skills declared in `skills.json` without changing the manifest:
Refresh resolvable skills declared in `skills.json` without changing the manifest:

```bash
spm update
Expand All @@ -96,8 +98,8 @@ spm update find-skills rspress-custom-theme
Behavior:

- Uses `skills.json` as the source of truth
- Re-resolves git refs to the latest commit
- Skips `file:` skills
- Re-resolves git refs and npm package targets
- Skips `link:` skills
- Fails immediately for unknown skill names
- Writes `skills-lock.yaml` only after fetch and link succeed

Expand All @@ -123,13 +125,14 @@ const skills = await listRepoSkills('vercel-labs', 'skills')

## Specifier Format

```
<source>#[ref&]path:<skill-path>
```text
git/file/npm: <source>#[ref&]path:<skill-path>
link: link:<path-to-skill-dir>
```

| Part | Description | Example |
|------|-------------|---------|
| `source` | Git URL or `file:` path | `https://github.com/o/r.git`, `file:./local` |
| `source` | Git URL, direct `link:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
| `ref` | Optional git ref | `main`, `v1.0.0`, `HEAD`, `6cb0992`, `6cb0992a176f2ca142e19f64dca8ac12025b035e` |
| `path` | Path to skill directory within source | `/skills/my-skill` |

Expand All @@ -138,7 +141,11 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
### Resolution Types

- **`git`** — Clones the repo, resolves commit hash, copies skill files
- **`file`** — Reads from local filesystem, computes content digest
- **`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:` reads `registry` and scoped `@scope:registry` values from `.npmrc`. Matching `:_authToken`, `:_auth`, or `username` + `:_password` entries are also used for private registry requests.

## Architecture

Expand Down
2 changes: 2 additions & 0 deletions packages/skills-package-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"@clack/prompts": "^1.1.0",
"cac": "^7.0.0",
"picocolors": "^1.1.1",
"semver": "^7.7.2",
"tar": "^7.4.3",
"yaml": "^2.8.1"
},
"devDependencies": {
Expand Down
41 changes: 35 additions & 6 deletions packages/skills-package-manager/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SkillsLock, UpdateCommandOptions, UpdateCommandResult } from '../c
import { writeSkillsLock } from '../config/writeSkillsLock'
import { ErrorCode, ManifestError, SkillError } from '../errors'
import { fetchSkillsFromLock, linkSkillsFromLock } from '../install/installSkills'
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'

function createEmptyResult(): UpdateCommandResult {
return {
Expand Down Expand Up @@ -62,18 +63,46 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<Upda
for (const skillName of targetSkills) {
const specifier = manifest.skills[skillName]

if (specifier.startsWith('file:')) {
result.skipped.push({ name: skillName, reason: 'file-specifier' })
continue
}

try {
const normalized = normalizeSpecifier(specifier)
if (normalized.type === 'link') {
result.skipped.push({ name: skillName, reason: 'link-specifier' })
continue
}

const { entry } = await resolveLockEntry(options.cwd, specifier)
const previous = currentLock?.skills[skillName]
if (
previous?.resolution.type === 'git' &&
entry.resolution.type === 'git' &&
previous.resolution.commit === entry.resolution.commit
previous.specifier === entry.specifier &&
previous.resolution.url === entry.resolution.url &&
previous.resolution.commit === entry.resolution.commit &&
previous.resolution.path === entry.resolution.path
) {
result.unchanged.push(skillName)
continue
}
Comment thread
SoonIter marked this conversation as resolved.

if (
previous?.resolution.type === 'npm' &&
entry.resolution.type === 'npm' &&
previous.specifier === entry.specifier &&
previous.resolution.packageName === entry.resolution.packageName &&
previous.resolution.version === entry.resolution.version &&
previous.resolution.path === entry.resolution.path &&
previous.resolution.tarball === entry.resolution.tarball &&
previous.resolution.integrity === entry.resolution.integrity
Comment thread
SoonIter marked this conversation as resolved.
Outdated
) {
result.unchanged.push(skillName)
continue
}
Comment thread
SoonIter marked this conversation as resolved.

if (
previous?.resolution.type === 'file' &&
entry.resolution.type === 'file' &&
previous.specifier === entry.specifier &&
previous.digest === entry.digest
) {
result.unchanged.push(skillName)
continue
Expand Down
53 changes: 48 additions & 5 deletions packages/skills-package-manager/src/config/syncSkillsLock.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import type { NormalizedSpecifier } from '../config/types'
import { ErrorCode, GitError, ParseError } from '../errors'
import { resolveNpmPackage } from '../npm/packPackage'
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
import { sha256 } from '../utils/hash'
import { sha256, sha256Directory } from '../utils/hash'
import type { InstallProgressListener, SkillsLock, SkillsLockEntry, SkillsManifest } from './types'

const execFileAsync = promisify(execFile)
Expand Down Expand Up @@ -89,17 +91,34 @@ export async function resolveLockEntry(
// Use provided skillName from manifest key, fallback to parsed skillName
const finalSkillName = skillName || normalized.skillName

if (normalized.type === 'link') {
const sourceRoot = path.resolve(cwd, normalized.source.slice('link:'.length))
return {
skillName: finalSkillName,
entry: {
specifier: normalized.normalized,
resolution: {
type: 'link',
path: path.relative(cwd, sourceRoot) || '.',
},
digest: await sha256Directory(sourceRoot),
},
}
Comment thread
SoonIter marked this conversation as resolved.
}

if (normalized.type === 'file') {
const sourceRoot = path.resolve(cwd, normalized.source.slice('file:'.length))
const tarballPath = path.resolve(cwd, normalized.source.slice('file:'.length))
const tarballContent = await readFile(tarballPath)
return {
Comment thread
SoonIter marked this conversation as resolved.
skillName: finalSkillName,
entry: {
specifier: normalized.normalized,
resolution: {
type: 'file',
path: path.relative(cwd, sourceRoot) || '.',
tarball: path.relative(cwd, tarballPath) || '.',
path: normalized.path,
},
digest: sha256(`${sourceRoot}:${normalized.path}`),
digest: sha256(Buffer.concat([tarballContent, Buffer.from(`:${normalized.path}`)])),
},
Comment thread
SoonIter marked this conversation as resolved.
}
}
Expand All @@ -121,6 +140,30 @@ export async function resolveLockEntry(
}
}

if (normalized.type === 'npm') {
const packageSpecifier = normalized.source.slice('npm:'.length)
const resolved = await resolveNpmPackage(cwd, packageSpecifier)

return {
skillName: finalSkillName,
entry: {
specifier: normalized.normalized,
resolution: {
type: 'npm',
packageName: resolved.name,
version: resolved.version,
path: normalized.path,
tarball: resolved.tarballUrl,
integrity: resolved.integrity,
registry: resolved.registry,
},
digest: sha256(
`${resolved.name}:${resolved.version}:${resolved.tarballUrl}:${normalized.path}`,
Comment thread
SoonIter marked this conversation as resolved.
Outdated
),
},
}
}

throw new ParseError({
code: ErrorCode.INVALID_SPECIFIER,
message: `Unsupported specifier type in 0.1.0 core flow: ${normalized.type}`,
Expand Down
17 changes: 13 additions & 4 deletions packages/skills-package-manager/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type SkillsManifest = {
}

export type NormalizedSpecifier = {
type: 'git' | 'file' | 'npm'
type: 'git' | 'link' | 'file' | 'npm'
source: string
ref: string | null
path: string
Expand All @@ -17,9 +17,18 @@ export type NormalizedSpecifier = {
export type SkillsLockEntry = {
specifier: string
resolution:
| { type: 'file'; path: string }
| { type: 'link'; path: string }
| { type: 'file'; tarball: string; path: string }
| { type: 'git'; url: string; commit: string; path: string }
| { type: 'npm'; packageName: string; version: string; path: string; integrity?: string }
| {
type: 'npm'
packageName: string
version: string
path: string
tarball: string
integrity?: string
registry?: string
}
digest: string
}

Expand Down Expand Up @@ -50,7 +59,7 @@ export type UpdateCommandResult = {
status: 'updated' | 'skipped' | 'failed'
updated: string[]
unchanged: string[]
skipped: Array<{ name: string; reason: 'file-specifier' }>
skipped: Array<{ name: string; reason: 'link-specifier' }>
failed: Array<{ name: string; reason: string }>
}

Expand Down
4 changes: 3 additions & 1 deletion packages/skills-package-manager/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ export function formatErrorForDisplay(error: unknown): string {
output += `\nExpected formats:`
output += `\n - owner/repo (GitHub shorthand)`
output += `\n - https://github.com/owner/repo.git`
output += `\n - file:./path/to/skill`
output += `\n - link:./path/to/skill-dir`
output += `\n - file:./path/to/skill-package.tgz#path:/skills/my-skill`
output += `\n - npm:@scope/skill-package#path:/skills/my-skill`
}
} else if (error instanceof ManifestError) {
if (error.code === ErrorCode.LOCKFILE_OUTDATED) {
Expand Down
Loading