Skip to content
Merged
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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ npx skills-package-manager add vercel-labs/skills --skill find-skills
# 🔗 Full GitHub URL
npx skills-package-manager add https://github.com/rstackjs/agent-skills --skill rspress-custom-theme

# 📁 Local skill
npx skills-package-manager add file:./my-skills#path:/skills/my-skill
# 📁 Local skill directory
npx skills-package-manager add link:./my-skills/my-skill
```

### Install all skills
Expand Down Expand Up @@ -116,17 +116,20 @@ Declares which skills to install and where to put them:
{
"installDir": ".agents/skills",
"linkTargets": [".claude/skills"],
"selfSkill": false,
"skills": {
// GitHub skill with path
"find-skills": "https://github.com/vercel-labs/skills.git#path:/skills/find-skills",
// Local skill
"my-local-skill": "file:./local-source#path:/skills/my-local-skill",
// Local skill directory
"my-local-skill": "link:./local-source/skills/my-local-skill",
// Short form — uses repo root
"create-ex": "https://github.com/therealXiaomanChu/ex-skill.git"
}
}
```

When `selfSkill` is `true`, skills-package-manager also installs its bundled `skills-package-manager-cli` skill so users get guidance for `skills.json`, `skills-lock.yaml`, and the `spm` workflow. This helper skill is not written to `skills-lock.yaml`.

### `skills-lock.yaml` — Lockfile

Locks resolved versions for reproducible installs:
Expand Down Expand Up @@ -155,7 +158,8 @@ skills:
| GitHub URL | `https://github.com/owner/repo` | `https://github.com/vercel-labs/skills` |
| Git + path | `url.git#path:/skills/name` | `https://github.com/owner/repo.git#path:/skills/my-skill` |
| Git + ref + path | `url.git#ref&path:/skills/name` | `https://github.com/owner/repo.git#main&path:/skills/my-skill` |
| Local file | `file:./path#path:/skills/name` | `file:./local-source#path:/skills/my-skill` |
| Local link | `link:./path/to/skill-dir` | `link:./local-source/skills/my-skill` |
| Local tarball | `file:./skills-package.tgz#path:/skills/name` | `file:./skills-package.tgz#path:/skills/my-skill` |

## 🔌 pnpm Integration

Expand Down
4 changes: 3 additions & 1 deletion packages/skills-package-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Default `skills.json` written by `spm init --yes`:
{
"installDir": ".agents/skills",
"linkTargets": [],
"selfSkill": false,
"skills": {}
}
```
Expand All @@ -85,6 +86,7 @@ spm install
```

This resolves each skill from its specifier, materializes it into `installDir` (default `.agents/skills/`), and creates symlinks for each `linkTarget`.
When `selfSkill` is `true`, `spm install` also installs the bundled `skills-package-manager-cli` skill so users get guidance for `skills.json`, `skills-lock.yaml`, and the `spm` workflow. This helper skill is not written to `skills-lock.yaml`.

### `spm update`

Expand All @@ -99,7 +101,7 @@ Behavior:

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

Expand Down
3 changes: 2 additions & 1 deletion packages/skills-package-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
},
"files": [
"bin",
"dist"
"dist",
"skills"
],
"dependencies": {
"@clack/prompts": "^1.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
name: skills-package-manager-cli
description: Help users work in repositories that use skills-package-manager. Use when requests mention `skills.json`, `skills-lock.yaml`, `selfSkill`, `npx skills-package-manager init`, `add`, `install`, `update`, skill specifiers, install directories like `.agents/skills`, or linked skill directories like `.claude/skills`
---

# skills-package-manager

Use this skill for repositories that already use `skills-package-manager`, or when a user needs help understanding and editing its manifest, lockfile, and CLI workflow.

## Core Model

- `skills.json` is the source of truth.
It declares which skills a repo wants, where to materialize them, where to link them, and whether to include the bundled helper skill.
- `skills-lock.yaml` is resolved state.
It pins commits, versions, paths, and digests so installs are reproducible.
- Installed directories such as `.agents/skills` and linked directories such as `.claude/skills` are outputs.
They are produced from the manifest and lockfile; they are not the canonical config.

## What `selfSkill` Means

- `selfSkill: true` adds the bundled `skills-package-manager-cli` skill during install.
- It is meant to help users who see `skills.json`, `skills-lock.yaml`, and `spm` commands but do not yet know how they fit together.
- The bundled skill is injected automatically. It should not be added manually under `skills` unless there is a very specific reason.

## Command Guide

1. `npx skills-package-manager init`
- Creates `skills.json`.
- `npx skills-package-manager init --yes` writes the default manifest immediately.

2. `npx skills-package-manager add <specifier> [--skill <name>]`
- Adds a skill to `skills.json`.
- Resolves it into `skills-lock.yaml`.
- Installs it into `installDir` and links it into each `linkTarget`.

3. `npx skills-package-manager install`
- Reconciles the manifest, lockfile, and installed output.
- Without `--frozen-lockfile`, it updates `skills-lock.yaml` when needed.
- With `--frozen-lockfile`, it requires the lockfile to already match the manifest.

4. `npx skills-package-manager update [skill...]`
- Refreshes resolvable entries in `skills-lock.yaml`.
- Skips `link:` skills, including the bundled `skills-package-manager-cli` self skill.

## How To Triage User Questions

1. If the user wants to change which skills a repo uses:
Edit `skills.json`, then run `npx skills-package-manager install`.

2. If the user wants to understand pinned versions or why a change happened:
Inspect `skills-lock.yaml`.

3. If the user says a skill is missing in their agent:
Check `installDir`, `linkTargets`, generated skill directories, and symlinks.

4. If the user is confused about `selfSkill`:
Explain that it enables the bundled `skills-package-manager-cli` helper skill, not an arbitrary repo-local skill.

## Specifier Reminders

- `link:./path/to/skill-dir` points to a local skill directory.
- `file:./pkg.tgz#path:/skills/name` points to a packaged tarball plus skill path.
- `npm:@scope/pkg#path:/skills/name` resolves a package from the configured registry.
- GitHub shorthand or Git URLs resolve remote repositories and may need `--skill` when multiple skills are available.

## Validation Checklist

- Keep `manifest`, `lockfile`, `installDir`, `linkTargets`, `skills`, and `specifier` terminology exact.
- Treat `skills-lock.yaml` as generated state unless the task is specifically about lockfile internals or checked-in examples.
- If you change this bundled skill inside the `skills-package-manager` repo, revalidate the skill folder and update any checked-in lockfile digest that refers to it.
2 changes: 2 additions & 0 deletions packages/skills-package-manager/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function createDefaultManifest(): SkillsManifest {
return {
installDir: '.agents/skills',
linkTargets: [],
selfSkill: false,
skills: {},
}
}
Expand All @@ -54,6 +55,7 @@ export async function initCommand(
? createDefaultManifest()
: {
...(await promptInit()),
selfSkill: false,
skills: {},
}

Expand Down
51 changes: 26 additions & 25 deletions packages/skills-package-manager/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { isLockInSync } from '../config/compareSkillsLock'
import { readSkillsLock } from '../config/readSkillsLock'
import { readSkillsManifest } from '../config/readSkillsManifest'
import { syncSkillsLock } from '../config/syncSkillsLock'
import type { InstallCommandOptions } from '../config/types'
import type { InstallCommandOptions, SkillsLock } from '../config/types'
import { writeSkillsLock } from '../config/writeSkillsLock'
import { ErrorCode, ManifestError } from '../errors'
import { fetchSkillsFromLock, linkSkillsFromLock } from '../install/installSkills'
import {
fetchSkillsFromLock,
linkSkillsFromLock,
withBundledSelfSkillLock,
} from '../install/installSkills'
import { createInstallProgressReporter } from '../install/progressReporter'

export async function installCommand(options: InstallCommandOptions) {
Expand All @@ -19,13 +23,14 @@ export async function installCommand(options: InstallCommandOptions) {
}

const currentLock = await readSkillsLock(options.cwd)
const totalSkills = Object.keys(manifest.skills).length
const reporter = createInstallProgressReporter()
const onProgress = (event: Parameters<typeof reporter.onProgress>[0]) =>
reporter.onProgress(event)
let started = false

try {
let lockfile: SkillsLock

if (options.frozenLockfile) {
// Frozen mode: lock must exist and be in sync
if (!currentLock) {
Expand All @@ -44,39 +49,35 @@ export async function installCommand(options: InstallCommandOptions) {
'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.',
})
}

reporter.start(totalSkills)
started = true
for (const skillName of Object.keys(currentLock.skills)) {
onProgress({ type: 'resolved', skillName })
}

reporter.setPhase('fetching')
await fetchSkillsFromLock(options.cwd, manifest, currentLock, { onProgress })
reporter.setPhase('linking')
await linkSkillsFromLock(options.cwd, manifest, currentLock, { onProgress })
reporter.setPhase('finalizing')
reporter.complete()

return { status: 'installed', installed: Object.keys(currentLock.skills) } as const
lockfile = currentLock
} else {
// Normal mode: sync lock with manifest (may trigger network requests)
lockfile = await syncSkillsLock(options.cwd, manifest, currentLock, {
onProgress,
})
}

// Normal mode: sync lock with manifest (may trigger network requests)
reporter.start(totalSkills)
const runtimeLock = await withBundledSelfSkillLock(options.cwd, manifest, lockfile)

reporter.start(Object.keys(runtimeLock.skills).length)
started = true
const lockfile = await syncSkillsLock(options.cwd, manifest, currentLock, { onProgress })
for (const skillName of Object.keys(lockfile.skills)) {
onProgress({ type: 'resolved', skillName })
}

reporter.setPhase('fetching')
await fetchSkillsFromLock(options.cwd, manifest, lockfile, { onProgress })
await fetchSkillsFromLock(options.cwd, manifest, runtimeLock, { onProgress })
reporter.setPhase('linking')
await linkSkillsFromLock(options.cwd, manifest, lockfile, { onProgress })
await linkSkillsFromLock(options.cwd, manifest, runtimeLock, { onProgress })

// Write lockfile only after all operations succeed (atomicity)
reporter.setPhase('finalizing')
await writeSkillsLock(options.cwd, lockfile)
if (!options.frozenLockfile) {
await writeSkillsLock(options.cwd, lockfile)
}
reporter.complete()

return { status: 'installed', installed: Object.keys(lockfile.skills) } as const
return { status: 'installed', installed: Object.keys(runtimeLock.skills) } as const
} catch (error) {
if (started) {
reporter.fail()
Expand Down
12 changes: 9 additions & 3 deletions packages/skills-package-manager/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { resolveLockEntry } from '../config/syncSkillsLock'
import type { SkillsLock, UpdateCommandOptions, UpdateCommandResult } from '../config/types'
import { writeSkillsLock } from '../config/writeSkillsLock'
import { ErrorCode, ManifestError, SkillError } from '../errors'
import { fetchSkillsFromLock, linkSkillsFromLock } from '../install/installSkills'
import {
fetchSkillsFromLock,
linkSkillsFromLock,
withBundledSelfSkillLock,
} from '../install/installSkills'
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'

function createEmptyResult(): UpdateCommandResult {
Expand Down Expand Up @@ -121,8 +125,10 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<Upda
return result
}

await fetchSkillsFromLock(options.cwd, manifest, candidateLock)
await linkSkillsFromLock(options.cwd, manifest, candidateLock)
const runtimeLock = await withBundledSelfSkillLock(options.cwd, manifest, candidateLock)

await fetchSkillsFromLock(options.cwd, manifest, runtimeLock)
await linkSkillsFromLock(options.cwd, manifest, runtimeLock)
await writeSkillsLock(options.cwd, candidateLock)

result.status = result.updated.length > 0 ? 'updated' : 'skipped'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { convertNodeError, ErrorCode, ParseError } from '../errors'
import { normalizeSkillsManifest } from './skillsManifest'
import type { SkillsManifest } from './types'

export async function readSkillsManifest(rootDir: string): Promise<SkillsManifest | null> {
Expand All @@ -9,12 +10,8 @@ export async function readSkillsManifest(rootDir: string): Promise<SkillsManifes
try {
const raw = await readFile(filePath, 'utf8')
try {
const json = JSON.parse(raw) as SkillsManifest
return {
installDir: json.installDir ?? '.agents/skills',
linkTargets: json.linkTargets ?? [],
skills: json.skills ?? {},
}
const json = JSON.parse(raw) as Partial<SkillsManifest>
return normalizeSkillsManifest(json)
} catch (parseError) {
throw new ParseError({
code: ErrorCode.JSON_PARSE_ERROR,
Expand Down
81 changes: 81 additions & 0 deletions packages/skills-package-manager/src/config/skillsManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { accessSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { normalizeLinkSource } from '../specifiers/normalizeLinkSource'
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
import type { SkillsManifest } from './types'

export const SELF_SKILL_NAME = 'skills-package-manager-cli'
const SELF_SKILL_CANDIDATE_PATHS = [
'../skills/skills-package-manager-cli',
'../../skills/skills-package-manager-cli',
]
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))

function resolveBundledSelfSkillDir(): string {
for (const relativePath of SELF_SKILL_CANDIDATE_PATHS) {
const candidate = path.resolve(MODULE_DIR, relativePath)
try {
accessSync(path.join(candidate, 'SKILL.md'))
return candidate
} catch {}
}

throw new Error('Unable to locate bundled skills-package-manager-cli skill')
}

export function getBundledSelfSkillSpecifier(): string {
return normalizeLinkSource(`link:${resolveBundledSelfSkillDir()}`)
}

export function shouldInjectBundledSelfSkill(manifest: SkillsManifest): boolean {
return normalizeSkillsManifest(manifest).selfSkill === true
}

function hasEquivalentSkillSpecifier(manifest: SkillsManifest, specifier: string): boolean {
const normalizedTarget = normalizeSpecifier(specifier).normalized
return Object.values(manifest.skills).some((existingSpecifier) => {
try {
return normalizeSpecifier(existingSpecifier).normalized === normalizedTarget
} catch {
return false
}
})
}

export function normalizeSkillsManifest(manifest: Partial<SkillsManifest>): SkillsManifest {
return {
$schema: manifest.$schema,
installDir: manifest.installDir ?? '.agents/skills',
linkTargets: manifest.linkTargets ?? [],
selfSkill: manifest.selfSkill ?? false,
skills: manifest.skills ?? {},
}
}

export async function expandSkillsManifest(
_rootDir: string,
manifest: SkillsManifest,
): Promise<SkillsManifest> {
const normalized = normalizeSkillsManifest(manifest)

if (!normalized.selfSkill) {
return normalized
}

const selfSpecifier = getBundledSelfSkillSpecifier()
if (
SELF_SKILL_NAME in normalized.skills ||
hasEquivalentSkillSpecifier(normalized, selfSpecifier)
) {
return normalized
}

return {
...normalized,
skills: {
...normalized.skills,
[SELF_SKILL_NAME]: selfSpecifier,
},
}
}
1 change: 1 addition & 0 deletions packages/skills-package-manager/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type SkillsManifest = {
$schema?: string
installDir?: string
linkTargets?: string[]
selfSkill?: boolean
skills: Record<string, string>
}

Expand Down
Loading