diff --git a/AGENTS.md b/AGENTS.md
index d493078..b1e7692 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
- `packages/skills-package-manager`: core library and `spm` CLI.
- `packages/pnpm-plugin-skills`: pnpm plugin that syncs skills during install.
- `website`: Rspress documentation site.
-- Root files such as `skills.json` and `skills-lock.yaml` document example skill manifests and lock state.
+- Root files such as `skills.json` document example skill manifests and pinned skill sources.
## Documentation Structure
@@ -19,7 +19,7 @@ Before you start your PR, check if you need to update the documents. The documen
- **Architecture** (`architecture/`):
- `how-it-works.mdx` - High-level system overview.
- `cli-commands.mdx` - CLI command implementation details.
- - `manifest-and-lockfile.mdx` - Manifest and lockfile formats.
+ - `manifest.mdx` - Manifest format and pinned specifier behavior.
- `pnpm-plugin.mdx` - pnpm plugin integration details.
- **Public Assets** (`public/`): Logos and favicon files.
@@ -36,18 +36,18 @@ Before you start your PR, check if you need to update the documents. The documen
- Use TypeScript and follow the existing module style in each package.
- Match existing formatting, naming, and file organization before introducing new patterns.
- Keep changes focused; avoid adding abstractions, comments, or files unless they are necessary.
-- Preserve manifest, lockfile, and CLI terminology consistently across code and docs.
+- Preserve manifest, specifier, and CLI terminology consistently across code and docs.
## Testing Expectations
-- Add or update tests when changing CLI behavior, specifier parsing, install flow, or lockfile behavior.
+- Add or update tests when changing CLI behavior, specifier parsing, install flow, or install state behavior.
- Prefer targeted tests under `packages/*/test`, following the existing `@rstest/core` style.
- Run `pnpm test` before opening a pull request.
- For docs-only changes, build the site with `pnpm build:website` if the change affects routing, components, or MDX structure.
## Commit and Pull Request Guidelines
-- Use clear, conventional commit messages such as `feat: add file specifier validation` or `fix: preserve lockfile resolution`.
+- Use clear, conventional commit messages such as `feat: add file specifier validation` or `fix: preserve install state`.
- Keep pull requests scoped to one change or theme.
- Include a brief summary, testing notes, and screenshots for docs/UI changes when relevant.
-- Link related issues or context, and note any manifest or lockfile changes explicitly.
+- Link related issues or context, and note any manifest or install behavior changes explicitly.
diff --git a/README.md b/README.md
index 61fdf0f..4e97396 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
The Next-Gen Package Manager for Agent Skills
- Manage, install, and link SKILL.md-based skills with lockfile-driven reproducibility.
+ Manage, install, and link SKILL.md-based skills from a single `skills.json` manifest.
@@ -40,7 +40,7 @@
## β¨ Features
-- π **Lockfile-Driven Versioning** β Ditch heavy git commits. `skills-lock.yaml` ensures every team member runs on identical skill versions.
+- π **Single-File Pins** β Keep exact git commits and npm versions directly in `skills.json`.
- π **Any Source, Any Skill** β Mix local `link:`, versioned `npm:`, or direct `git:` repos with easeβeven sub-folders within `.tgz` archives.
- π **npx skills compatible** β A seamless, drop-in replacement. Swap `npx skills` for `npx skills-package-manager` and unlock more power.
- π **Native pnpm Integration** β The `pnpm-plugin-skills` hooks directly into your install lifecycle for zero-effort synchronization.
@@ -64,6 +64,10 @@ npx skills-package-manager add rstackjs/agent-skills
# π― Direct β specify skill by name
npx skills-package-manager add rstackjs/agent-skills --skill pr-creator
+npx skills-package-manager add rstackjs/agent-skills -s pr-creator -s rspress-custom-theme
+
+# π List without installing
+npx skills-package-manager add rstackjs/agent-skills --list
# π Local skill directory
npx skills-package-manager add link:./my-skills/my-skill
@@ -75,14 +79,11 @@ npx skills-package-manager add link:./my-skills/my-skill
npx skills-package-manager install
```
-> π‘ **Tip:** Use `--frozen-lockfile` in CI/CD to ensure reproducible installs without modifying the lockfile.
-
## ποΈ How It Works
-SPM uses two simple files to manage your agent's capabilities:
+SPM uses one manifest file to manage your agent's capabilities:
-1. **`skills.json` (The Manifest)**: The single source of truth where you declare your requirements across any protocol.
-2. **`skills-lock.yaml` (The Lockfile)**: Deterministically locks every dependency to ensure every installation is identical.
+1. **`skills.json`**: The single source of truth where you declare pinned skill specifiers, `installDir`, and `linkTargets`.
## π Documentation
diff --git a/packages/pnpm-plugin-skills/README.md b/packages/pnpm-plugin-skills/README.md
index 3a30e25..2322906 100644
--- a/packages/pnpm-plugin-skills/README.md
+++ b/packages/pnpm-plugin-skills/README.md
@@ -7,10 +7,10 @@ A pnpm plugin that automatically installs agent skills during `pnpm install`.
This plugin hooks into pnpm's `preResolution` lifecycle to run skill installation before dependency resolution. On every `pnpm install`, it:
1. Reads `skills.json` from the workspace root
-2. Resolves and syncs `skills-lock.yaml`
+2. Resolves the manifest into an in-memory installation plan
3. Materializes skills into the configured `installDir`
4. Creates symlinks for configured `linkTargets`
-5. Skips if the lockfile hasn't changed (fast path)
+5. Updates the internal install state for future incremental runs
## Setup
@@ -27,7 +27,7 @@ Then create a `skills.json` in your project root:
"installDir": ".agents/skills",
"linkTargets": [".claude/skills"],
"skills": {
- "my-skill": "https://github.com/owner/repo.git#path:/skills/my-skill"
+ "my-skill": "github:owner/repo#abc1234&path:/skills/my-skill"
}
}
```
diff --git a/packages/pnpm-plugin-skills/src/index.ts b/packages/pnpm-plugin-skills/src/index.ts
index 96b38cf..950154a 100644
--- a/packages/pnpm-plugin-skills/src/index.ts
+++ b/packages/pnpm-plugin-skills/src/index.ts
@@ -3,18 +3,18 @@ import { installCommand } from 'skills-package-manager'
export async function preResolution(
options: { lockfileDir?: string; workspaceRoot?: string } = {},
) {
- const lockfileDir = options.lockfileDir
- if (!lockfileDir) {
+ const projectRoot = options.lockfileDir
+ if (!projectRoot) {
return undefined
}
- await installCommand({ cwd: lockfileDir })
+ await installCommand({ cwd: projectRoot })
return undefined
}
export function afterAllResolved(
- lockfile: Record,
+ pnpmLockfile: Record,
_context: { log?: (message: string) => void } = {},
) {
- return lockfile
+ return pnpmLockfile
}
diff --git a/packages/pnpm-plugin-skills/test/index.test.ts b/packages/pnpm-plugin-skills/test/index.test.ts
index e82fb38..fddcdc3 100644
--- a/packages/pnpm-plugin-skills/test/index.test.ts
+++ b/packages/pnpm-plugin-skills/test/index.test.ts
@@ -27,7 +27,7 @@ async function loadPreResolution() {
}
describe('preResolution', () => {
- it('installs skills from workspace root when manifest and lock exist', async () => {
+ it('installs skills from workspace root when only skills.json exists', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'pnpm-plugin-skills-'))
mkdirSync(path.join(root, 'skills-source/skills/hello-skill'), { recursive: true })
writeFileSync(
@@ -48,23 +48,6 @@ describe('preResolution', () => {
2,
),
)
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- [
- "lockfileVersion: '0.1'",
- 'installDir: .agents/skills',
- 'linkTargets:',
- ' - .claude/skills',
- 'skills:',
- ' hello-skill:',
- ' specifier: link:./skills-source/skills/hello-skill',
- ' resolution:',
- ' type: link',
- ` path: ${JSON.stringify(path.join(root, 'skills-source/skills/hello-skill'))}`,
- ' digest: test-digest',
- ].join('\n'),
- )
-
const preResolution = await loadPreResolution()
const result = await preResolution({
@@ -78,6 +61,8 @@ describe('preResolution', () => {
'Hello from plugin',
)
expect(existsSync(path.join(root, '.claude/skills/hello-skill'))).toBe(true)
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
+ expect(existsSync(path.join(root, '.agents/skills/lock.yaml'))).toBe(false)
})
})
diff --git a/packages/skills-package-manager/README.md b/packages/skills-package-manager/README.md
index 027bbb7..d3943b5 100644
--- a/packages/skills-package-manager/README.md
+++ b/packages/skills-package-manager/README.md
@@ -9,7 +9,7 @@ For one-off usage, `npx skills-package-manager add ...` is the low-friction migr
```bash
npx skills-package-manager --help
npx skills-package-manager --version
-npx skills-package-manager add [--skill ]
+npx skills-package-manager add [--skill ...]
npx skills-package-manager install
npx skills-package-manager patch
npx skills-package-manager patch-commit
@@ -37,26 +37,37 @@ npx skills-package-manager add owner/repo
# Interactive β clone repo, discover skills, select via multiselect prompt
npx skills-package-manager add owner/repo
npx skills-package-manager add https://github.com/owner/repo
+npx skills-package-manager add https://gitlab.com/org/repo
+npx skills-package-manager add git@github.com:owner/repo.git
+npx skills-package-manager add ./my-local-skills
-# Non-interactive β add a specific skill by name
+# Non-interactive β add one or more specific skills by name
npx skills-package-manager add owner/repo --skill find-skills
+npx skills-package-manager add owner/repo -s frontend-design -s skill-creator
npx skills-package-manager add owner/repo@find-skills
npx skills-package-manager add owner/repo#main@find-skills
# Direct repo subpath
npx skills-package-manager add owner/repo/skills/my-skill
-npx skills-package-manager add https://github.com/owner/repo/tree/main/skills/my-skill#main
+npx skills-package-manager add https://github.com/owner/repo/tree/main/skills/my-skill
+
+# Inspect or target agents with skills CLI-compatible flags
+npx skills-package-manager add owner/repo --list
+npx skills-package-manager add owner/repo --all
+npx skills-package-manager add owner/repo -a claude-code -a opencode
# Direct specifier β skip discovery
-npx skills-package-manager add https://github.com/owner/repo.git#path:/skills/my-skill
+npx skills-package-manager add github:owner/repo#abc1234&path:/skills/my-skill
npx skills-package-manager add link:./local-source/skills/my-skill
-npx skills-package-manager add local:./.agents/skills/my-skill
+npx skills-package-manager add local:*
npx skills-package-manager add ./local-source
-npx skills-package-manager add file:./skills-package.tgz#path:/skills/my-skill
-npx skills-package-manager add npm:@scope/skills-package#path:/skills/my-skill
+npx skills-package-manager add file:./skills-package.tgz&path:/skills/my-skill
+npx skills-package-manager add npm:@scope/skills-package@1.0.0&path:/skills/my-skill
```
After `npx skills-package-manager add`, the newly added skills are resolved, installed or registered according to their protocol, and linked to each configured `linkTarget` immediately.
+GitHub sources are written back to `skills.json` as pinned `github:owner/repo#&path:` specifiers.
+The `--copy` flag is accepted for `npx skills add` command-line compatibility; SPM still keeps one canonical install directory and links configured agent targets from there.
#### How it works
@@ -65,7 +76,7 @@ When given `owner/repo` or a GitHub URL:
1. Shallow-clones the repository into a temp 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`
+4. Writes selected, pinned skill specifiers to `skills.json`
5. Cleans up the temp directory
### `npx skills-package-manager init`
@@ -106,7 +117,7 @@ npx skills-package-manager install
```
This resolves each skill from its specifier, installs managed skills into `installDir` (default `.agents/skills/`), registers `local:` skills in place, and creates symlinks for each `linkTarget`.
-When `selfSkill` is `true`, `npx skills-package-manager install` also installs the bundled `skills-package-manager-cli` skill so users get guidance for `skills.json`, `skills-lock.yaml`, and `npx skills-package-manager` commands. This helper skill is not written to `skills-lock.yaml`.
+When `selfSkill` is `true`, `npx skills-package-manager install` also installs the bundled `skills-package-manager-cli` skill so users get guidance for `skills.json` and `npx skills-package-manager` commands. This helper skill is injected at install time and is not written to `skills.json`.
If `patchedSkills` contains an entry for a managed skill, the corresponding patch file is applied after the skill is materialized. `local:` skills cannot be patched because their source directories are user-owned.
### `npx skills-package-manager patch`
@@ -120,7 +131,7 @@ npx skills-package-manager patch hello-skill --edit-dir ./tmp/hello-skill
Behavior:
-- Resolves the currently locked content for the target skill
+- Resolves the current manifest content for the target skill
- Extracts an editable copy into a temporary directory by default
- Reapplies any committed patch for that skill unless `--ignore-existing` is passed
- Writes patch edit metadata so `patch-commit` can generate a new patch file later
@@ -139,12 +150,11 @@ Behavior:
- Compares the edited directory with the original resolved skill content
- Writes a unified diff patch file to `patches/.patch` by default
- Updates `skills.json` through the `patchedSkills` field
-- Updates `skills-lock.yaml` with patch path and digest metadata
- Reinstalls and relinks the patched skill so the working tree reflects the committed patch
### `npx skills-package-manager update`
-Refresh resolvable skills declared in `skills.json` without changing the manifest:
+Refresh resolvable skills declared in `skills.json` and write the updated pins back to the manifest:
```bash
npx skills-package-manager update
@@ -154,10 +164,10 @@ npx skills-package-manager update find-skills rspress-custom-theme
Behavior:
- Uses `skills.json` as the source of truth
-- Re-resolves git refs and npm package targets
-- Skips local `link:` and `local:` skills, including the bundled self skill
+- Updates git skills to the latest `main` commit and npm skills to the registry `latest` version
+- Skips local `link:`, `local:`, and `file:` skills
- Fails immediately for unknown skill names
-- Writes `skills-lock.yaml` only after fetch and link succeed
+- Writes `skills.json` only after the updated install succeeds
## Programmatic API
@@ -182,14 +192,15 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
## Specifier Format
```text
-git/file/npm: #[ref&]path:
+git/file/npm: [#ref][&path:]
link: link:
local: local:
+local shorthand: local:*
```
| Part | Description | Example |
|------|-------------|---------|
-| `source` | Git URL, direct `link:` or `local:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `local:./.agents/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
+| `source` | Git URL or `github:` shorthand, direct `link:` or `local:` skill path, `file:` tarball, or `npm:` package name | `github:o/r`, `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `local:*`, `file:./skills.tgz`, `npm:@scope/pkg@1.0.0` |
| `ref` | Optional git ref | `main`, `v1.0.0`, `HEAD`, `6cb0992`, `6cb0992a176f2ca142e19f64dca8ac12025b035e` |
| `path` | Path to skill directory within source | `/skills/my-skill` |
@@ -201,7 +212,7 @@ local: local:
- **`link`** β Symlinks a local skill directory into `installDir`
- **`local`** β Uses an existing user-owned skill directory in place
- **`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 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.
@@ -212,7 +223,7 @@ src/
βββ bin/ # CLI entry points
βββ cli/ # CLI runner and interactive prompts
βββ commands/ # add, install, patch command implementations
-βββ config/ # skills.json / skills-lock.yaml read/write
+βββ config/ # skills.json read/write and in-memory install plan resolution
βββ github/ # Git clone + skill discovery (listSkills)
βββ install/ # Skill materialization, linking, pruning
βββ patches/ # Patch edit state, diff generation, patch application
@@ -231,4 +242,3 @@ pnpm build # Builds with Rslib (ESM output + DTS)
```bash
pnpm test # Runs tests with Rstest
```
-``
diff --git a/packages/skills-package-manager/skills/skills-package-manager-cli/SKILL.md b/packages/skills-package-manager/skills/skills-package-manager-cli/SKILL.md
index b28f4f2..bc2cd20 100644
--- a/packages/skills-package-manager/skills/skills-package-manager-cli/SKILL.md
+++ b/packages/skills-package-manager/skills/skills-package-manager-cli/SKILL.md
@@ -1,26 +1,23 @@
---
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`
+description: Help users work in repositories that use skills-package-manager. Use when requests mention `skills.json`, `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.
+Use this skill for repositories that already use `skills-package-manager`, or when a user needs help understanding and editing its manifest 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.
+ It declares which skills a repo wants, the pinned git commits or npm versions to use, where to materialize skills, where to link them, and whether to include the bundled helper skill.
- 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.
+ They are produced from `skills.json`; they are not 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 `npx skills-package-manager` 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.
+- The bundled skill is injected at runtime. It should not be added manually under `skills` unless there is a very specific reason.
## Command Guide
@@ -28,27 +25,29 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
- Creates `skills.json`.
- `npx skills-package-manager init --yes` writes the default manifest immediately.
-2. `npx skills-package-manager add [--skill ]`
+2. `npx skills-package-manager add [--skill ...]`
- Adds a skill to `skills.json`.
- - Resolves it into `skills-lock.yaml`.
+ - Resolves remote git/npm inputs into pinned specifiers in the manifest.
+ - GitHub shorthand, GitHub URL, and GitHub tree URL inputs are written back as `github:owner/repo#&path:`.
+ - Compatible with common `npx skills add` flags: `-s/--skill` can repeat, `-l/--list` lists without installing, `--all` selects all skills and all known project agents, `-a/--agent` can repeat, and `--copy` is accepted as a no-op compatibility flag.
- 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.
+ - Resolves and installs everything declared in `skills.json`.
+ - Does not write a separate lock file.
4. `npx skills-package-manager update [skill...]`
- - Refreshes resolvable entries in `skills-lock.yaml`.
- - Skips local `link:` and `local:` skills, including the bundled `skills-package-manager-cli` self skill.
+ - Updates git skills to the latest `main` commit and npm skills to the registry `latest` version.
+ - Writes updated pins back to `skills.json` only after install succeeds.
+ - Skips `link:`, `local:`, and `file:` skills.
## 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`.
+2. If the user wants newer remote skills:
+ Run `npx skills-package-manager update` or update the pinned specifier in `skills.json`.
3. If the user says a skill is missing in their agent:
Check `installDir`, `linkTargets`, generated skill directories, and symlinks.
@@ -58,14 +57,15 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
## Specifier Reminders
-- `link:./path/to/skill-dir` points to a local skill directory.
+- `github:owner/repo#commit&path:/skills/name` points to a pinned GitHub skill.
+- `link:./path/to/skill-dir` points to a local skill directory and is symlinked into `installDir`.
+- `local:*` keeps an existing user-owned skill at `${installDir}/${skillName}` in place.
- `local:./path/to/existing-skill-dir` keeps an existing user-owned skill directory in place.
-- `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.
+- `file:./pkg.tgz&path:/skills/name` points to a packaged tarball plus skill path.
+- `npm:@scope/pkg@1.0.0&path:/skills/name` resolves a package from the configured registry.
## 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.
+- Keep `manifest`, `installDir`, `linkTargets`, `skills`, and `specifier` terminology exact.
+- Treat `skills.json` as the only user-maintained config file.
+- If you change this bundled skill inside the `skills-package-manager` repo, revalidate the skill folder.
diff --git a/packages/skills-package-manager/src/cli/runCli.ts b/packages/skills-package-manager/src/cli/runCli.ts
index 372d20e..6ef76ea 100644
--- a/packages/skills-package-manager/src/cli/runCli.ts
+++ b/packages/skills-package-manager/src/cli/runCli.ts
@@ -38,6 +38,50 @@ function formatFlagName(name: string): string {
return name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)
}
+function collectOptionValues(argv: string[], optionNames: string[]): string[] | undefined {
+ const values: string[] = []
+
+ for (let index = 3; index < argv.length; index += 1) {
+ const token = argv[index]
+ const equalsName = optionNames.find((name) => token.startsWith(`${name}=`))
+ if (equalsName) {
+ const value = token.slice(equalsName.length + 1)
+ if (value) {
+ values.push(value)
+ }
+ continue
+ }
+
+ const shortName = optionNames.find(
+ (name) =>
+ name.startsWith('-') && !name.startsWith('--') && token.startsWith(name) && token !== name,
+ )
+ if (shortName) {
+ values.push(token.slice(shortName.length))
+ continue
+ }
+
+ if (!optionNames.includes(token)) {
+ continue
+ }
+
+ while (index + 1 < argv.length && !argv[index + 1].startsWith('-')) {
+ values.push(argv[index + 1])
+ index += 1
+ }
+ }
+
+ return values.length > 0 ? values : undefined
+}
+
+function toSingleOrArray(values: string[] | undefined, fallback?: string[] | string) {
+ if (!values) {
+ return fallback
+ }
+
+ return values.length === 1 ? values[0] : values
+}
+
const packageVersion = packageJson.version
export function runCli(argv: string[], context?: { cwd?: string }): Promise
@@ -54,41 +98,56 @@ export async function runCli(argv: string[], context: InternalRunCliContext = {}
.command('add [...positionals]')
.option('-a, --agent ', 'Target agent')
.option('-g, --global', 'Install into the global skills workspace')
- .option('--skill ', 'Select a skill')
+ .option('-s, --skill ', 'Select a skill')
+ .option('-l, --list', 'List available skills without installing')
+ .option('--copy', 'Accept skills CLI copy-mode flag')
+ .option('--all', 'Install all discovered skills to all known project agents')
.option('-y, --yes', 'Skip prompts and select defaults')
.action(
async (
positionals: string[] = [],
- options: { agent?: string[] | string; global?: boolean; skill?: string; yes?: boolean },
+ options: {
+ agent?: string[] | string
+ global?: boolean
+ skill?: string[] | string
+ list?: boolean
+ copy?: boolean
+ all?: boolean
+ yes?: boolean
+ },
) => {
const specifier = positionals[0]
if (!specifier) {
throw new Error('Missing required specifier')
}
- const agent = Array.isArray(options.agent)
- ? options.agent
- : options.agent
- ? [options.agent]
- : undefined
+ const collectedAgents = collectOptionValues(argv, ['-a', '--agent'])
+ const agent = collectedAgents
+ ? collectedAgents
+ : Array.isArray(options.agent)
+ ? options.agent
+ : options.agent
+ ? [options.agent]
+ : undefined
+ const skill = toSingleOrArray(collectOptionValues(argv, ['-s', '--skill']), options.skill)
return handlers.addCommand({
cwd,
specifier,
- skill: options.skill,
+ skill,
global: options.global,
yes: options.yes,
agent,
+ list: options.list,
+ copy: options.copy,
+ all: options.all,
})
},
)
- cli
- .command('install [...args]')
- .option('--frozen-lockfile', 'Fail if lockfile is out of sync')
- .action(async (_args: string[], options: { frozenLockfile?: boolean }) => {
- return handlers.installCommand({ cwd, frozenLockfile: options.frozenLockfile })
- })
+ cli.command('install [...args]').action(async () => {
+ return handlers.installCommand({ cwd })
+ })
cli
.command('patch ')
diff --git a/packages/skills-package-manager/src/commands/add.ts b/packages/skills-package-manager/src/commands/add.ts
index 48f21be..0163c39 100644
--- a/packages/skills-package-manager/src/commands/add.ts
+++ b/packages/skills-package-manager/src/commands/add.ts
@@ -8,16 +8,13 @@ import {
resolveCompatibleAddAgentTargets,
} from '../cli/agentCompatibility'
import { promptSkillSelection } from '../cli/prompt'
-import { readSkillsLock } from '../config/readSkillsLock'
import { readSkillsManifest } from '../config/readSkillsManifest'
-import { syncSkillsLock } from '../config/syncSkillsLock'
-import type { AddCommandOptions, NormalizedSpecifier } from '../config/types'
-import { writeSkillsLock } from '../config/writeSkillsLock'
+import { resolveSkillEntry, resolveSkillsPlan } from '../config/resolveSkillsPlan'
+import type { AddCommandOptions, NormalizedSpecifier, ResolvedSkillEntry } from '../config/types'
import { writeSkillsManifest } from '../config/writeSkillsManifest'
import { ErrorCode, ParseError, SkillError } from '../errors'
import { cloneAndDiscover, discoverSkillsInDir, parseGitHubUrl } from '../github/listSkills'
import type { SkillInfo } from '../github/types'
-import { withBundledSelfSkillLock } from '../install/withBundledSelfSkillLock'
import { runPipeline } from '../pipeline'
import { loadConfig } from '../pipeline/context'
import { normalizeLinkSource } from '../specifiers/normalizeLinkSource'
@@ -46,16 +43,13 @@ type ExtractedAddSource = {
}
function buildGitSpecifier(repoUrl: string, skillPath: string, ref?: string): string {
- return ref ? `${repoUrl}#${ref}&path:${skillPath}` : `${repoUrl}#path:${skillPath}`
+ return ref ? `${repoUrl}#${ref}&path:${skillPath}` : `${repoUrl}&path:${skillPath}`
}
async function runInstallPipeline(cwd: string) {
const ctx = await loadConfig(cwd)
- const runtimeLock = ctx.lockfile
- ? await withBundledSelfSkillLock(cwd, ctx.manifest, ctx.lockfile)
- : null
- const entries = runtimeLock?.skills ?? ctx.lockfile?.skills ?? {}
- await runPipeline({ ctx, entries, skipResolve: true })
+ const plan = await resolveSkillsPlan(cwd, ctx.manifest)
+ await runPipeline({ ctx, plan, skipResolve: true })
}
function buildLinkSpecifier(sourceRoot: string, skillPath: string): string {
@@ -138,15 +132,12 @@ function parseTreeUrlSuffix(
})
}
- if (normalizedTreeSuffix.includes('/')) {
- throw new ParseError({
- code: ErrorCode.INVALID_SPECIFIER,
- message:
- provider === 'GitHub'
- ? `Ambiguous GitHub tree URL: ${input}. If the ref contains "/", specify it explicitly with "#" instead.`
- : `Ambiguous GitLab tree URL: ${input}. GitLab refs can contain slashes, so provide the ref explicitly via #.`,
- content: input,
- })
+ const [treeRef, ...subpathParts] = normalizedTreeSuffix.split('/')
+ if (subpathParts.length > 0) {
+ return {
+ ref: treeRef,
+ subpath: sanitizeSourceSubpath(subpathParts.join('/')),
+ }
}
return { ref: normalizedTreeSuffix }
@@ -361,7 +352,7 @@ function parseRepoSkillSpecifier(input: string): { specifier: string; skill: str
}
}
-export function normalizeAddCommandInput(specifier: string, skill?: string) {
+export function normalizeAddCommandInput(specifier: string, skill?: string | string[]) {
const parsedRepoSkill = parseRepoSkillSpecifier(specifier)
if (!parsedRepoSkill) {
return { specifier, skill }
@@ -411,6 +402,13 @@ function formatAvailableSkills(skills: SkillInfo[]): string {
return `${preview}, ...`
}
+function printAvailableSkills(skills: SkillInfo[]) {
+ for (const skill of skills) {
+ const details = skill.description ? ` - ${skill.description}` : ''
+ console.info(`${skill.name}\t${skill.path}${details}`)
+ }
+}
+
function filterSkillsBySubpath(skills: SkillInfo[], subpath?: string): SkillInfo[] {
if (!subpath) {
return skills
@@ -423,6 +421,34 @@ function filterSkillsBySubpath(skills: SkillInfo[], subpath?: string): SkillInfo
})
}
+function selectRequestedSkills(skills: SkillInfo[], requestedSkills: string[]): SkillInfo[] {
+ if (requestedSkills.includes('*')) {
+ return skills
+ }
+
+ const selectedSkills: SkillInfo[] = []
+ const selectedKeys = new Set()
+
+ for (const requestedSkill of requestedSkills) {
+ const found = findRequestedSkill(skills, requestedSkill)
+ if (!found) {
+ throw new SkillError({
+ code: ErrorCode.SKILL_NOT_FOUND,
+ skillName: requestedSkill,
+ message: `Skill ${requestedSkill} not found in source. Available skills: ${formatAvailableSkills(skills)}`,
+ })
+ }
+
+ const key = `${found.name}\0${found.path}`
+ if (!selectedKeys.has(key)) {
+ selectedKeys.add(key)
+ selectedSkills.push(found)
+ }
+ }
+
+ return selectedSkills
+}
+
async function discoverSkillsFromSource(source: ParsedAddSource): Promise {
if (source.type === 'local') {
if (!existsSync(source.localPath)) {
@@ -442,14 +468,63 @@ async function discoverSkillsFromSource(source: ParsedAddSource): Promise {
+ await ensureDir(cwd)
+
+ const existingManifest = (await readSkillsManifest(cwd)) ?? {
+ installDir: manifestDefaults?.installDir ?? '.agents/skills',
+ linkTargets: manifestDefaults?.linkTargets ?? [],
+ skills: {},
+ }
+
+ if (manifestDefaults) {
+ existingManifest.installDir = manifestDefaults.installDir
+ existingManifest.linkTargets = manifestDefaults.linkTargets
+ }
+
let normalized: NormalizedSpecifier
try {
- normalized = normalizeSpecifier(specifier)
+ normalized = normalizeSpecifier(specifier, {
+ installDir: existingManifest.installDir,
+ })
} catch (error) {
if (error instanceof ParseError) {
throw error
@@ -462,21 +537,13 @@ async function addSingleSkill(
})
}
- await ensureDir(cwd)
-
- const existingManifest = (await readSkillsManifest(cwd)) ?? {
- installDir: manifestDefaults?.installDir ?? '.agents/skills',
- linkTargets: manifestDefaults?.linkTargets ?? [],
- skills: {},
- }
-
- if (manifestDefaults) {
- existingManifest.installDir = manifestDefaults.installDir
- existingManifest.linkTargets = manifestDefaults.linkTargets
- }
+ const { entry } = await resolveSkillEntry(cwd, specifier, normalized.skillName, {
+ installDir: existingManifest.installDir,
+ })
+ const manifestSpecifier = formatResolvedManifestSpecifier(normalized, entry)
const existing = existingManifest.skills[normalized.skillName]
- if (existing && existing !== normalized.normalized) {
+ if (existing && existing !== manifestSpecifier) {
throw new SkillError({
code: ErrorCode.SKILL_EXISTS,
skillName: normalized.skillName,
@@ -484,16 +551,12 @@ async function addSingleSkill(
})
}
- existingManifest.skills[normalized.skillName] = normalized.normalized
+ existingManifest.skills[normalized.skillName] = manifestSpecifier
await writeSkillsManifest(cwd, existingManifest)
- const existingLock = await readSkillsLock(cwd)
- const lockfile = await syncSkillsLock(cwd, existingManifest, existingLock)
- await writeSkillsLock(cwd, lockfile)
-
return {
skillName: normalized.skillName,
- specifier: normalized.normalized,
+ specifier: manifestSpecifier,
}
}
@@ -519,7 +582,7 @@ async function resolveAddManifestContext(options: AddCommandOptions): Promise<{
const targetCwd = options.global ? getSkillsPackageManagerHome() : options.cwd
const existingManifest = await readSkillsManifest(targetCwd)
const installDir = existingManifest?.installDir ?? '.agents/skills'
- const requestedAgents = normalizeStringArray(options.agent)
+ const requestedAgents = options.all ? ['*'] : normalizeStringArray(options.agent)
if (requestedAgents) {
const resolvedTargets = resolveCompatibleAddAgentTargets(requestedAgents, {
@@ -567,6 +630,7 @@ export async function addCommand(options: AddCommandOptions) {
const normalizedInput = normalizeAddCommandInput(options.specifier, options.skill)
const { specifier, skill } = normalizedInput
const parsedSource = parseAddSourceSpecifier(specifier)
+ const requestedSkills = options.all ? ['*'] : normalizeStringArray(skill)
if (parsedSource) {
p.intro(pc.bgCyan(pc.black(' spm ')))
@@ -586,7 +650,7 @@ export async function addCommand(options: AddCommandOptions) {
spinner.stop(pc.red('No skills found'))
throw new SkillError({
code: ErrorCode.SKILL_NOT_FOUND,
- skillName: skill ?? sourceLabel,
+ skillName: requestedSkills?.[0] ?? sourceLabel,
message: `No valid skills found in ${sourceLabel}`,
})
}
@@ -595,20 +659,15 @@ export async function addCommand(options: AddCommandOptions) {
`Found ${pc.green(String(discoveredSkills.length))} skill${discoveredSkills.length !== 1 ? 's' : ''}`,
)
- let selectedSkills: SkillInfo[]
- if (skill === '*') {
- selectedSkills = discoveredSkills
- } else if (skill) {
- const found = findRequestedSkill(discoveredSkills, skill)
- if (!found) {
- throw new SkillError({
- code: ErrorCode.SKILL_NOT_FOUND,
- skillName: skill,
- message: `Skill ${skill} not found in ${sourceLabel}. Available skills: ${formatAvailableSkills(discoveredSkills)}`,
- })
- }
+ if (options.list) {
+ printAvailableSkills(discoveredSkills)
+ p.outro('Listed skills')
+ return { status: 'listed' as const, skills: discoveredSkills }
+ }
- selectedSkills = [found]
+ let selectedSkills: SkillInfo[]
+ if (requestedSkills && requestedSkills.length > 0) {
+ selectedSkills = selectRequestedSkills(discoveredSkills, requestedSkills)
} else if (options.yes) {
selectedSkills = discoveredSkills
} else {
@@ -642,6 +701,19 @@ export async function addCommand(options: AddCommandOptions) {
}
// Protocol specifier (file:, npm:, git URL with fragment, etc.) β direct add
+ if (options.list) {
+ const normalized = normalizeSpecifier(specifier, {
+ installDir: manifestContext.installDir,
+ })
+ const listedSkill = {
+ name: normalized.skillName,
+ description: '',
+ path: normalized.path,
+ }
+ printAvailableSkills([listedSkill])
+ return { status: 'listed' as const, skills: [listedSkill] }
+ }
+
const result = await addSingleSkill(cwd, specifier, manifestContext)
const spinner = p.spinner()
spinner.start('Installing skills...')
diff --git a/packages/skills-package-manager/src/commands/install.ts b/packages/skills-package-manager/src/commands/install.ts
index 788fe7a..d8af320 100644
--- a/packages/skills-package-manager/src/commands/install.ts
+++ b/packages/skills-package-manager/src/commands/install.ts
@@ -1,40 +1,9 @@
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
-import path from 'node:path'
-import YAML from 'yaml'
-import { isLockInSync, isSkillsLockEqual } from '../config/compareSkillsLock'
-import { syncSkillsLock } from '../config/syncSkillsLock'
-import type { InstallCommandOptions, SkillsLock } from '../config/types'
-import { writeSkillsLock } from '../config/writeSkillsLock'
-import { ErrorCode, ManifestError } from '../errors'
+import { resolveSkillsPlan } from '../config/resolveSkillsPlan'
+import type { InstallCommandOptions } from '../config/types'
import { createInstallProgressReporter } from '../install/progressReporter'
-import { withBundledSelfSkillLock } from '../install/withBundledSelfSkillLock'
import { runPipeline } from '../pipeline'
import { loadConfig } from '../pipeline/context'
-async function readInstallDirLock(cwd: string, installDir: string): Promise {
- const filePath = path.join(cwd, installDir, 'lock.yaml')
- try {
- return YAML.parse(await readFile(filePath, 'utf8')) as SkillsLock
- } catch {
- return null
- }
-}
-
-async function writeInstallDirLock(
- cwd: string,
- installDir: string,
- lockfile: SkillsLock,
-): Promise {
- const dirPath = path.join(cwd, installDir)
- const filePath = path.join(dirPath, 'lock.yaml')
- try {
- await mkdir(dirPath, { recursive: true })
- await writeFile(filePath, YAML.stringify(lockfile), 'utf8')
- } catch (error) {
- throw new Error(`Failed to write install-dir lock copy: ${(error as Error).message}`)
- }
-}
-
export async function installCommand(options: InstallCommandOptions) {
const ctx = await loadConfig(options.cwd)
@@ -50,84 +19,26 @@ export async function installCommand(options: InstallCommandOptions) {
let started = false
try {
- let lockfile: SkillsLock
- const installDir = ctx.manifest.installDir ?? '.agents/skills'
-
- if (options.frozenLockfile) {
- // Frozen mode: lock must exist and be in sync
- if (!ctx.lockfile) {
- throw new ManifestError({
- code: ErrorCode.LOCKFILE_NOT_FOUND,
- filePath: `${options.cwd}/skills-lock.yaml`,
- message:
- 'Lockfile is required in frozen mode but none was found. Run "spm install" first.',
- })
- }
- if (
- !(await isLockInSync(
- options.cwd,
- ctx.manifest,
- ctx.lockfile,
- ctx.manifestStat,
- ctx.installState,
- ))
- ) {
- throw new ManifestError({
- code: ErrorCode.LOCKFILE_OUTDATED,
- filePath: `${options.cwd}/skills-lock.yaml`,
- message:
- 'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.',
- })
- }
- lockfile = ctx.lockfile
- } else {
- // Normal mode: check install-dir lock copy for fast-path skip.
- // Only skip when there are no file: skills, because tarball contents
- // may change without the lockfile being modified. link: and local:
- // skills always reflect the current source.
- const hasLocalSource = Object.values(ctx.manifest.skills).some((s) => s.startsWith('file:'))
- const installDirLock = await readInstallDirLock(options.cwd, installDir)
- if (
- !hasLocalSource &&
- ctx.lockfile &&
- installDirLock &&
- isSkillsLockEqual(ctx.lockfile, installDirLock) &&
- (await isLockInSync(options.cwd, ctx.manifest, ctx.lockfile))
- ) {
- console.info('Skills Lockfile is up to date, resolve skipped')
- lockfile = ctx.lockfile
- } else {
- lockfile = await syncSkillsLock(options.cwd, ctx.manifest, ctx.lockfile, {
- manifestStat: ctx.manifestStat,
- installState: ctx.installState,
- })
- }
- }
-
- const runtimeLock = await withBundledSelfSkillLock(options.cwd, ctx.manifest, lockfile)
+ const plan = await resolveSkillsPlan(options.cwd, ctx.manifest)
- reporter.start(Object.keys(runtimeLock.skills).length)
+ reporter.start(Object.keys(plan.skills).length)
started = true
- for (const skillName of Object.keys(runtimeLock.skills)) {
+ for (const skillName of Object.keys(plan.skills)) {
onProgress({ type: 'resolved', skillName })
}
reporter.setPhase('fetching')
await runPipeline({
ctx,
- entries: runtimeLock.skills,
+ plan,
skipResolve: true,
options: { onProgress },
})
reporter.setPhase('finalizing')
- if (!options.frozenLockfile) {
- await writeSkillsLock(options.cwd, lockfile)
- }
- await writeInstallDirLock(options.cwd, installDir, lockfile)
reporter.complete()
- return { status: 'installed' as const, installed: Object.keys(runtimeLock.skills) }
+ return { status: 'installed' as const, installed: Object.keys(plan.skills) }
} catch (error) {
if (started) {
reporter.fail()
diff --git a/packages/skills-package-manager/src/commands/patch.ts b/packages/skills-package-manager/src/commands/patch.ts
index ef79adc..6cd3809 100644
--- a/packages/skills-package-manager/src/commands/patch.ts
+++ b/packages/skills-package-manager/src/commands/patch.ts
@@ -1,14 +1,12 @@
import { access, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
-import { isLockInSync } from '../config/compareSkillsLock'
-import { syncSkillsLock } from '../config/syncSkillsLock'
+import { resolveSkillsPlan } from '../config/resolveSkillsPlan'
import type {
NormalizedSkillsManifest,
PatchCommandOptions,
PatchCommandResult,
- SkillsLock,
- SkillsLockEntry,
+ ResolvedSkillEntry,
} from '../config/types'
import { convertNodeError, ErrorCode, FileSystemError, ManifestError, SkillError } from '../errors'
import { extractSkillToDir } from '../install/extractSkillToDir'
@@ -37,22 +35,14 @@ async function ensureEditDirDoesNotExist(editDir: string) {
})
}
-async function createBaseLock(
+async function createBasePlan(
cwd: string,
manifest: NormalizedSkillsManifest,
- currentLock: SkillsLock | null,
) {
- if (currentLock && (await isLockInSync(cwd, manifest, currentLock))) {
- return {
- ...currentLock,
- skills: { ...currentLock.skills },
- }
- }
-
- return syncSkillsLock(cwd, manifest, currentLock)
+ return resolveSkillsPlan(cwd, manifest)
}
-function getUnpatchedBaseEntry(entry: SkillsLockEntry): SkillsLockEntry {
+function getUnpatchedBaseEntry(entry: ResolvedSkillEntry): ResolvedSkillEntry {
if (!entry.patch) {
return entry
}
@@ -89,14 +79,14 @@ export async function patchCommand(options: PatchCommandOptions): Promise {
+ const currentSpecifier = manifest.skills[skillName]
+ const normalized = normalizeSpecifier(currentSpecifier, {
+ installDir: manifest.installDir,
+ skillName,
+ })
+
+ if (normalized.type === 'link') {
+ return { specifier: currentSpecifier, skipped: 'link-specifier' }
+ }
+ if (normalized.type === 'local') {
+ return { specifier: currentSpecifier, skipped: 'local-specifier' }
+ }
+ if (normalized.type === 'file') {
+ return { specifier: currentSpecifier, skipped: 'file-specifier' }
+ }
+ if (normalized.type === 'git') {
+ const commit = await resolveGitCommit(normalized.source, 'main')
return {
- ...currentLock,
- skills: { ...currentLock.skills },
+ specifier: `${toGitHubSpecifierSource(normalized.source)}#${commit}${formatPathSuffix(normalized.path)}`,
}
}
+ const packageName = parseNpmPackageName(normalized.source)
+ const resolved = await resolveNpmPackage(cwd, packageName)
return {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {},
+ specifier: `npm:${resolved.name}@${resolved.version}${formatPathSuffix(normalized.path)}`,
}
}
@@ -57,38 +108,25 @@ export async function updateCommand(options: UpdateCommandOptions): Promise 0) {
+ await writeSkillsManifest(options.cwd, nextManifest)
+ }
result.status = result.updated.length > 0 ? 'updated' : 'skipped'
return result
diff --git a/packages/skills-package-manager/src/config/compareSkillsLock.ts b/packages/skills-package-manager/src/config/compareSkillsLock.ts
deleted file mode 100644
index 51cf297..0000000
--- a/packages/skills-package-manager/src/config/compareSkillsLock.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import path from 'node:path'
-import type { ManifestStat } from '../pipeline/types'
-import { normalizeLinkSource, normalizeLocalSource } from '../specifiers/normalizeLinkSource'
-import { parseSpecifier } from '../specifiers/parseSpecifier'
-import { sha256File } from '../utils/hash'
-import { toPortableRelativePath } from '../utils/path'
-import type { NormalizedSkillsManifest, SkillsLock } from './types'
-
-interface ParsedSpecifier {
- sourcePart: string
- ref: string | null
- path: string
-}
-
-function parseForComparison(specifier: string): ParsedSpecifier {
- const parsed = parseSpecifier(specifier)
- const isLink = parsed.sourcePart.startsWith('link:')
- const isLocal = parsed.sourcePart.startsWith('local:')
- return {
- sourcePart: isLink
- ? normalizeLinkSource(parsed.sourcePart)
- : isLocal
- ? normalizeLocalSource(parsed.sourcePart)
- : parsed.sourcePart,
- ref: isLink || isLocal ? null : parsed.ref,
- path: isLink || isLocal ? '/' : parsed.path || '/',
- }
-}
-
-function isSpecifierCompatible(manifestSpecifier: string, lockSpecifier: string): boolean {
- const manifest = parseForComparison(manifestSpecifier)
- const lock = parseForComparison(lockSpecifier)
-
- if (manifest.sourcePart !== lock.sourcePart) {
- return false
- }
-
- if (manifest.path !== lock.path) {
- return false
- }
-
- if (manifest.ref === null) {
- return true
- }
-
- return manifest.ref === lock.ref
-}
-
-function normalizeInstallDir(dir: string | undefined): string {
- return dir ?? '.agents/skills'
-}
-
-function normalizeLinkTargets(targets: string[] | undefined): string[] {
- return targets ?? []
-}
-
-function arraysEqual(a: string[], b: string[]): boolean {
- if (a.length !== b.length) return false
- return a.every((val, i) => val === b[i])
-}
-
-async function isPatchInSync(
- rootDir: string,
- manifest: NormalizedSkillsManifest,
- skillName: string,
- lock: SkillsLock,
-): Promise {
- const lockEntry = lock.skills[skillName]
- if (!lockEntry) {
- return false
- }
-
- const manifestPatchPath = manifest.patchedSkills?.[skillName]
- if (!manifestPatchPath) {
- return lockEntry.patch === undefined
- }
-
- if (!lockEntry.patch) {
- return false
- }
-
- const absolutePatchPath = path.resolve(rootDir, manifestPatchPath)
- const normalizedPatchPath = toPortableRelativePath(rootDir, absolutePatchPath)
-
- if (lockEntry.patch.path !== normalizedPatchPath) {
- return false
- }
-
- return lockEntry.patch.digest === (await sha256File(absolutePatchPath))
-}
-
-export function isSkillsLockEqual(a: SkillsLock, b: SkillsLock): boolean {
- if (a.lockfileVersion !== b.lockfileVersion) return false
- if (normalizeInstallDir(a.installDir) !== normalizeInstallDir(b.installDir)) return false
- if (!arraysEqual(normalizeLinkTargets(a.linkTargets), normalizeLinkTargets(b.linkTargets))) {
- return false
- }
-
- const aSkills = Object.entries(a.skills)
- const bSkills = Object.entries(b.skills)
- if (aSkills.length !== bSkills.length) return false
-
- for (const [name, aEntry] of aSkills) {
- const bEntry = b.skills[name]
- if (!bEntry) return false
- if (JSON.stringify(aEntry) !== JSON.stringify(bEntry)) return false
- }
-
- return true
-}
-
-export async function isLockInSync(
- rootDir: string,
- manifest: NormalizedSkillsManifest,
- lock: SkillsLock | null,
- manifestStat?: ManifestStat | null,
- installState?: { manifestStat?: ManifestStat } | null,
-): Promise {
- if (!lock) return false
-
- if (normalizeInstallDir(manifest.installDir) !== normalizeInstallDir(lock.installDir)) {
- return false
- }
-
- if (
- !arraysEqual(normalizeLinkTargets(manifest.linkTargets), normalizeLinkTargets(lock.linkTargets))
- ) {
- return false
- }
-
- const manifestSkills = Object.entries(manifest.skills)
- const lockSkillNames = Object.keys(lock.skills)
- const patchedSkillNames = Object.keys(manifest.patchedSkills ?? {})
-
- if (manifestSkills.length !== lockSkillNames.length) {
- return false
- }
-
- if (patchedSkillNames.some((skillName) => !(skillName in manifest.skills))) {
- return false
- }
-
- for (const [name, specifier] of manifestSkills) {
- const lockEntry = lock.skills[name]
- if (!lockEntry) return false
- if (!isSpecifierCompatible(specifier, lockEntry.specifier)) return false
- if (!(await isPatchInSync(rootDir, manifest, name, lock))) return false
- }
-
- return true
-}
diff --git a/packages/skills-package-manager/src/config/readSkillsLock.ts b/packages/skills-package-manager/src/config/readSkillsLock.ts
deleted file mode 100644
index cd1bd21..0000000
--- a/packages/skills-package-manager/src/config/readSkillsLock.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { readFile } from 'node:fs/promises'
-import path from 'node:path'
-import YAML from 'yaml'
-import { convertNodeError, ErrorCode, ParseError } from '../errors'
-import type { SkillsLock } from './types'
-
-export async function readSkillsLock(rootDir: string): Promise {
- const filePath = path.join(rootDir, 'skills-lock.yaml')
-
- try {
- const raw = await readFile(filePath, 'utf8')
- try {
- return YAML.parse(raw) as SkillsLock
- } catch (parseError) {
- throw new ParseError({
- code: ErrorCode.YAML_PARSE_ERROR,
- filePath,
- content: raw,
- message: `Failed to parse skills-lock.yaml: ${(parseError as Error).message}`,
- cause: parseError as Error,
- })
- }
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
- return null
- }
- if (error instanceof ParseError) {
- throw error
- }
- throw convertNodeError(error as NodeJS.ErrnoException, { operation: 'read', path: filePath })
- }
-}
diff --git a/packages/skills-package-manager/src/config/syncSkillsLock.ts b/packages/skills-package-manager/src/config/resolveSkillsPlan.ts
similarity index 51%
rename from packages/skills-package-manager/src/config/syncSkillsLock.ts
rename to packages/skills-package-manager/src/config/resolveSkillsPlan.ts
index d277b3e..391a766 100644
--- a/packages/skills-package-manager/src/config/syncSkillsLock.ts
+++ b/packages/skills-package-manager/src/config/resolveSkillsPlan.ts
@@ -1,27 +1,31 @@
import path from 'node:path'
-import type { NormalizedSpecifier } from '../config/types'
import { ErrorCode, ParseError } from '../errors'
import type { ManifestStat } from '../pipeline/types'
import { resolveEntry } from '../resolvers'
import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
import { sha256File } from '../utils/hash'
import { toPortableRelativePath } from '../utils/path'
-import { isLockInSync } from './compareSkillsLock'
+import { expandSkillsManifest } from './skillsManifest'
import type {
InstallProgressListener,
NormalizedSkillsManifest,
- SkillsLock,
- SkillsLockEntry,
+ NormalizedSpecifier,
+ ResolvedSkillEntry,
+ ResolvedSkillsPlan,
} from './types'
-export async function resolveLockEntry(
+export async function resolveSkillEntry(
cwd: string,
specifier: string,
skillName?: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+ options?: { installDir?: string },
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
let normalized: NormalizedSpecifier
try {
- normalized = normalizeSpecifier(specifier)
+ normalized = normalizeSpecifier(specifier, {
+ installDir: options?.installDir,
+ skillName,
+ })
} catch (error) {
if (error instanceof ParseError) {
throw error
@@ -41,8 +45,8 @@ export async function attachManifestPatchToEntry(
cwd: string,
manifest: NormalizedSkillsManifest,
skillName: string,
- entry: SkillsLockEntry,
-): Promise {
+ entry: ResolvedSkillEntry,
+): Promise {
const patchPath = manifest.patchedSkills?.[skillName]
if (!patchPath) {
return entry
@@ -62,52 +66,35 @@ export async function attachManifestPatchToEntry(
}
}
-export async function syncSkillsLock(
+export async function resolveSkillsPlan(
cwd: string,
manifest: NormalizedSkillsManifest,
- existingLock: SkillsLock | null,
options?: {
onProgress?: InstallProgressListener
manifestStat?: ManifestStat | null
installState?: { manifestStat?: ManifestStat } | null
},
-): Promise {
- // Fast path: if existingLock is in sync with manifest, reuse npm/git entries
- // and only re-resolve link/file entries to detect local source changes.
- const reuseEntries = new Map()
- if (
- existingLock &&
- (await isLockInSync(cwd, manifest, existingLock, options?.manifestStat, options?.installState))
- ) {
- for (const [name, entry] of Object.entries(existingLock.skills)) {
- if (entry.resolution.type === 'npm' || entry.resolution.type === 'git') {
- reuseEntries.set(name, entry)
- }
- }
- }
-
+): Promise {
+ const expandedManifest = await expandSkillsManifest(cwd, manifest)
const entries = await Promise.all(
- Object.entries(manifest.skills).map(async ([skillName, specifier]) => {
- const reused = reuseEntries.get(skillName)
- if (reused) {
- const entryWithPatch = await attachManifestPatchToEntry(cwd, manifest, skillName, reused)
- options?.onProgress?.({ type: 'resolved', skillName })
- return [skillName, entryWithPatch] as const
- }
-
- const { skillName: resolvedName, entry } = await resolveLockEntry(cwd, specifier, skillName)
- const entryWithPatch = await attachManifestPatchToEntry(cwd, manifest, resolvedName, entry)
+ Object.entries(expandedManifest.skills).map(async ([skillName, specifier]) => {
+ const { skillName: resolvedName, entry } = await resolveSkillEntry(cwd, specifier, skillName, {
+ installDir: expandedManifest.installDir,
+ })
+ const entryWithPatch = await attachManifestPatchToEntry(
+ cwd,
+ expandedManifest,
+ resolvedName,
+ entry,
+ )
options?.onProgress?.({ type: 'resolved', skillName: resolvedName })
return [resolvedName, entryWithPatch] as const
}),
)
- const nextSkills: Record = Object.fromEntries(entries)
-
return {
- lockfileVersion: '0.1',
- installDir: manifest.installDir ?? '.agents/skills',
- linkTargets: manifest.linkTargets ?? [],
- skills: nextSkills,
+ installDir: expandedManifest.installDir,
+ linkTargets: expandedManifest.linkTargets,
+ skills: Object.fromEntries(entries),
}
}
diff --git a/packages/skills-package-manager/src/config/types.ts b/packages/skills-package-manager/src/config/types.ts
index acfbbd0..1923f1f 100644
--- a/packages/skills-package-manager/src/config/types.ts
+++ b/packages/skills-package-manager/src/config/types.ts
@@ -33,7 +33,7 @@ export type NormalizedSpecifier = {
skillName: string
}
-export type SkillsLockEntry = {
+export type ResolvedSkillEntry = {
specifier: string
resolution:
| { type: 'link'; path: string }
@@ -56,11 +56,10 @@ export type SkillsLockEntry = {
}
}
-export type SkillsLock = {
- lockfileVersion: '0.1'
+export type ResolvedSkillsPlan = {
installDir: string
linkTargets: string[]
- skills: Record
+ skills: Record
}
export type InitCommandOptions = {
@@ -71,10 +70,13 @@ export type InitCommandOptions = {
export type AddCommandOptions = {
cwd: string
specifier: string
- skill?: string
+ skill?: string | string[]
global?: boolean
yes?: boolean
agent?: string[]
+ list?: boolean
+ copy?: boolean
+ all?: boolean
}
export type UpdateCommandOptions = {
@@ -112,13 +114,12 @@ export type UpdateCommandResult = {
status: 'updated' | 'skipped' | 'failed'
updated: string[]
unchanged: string[]
- skipped: Array<{ name: string; reason: 'link-specifier' | 'local-specifier' }>
+ skipped: Array<{ name: string; reason: 'link-specifier' | 'local-specifier' | 'file-specifier' }>
failed: Array<{ name: string; reason: string }>
}
export type InstallCommandOptions = {
cwd: string
- frozenLockfile?: boolean
onProgress?: InstallProgressListener
}
diff --git a/packages/skills-package-manager/src/config/writeSkillsLock.ts b/packages/skills-package-manager/src/config/writeSkillsLock.ts
deleted file mode 100644
index 6514b75..0000000
--- a/packages/skills-package-manager/src/config/writeSkillsLock.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { writeFile } from 'node:fs/promises'
-import path from 'node:path'
-import YAML from 'yaml'
-import { convertNodeError } from '../errors'
-import type { SkillsLock } from './types'
-
-export async function writeSkillsLock(rootDir: string, lockfile: SkillsLock): Promise {
- const filePath = path.join(rootDir, 'skills-lock.yaml')
- try {
- await writeFile(filePath, YAML.stringify(lockfile), 'utf8')
- } catch (error) {
- throw convertNodeError(error as NodeJS.ErrnoException, { operation: 'write', path: filePath })
- }
-}
diff --git a/packages/skills-package-manager/src/errors/codes.ts b/packages/skills-package-manager/src/errors/codes.ts
index 9c37d9b..ca8da45 100644
--- a/packages/skills-package-manager/src/errors/codes.ts
+++ b/packages/skills-package-manager/src/errors/codes.ts
@@ -24,8 +24,6 @@ export enum ErrorCode {
// Config/Manifest errors (4xx)
MANIFEST_NOT_FOUND = 'EMANIFEST',
- LOCKFILE_NOT_FOUND = 'ELOCKFILE',
- LOCKFILE_OUTDATED = 'ELOCKOUTDATED',
MANIFEST_EXISTS = 'EMANIFESTEXISTS',
MANIFEST_VALIDATION_ERROR = 'EMANIFESTVAL',
diff --git a/packages/skills-package-manager/src/errors/index.ts b/packages/skills-package-manager/src/errors/index.ts
index c2d50e3..ac03748 100644
--- a/packages/skills-package-manager/src/errors/index.ts
+++ b/packages/skills-package-manager/src/errors/index.ts
@@ -122,10 +122,7 @@ export function formatErrorForDisplay(error: unknown): string {
output += `\n - npm:@scope/skill-package#path:/skills/my-skill`
}
} else if (error instanceof ManifestError) {
- if (error.code === ErrorCode.LOCKFILE_OUTDATED) {
- output += `\n\nThe lockfile is out of sync with skills.json.`
- output += `\nRun "spm install" to update the lockfile.`
- } else if (error.code === ErrorCode.MANIFEST_VALIDATION_ERROR) {
+ if (error.code === ErrorCode.MANIFEST_VALIDATION_ERROR) {
output += `\n\nPlease fix the validation errors in "${error.filePath}".`
output += `\nRefer to the JSON Schema at: https://unpkg.com/skills-package-manager@latest/skills.schema.json`
}
diff --git a/packages/skills-package-manager/src/errors/types.ts b/packages/skills-package-manager/src/errors/types.ts
index ff45c5d..31bb79f 100644
--- a/packages/skills-package-manager/src/errors/types.ts
+++ b/packages/skills-package-manager/src/errors/types.ts
@@ -103,7 +103,7 @@ export class ParseError extends SpmError {
}
/**
- * Error thrown when manifest or lockfile operations fail
+ * Error thrown when manifest operations fail
*/
export class ManifestError extends SpmError {
readonly filePath: string
@@ -111,8 +111,6 @@ export class ManifestError extends SpmError {
constructor(options: {
code:
| ErrorCode.MANIFEST_NOT_FOUND
- | ErrorCode.LOCKFILE_NOT_FOUND
- | ErrorCode.LOCKFILE_OUTDATED
| ErrorCode.MANIFEST_EXISTS
| ErrorCode.MANIFEST_VALIDATION_ERROR
filePath: string
@@ -121,8 +119,6 @@ export class ManifestError extends SpmError {
}) {
const defaultMessages: Record = {
[ErrorCode.MANIFEST_NOT_FOUND]: `Manifest not found: ${options.filePath}`,
- [ErrorCode.LOCKFILE_NOT_FOUND]: `Lockfile not found: ${options.filePath}`,
- [ErrorCode.LOCKFILE_OUTDATED]: `Lockfile is out of date: ${options.filePath}`,
[ErrorCode.MANIFEST_EXISTS]: `Manifest already exists: ${options.filePath}`,
[ErrorCode.MANIFEST_VALIDATION_ERROR]: `Invalid skills.json: ${options.filePath}`,
}
diff --git a/packages/skills-package-manager/src/fetchers/file.ts b/packages/skills-package-manager/src/fetchers/file.ts
index 3a2c526..6cd170a 100644
--- a/packages/skills-package-manager/src/fetchers/file.ts
+++ b/packages/skills-package-manager/src/fetchers/file.ts
@@ -1,11 +1,11 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { materializePackedSkill } from '../install/materializePackedSkill'
export async function fetchFileSkill(
rootDir: string,
skillName: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
installDir: string,
): Promise {
if (entry.resolution.type !== 'file') {
diff --git a/packages/skills-package-manager/src/fetchers/git.ts b/packages/skills-package-manager/src/fetchers/git.ts
index 288afd9..88c4811 100644
--- a/packages/skills-package-manager/src/fetchers/git.ts
+++ b/packages/skills-package-manager/src/fetchers/git.ts
@@ -1,11 +1,11 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { extractGitSkillToDir, materializeGitSkill } from '../install/materializeGitSkill'
export async function fetchGitSkill(
rootDir: string,
skillName: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
installDir: string,
): Promise {
if (entry.resolution.type !== 'git') {
@@ -26,7 +26,7 @@ export async function fetchGitSkill(
}
export async function extractGitSkillToPath(
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
targetDir: string,
): Promise {
if (entry.resolution.type !== 'git') {
diff --git a/packages/skills-package-manager/src/fetchers/index.ts b/packages/skills-package-manager/src/fetchers/index.ts
index 82a60df..ed7ba53 100644
--- a/packages/skills-package-manager/src/fetchers/index.ts
+++ b/packages/skills-package-manager/src/fetchers/index.ts
@@ -1,4 +1,4 @@
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import type { CacheManager } from '../pipeline/types'
import { fetchFileSkill } from './file'
import { fetchGitSkill } from './git'
@@ -9,7 +9,7 @@ import { fetchNpmSkill } from './npm'
export async function fetchSkill(
rootDir: string,
skillName: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
installDir: string,
cache: CacheManager,
): Promise<{ installPath: string; fromCache?: boolean }> {
diff --git a/packages/skills-package-manager/src/fetchers/link.ts b/packages/skills-package-manager/src/fetchers/link.ts
index d061ab7..d12db92 100644
--- a/packages/skills-package-manager/src/fetchers/link.ts
+++ b/packages/skills-package-manager/src/fetchers/link.ts
@@ -1,12 +1,12 @@
import { rm, symlink } from 'node:fs/promises'
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { ensureDir } from '../utils/fs'
export async function fetchLinkSkill(
rootDir: string,
skillName: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
installDir: string,
): Promise {
if (entry.resolution.type !== 'link') {
diff --git a/packages/skills-package-manager/src/fetchers/local.ts b/packages/skills-package-manager/src/fetchers/local.ts
index 63c7c9f..509c56c 100644
--- a/packages/skills-package-manager/src/fetchers/local.ts
+++ b/packages/skills-package-manager/src/fetchers/local.ts
@@ -1,8 +1,8 @@
import { access } from 'node:fs/promises'
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
-export async function fetchLocalSkill(rootDir: string, entry: SkillsLockEntry): Promise {
+export async function fetchLocalSkill(rootDir: string, entry: ResolvedSkillEntry): Promise {
if (entry.resolution.type !== 'local') {
throw new Error('Expected local resolution')
}
diff --git a/packages/skills-package-manager/src/fetchers/npm.ts b/packages/skills-package-manager/src/fetchers/npm.ts
index 23ff0f9..72d005f 100644
--- a/packages/skills-package-manager/src/fetchers/npm.ts
+++ b/packages/skills-package-manager/src/fetchers/npm.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { materializePackedSkill } from '../install/materializePackedSkill'
import { downloadNpmPackageTarball } from '../npm/packPackage'
import type { CacheManager } from '../pipeline/types'
@@ -10,7 +10,7 @@ const inFlightDownloads = new Map {
@@ -57,10 +57,6 @@ export async function fetchNpmSkill(
return { installPath: path.join(rootDir, installDir, skillName), fromCache }
} finally {
inFlightDownloads.delete(cacheKey)
- // Note: cleanupPackedNpmPackage is not called here because tarballs are stored
- // in a persistent cache directory (see downloadNpmPackageTarball). The old
- // installSkills flow cleaned up temp directories at the end of fetchSkillsFromLock,
- // but the current implementation uses a deterministic persistent cache, so no
- // per-run cleanup is required.
+ // Tarballs are stored in a deterministic persistent cache, so no per-run cleanup is required.
}
}
diff --git a/packages/skills-package-manager/src/github/listSkills.ts b/packages/skills-package-manager/src/github/listSkills.ts
index 57c7ac6..524b356 100644
--- a/packages/skills-package-manager/src/github/listSkills.ts
+++ b/packages/skills-package-manager/src/github/listSkills.ts
@@ -8,7 +8,50 @@ import type { SkillInfo } from './types'
const execFileAsync = promisify(execFile)
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__'])
-const ALLOWED_HIDDEN_DIRS = new Set(['.agents', '.claude', '.github'])
+const ALLOWED_HIDDEN_DIRS = new Set([
+ '.adal',
+ '.aider-desk',
+ '.agents',
+ '.augment',
+ '.bob',
+ '.claude',
+ '.codeartsdoer',
+ '.codebuddy',
+ '.codemaker',
+ '.codestudio',
+ '.commandcode',
+ '.continue',
+ '.cortex',
+ '.crush',
+ '.curated',
+ '.devin',
+ '.experimental',
+ '.factory',
+ '.forge',
+ '.github',
+ '.goose',
+ '.hermes',
+ '.iflow',
+ '.junie',
+ '.kilocode',
+ '.kiro',
+ '.kode',
+ '.mcpjam',
+ '.neovate',
+ '.openhands',
+ '.pi',
+ '.pochi',
+ '.qoder',
+ '.qwen',
+ '.roo',
+ '.rovodev',
+ '.system',
+ '.tabnine',
+ '.trae',
+ '.vibe',
+ '.windsurf',
+ '.zencoder',
+])
function parseSkillFrontmatter(content: string): { name: string; description: string } {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
diff --git a/packages/skills-package-manager/src/index.ts b/packages/skills-package-manager/src/index.ts
index b11c149..4b01e96 100644
--- a/packages/skills-package-manager/src/index.ts
+++ b/packages/skills-package-manager/src/index.ts
@@ -8,12 +8,10 @@ export { installCommand } from './commands/install'
export { patchCommand } from './commands/patch'
export { patchCommitCommand } from './commands/patchCommit'
export { updateCommand } from './commands/update'
-export { isLockInSync } from './config/compareSkillsLock'
-export { readSkillsLock } from './config/readSkillsLock'
export { readSkillsManifest } from './config/readSkillsManifest'
+export { resolveSkillEntry, resolveSkillsPlan } from './config/resolveSkillsPlan'
export { expandSkillsManifest, normalizeSkillsManifest } from './config/skillsManifest'
// Config
-export { resolveLockEntry } from './config/syncSkillsLock'
export type {
AddCommandOptions,
InitCommandOptions,
@@ -25,13 +23,12 @@ export type {
PatchCommandResult,
PatchCommitCommandOptions,
PatchCommitCommandResult,
- SkillsLock,
- SkillsLockEntry,
+ ResolvedSkillsPlan,
+ ResolvedSkillEntry,
SkillsManifest,
UpdateCommandOptions,
UpdateCommandResult,
} from './config/types'
-export { writeSkillsLock } from './config/writeSkillsLock'
export { writeSkillsManifest } from './config/writeSkillsManifest'
// Errors
export {
@@ -59,13 +56,9 @@ export {
} from './github/listSkills'
export type { SkillInfo } from './github/types'
// Install
-export {
- fetchSkillsFromLock,
- installSkills,
- linkSkillsFromLock,
-} from './install/installSkills'
+export { installSkills } from './install/installSkills'
+export { installStageHooks } from './install/installPlan'
export { createInstallProgressReporter } from './install/progressReporter'
-export { installStageHooks, withBundledSelfSkillLock } from './install/withBundledSelfSkillLock'
// Specifiers
export { normalizeSpecifier } from './specifiers/normalizeSpecifier'
export { parseSpecifier } from './specifiers/parseSpecifier'
diff --git a/packages/skills-package-manager/src/install/extractSkillToDir.ts b/packages/skills-package-manager/src/install/extractSkillToDir.ts
index b70b9f4..bc795f2 100644
--- a/packages/skills-package-manager/src/install/extractSkillToDir.ts
+++ b/packages/skills-package-manager/src/install/extractSkillToDir.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { cleanupPackedNpmPackage, downloadNpmPackageTarball } from '../npm/packPackage'
import { extractGitSkillToDir } from './materializeGitSkill'
import { copyLocalSkillToDir } from './materializeLocalSkill'
@@ -7,7 +7,7 @@ import { extractPackedSkillToDir } from './materializePackedSkill'
export async function extractSkillToDir(
rootDir: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
targetDir: string,
) {
if (entry.resolution.type === 'link') {
diff --git a/packages/skills-package-manager/src/install/installPlan.ts b/packages/skills-package-manager/src/install/installPlan.ts
new file mode 100644
index 0000000..91245fd
--- /dev/null
+++ b/packages/skills-package-manager/src/install/installPlan.ts
@@ -0,0 +1,5 @@
+import type { ResolvedSkillsPlan, SkillsManifest } from '../config/types'
+
+export const installStageHooks = {
+ beforeFetch: async (_rootDir: string, _manifest: SkillsManifest, _plan: ResolvedSkillsPlan) => {},
+}
diff --git a/packages/skills-package-manager/src/install/installSkills.ts b/packages/skills-package-manager/src/install/installSkills.ts
index a869dac..24f53c7 100644
--- a/packages/skills-package-manager/src/install/installSkills.ts
+++ b/packages/skills-package-manager/src/install/installSkills.ts
@@ -1,189 +1,9 @@
-import { access } from 'node:fs/promises'
-import path from 'node:path'
-import { isLockInSync } from '../config/compareSkillsLock'
-import { readSkillsLock } from '../config/readSkillsLock'
-import { readSkillsManifest } from '../config/readSkillsManifest'
-import {
- getBundledSelfSkillSpecifier,
- SELF_SKILL_NAME,
- shouldInjectBundledSelfSkill,
-} from '../config/skillsManifest'
-import { resolveLockEntry, syncSkillsLock } from '../config/syncSkillsLock'
-import type { InstallProgressListener, SkillsLock, SkillsManifest } from '../config/types'
-import { writeSkillsLock } from '../config/writeSkillsLock'
-import { fetchSkill } from '../fetchers'
-import { applySkillPatch } from '../patches/skillPatch'
-import { createFileSystemCache } from '../pipeline/cache'
-import { sha256 } from '../utils/hash'
-import { readInstallState, writeInstallState } from './installState'
-import { linkSkill } from './links'
-import {
- ensureLocalSkillGitignoreRules,
- getLocalSkillDirs,
- getSkillInstallPath,
-} from './localSkills'
-import { pruneManagedSkills } from './pruneManagedSkills'
-
-async function areManagedSkillsInstalled(
- rootDir: string,
- installDir: string,
- skillNames: string[],
-): Promise {
- for (const skillName of skillNames) {
- try {
- await access(path.join(rootDir, installDir, skillName, 'SKILL.md'))
- } catch {
- return false
- }
- }
- return true
-}
-
-export async function withBundledSelfSkillLock(
- rootDir: string,
- manifest: SkillsManifest,
- lockfile: SkillsLock,
-): Promise {
- if (!shouldInjectBundledSelfSkill(manifest) || lockfile.skills[SELF_SKILL_NAME]) {
- return lockfile
- }
-
- const { entry } = await resolveLockEntry(rootDir, getBundledSelfSkillSpecifier(), SELF_SKILL_NAME)
-
- return {
- ...lockfile,
- skills: {
- ...lockfile.skills,
- [SELF_SKILL_NAME]: entry,
- },
- }
-}
-
-export async function fetchSkillsFromLock(
- rootDir: string,
- manifest: SkillsManifest,
- lockfile: SkillsLock,
- options?: {
- onProgress?: InstallProgressListener
- },
-) {
- const installDir = manifest.installDir ?? '.agents/skills'
- const linkTargets = manifest.linkTargets ?? []
-
- await ensureLocalSkillGitignoreRules(rootDir, lockfile)
- await pruneManagedSkills(
- rootDir,
- installDir,
- linkTargets,
- Object.keys(lockfile.skills),
- getLocalSkillDirs(rootDir, [lockfile]),
- )
-
- const lockDigest = sha256(JSON.stringify(lockfile))
- const state = await readInstallState(rootDir, installDir)
- if (
- state?.lockDigest === lockDigest &&
- (await areManagedSkillsInstalled(rootDir, installDir, Object.keys(lockfile.skills)))
- ) {
- return { status: 'skipped', reason: 'up-to-date' } as const
- }
-
- const cache = createFileSystemCache(rootDir)
-
- try {
- for (const [skillName, entry] of Object.entries(lockfile.skills)) {
- const { installPath } = await fetchSkill(rootDir, skillName, entry, installDir, cache)
- if (entry.patch) {
- await applySkillPatch(installPath, path.resolve(rootDir, entry.patch.path))
- }
- options?.onProgress?.({ type: 'added', skillName })
- }
-
- await writeInstallState(rootDir, installDir, {
- lockDigest,
- installDir,
- linkTargets,
- installerVersion: '0.1.0',
- installedAt: new Date().toISOString(),
- })
- } catch (error) {
- throw error
- }
-
- return { status: 'fetched', fetched: Object.keys(lockfile.skills) } as const
-}
-
-export async function linkSkillsFromLock(
- rootDir: string,
- manifest: SkillsManifest,
- lockfile: SkillsLock,
- options?: {
- onProgress?: InstallProgressListener
- },
-) {
- const installDir = manifest.installDir ?? '.agents/skills'
- const linkTargets = manifest.linkTargets ?? []
-
- for (const skillName of Object.keys(lockfile.skills)) {
- for (const linkTarget of linkTargets) {
- await linkSkill(
- rootDir,
- installDir,
- linkTarget,
- skillName,
- getSkillInstallPath(rootDir, installDir, skillName, lockfile.skills[skillName]),
- )
- }
- options?.onProgress?.({ type: 'installed', skillName })
- }
-
- return { status: 'linked', linked: Object.keys(lockfile.skills) } as const
-}
+import { installCommand } from '../commands/install'
+import type { InstallProgressListener } from '../config/types'
export async function installSkills(
rootDir: string,
- options?: { frozenLockfile?: boolean; onProgress?: InstallProgressListener },
+ options?: { onProgress?: InstallProgressListener },
) {
- const manifest = await readSkillsManifest(rootDir)
- if (!manifest) {
- return { status: 'skipped', reason: 'manifest-missing' } as const
- }
-
- const currentLock = await readSkillsLock(rootDir)
-
- let lockfile: SkillsLock
-
- if (options?.frozenLockfile) {
- if (!currentLock) {
- throw new Error('Lockfile is required in frozen mode but none was found')
- }
- if (!(await isLockInSync(rootDir, manifest, currentLock))) {
- throw new Error(
- 'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.',
- )
- }
- lockfile = currentLock
- for (const skillName of Object.keys(lockfile.skills)) {
- options?.onProgress?.({ type: 'resolved', skillName })
- }
- } else {
- lockfile = await syncSkillsLock(rootDir, manifest, currentLock, {
- onProgress: options?.onProgress,
- })
- }
-
- const runtimeLock = await withBundledSelfSkillLock(rootDir, manifest, lockfile)
-
- await fetchSkillsFromLock(rootDir, manifest, runtimeLock, {
- onProgress: options?.onProgress,
- })
- await linkSkillsFromLock(rootDir, manifest, runtimeLock, {
- onProgress: options?.onProgress,
- })
-
- if (!options?.frozenLockfile) {
- await writeSkillsLock(rootDir, lockfile)
- }
-
- return { status: 'installed', installed: Object.keys(runtimeLock.skills) } as const
+ return installCommand({ cwd: rootDir, onProgress: options?.onProgress })
}
diff --git a/packages/skills-package-manager/src/install/localSkills.ts b/packages/skills-package-manager/src/install/localSkills.ts
index 1485cd2..1ab2a69 100644
--- a/packages/skills-package-manager/src/install/localSkills.ts
+++ b/packages/skills-package-manager/src/install/localSkills.ts
@@ -1,19 +1,19 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
-import type { SkillsLock, SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillsPlan, ResolvedSkillEntry } from '../config/types'
export function getLocalSkillDirs(
rootDir: string,
- lockfiles: Array,
+ plans: Array,
) {
const dirs: string[] = []
- for (const lockfile of lockfiles) {
- if (!lockfile) {
+ for (const plan of plans) {
+ if (!plan) {
continue
}
- for (const entry of Object.values(lockfile.skills)) {
+ for (const entry of Object.values(plan.skills)) {
if (entry.resolution.type === 'local') {
dirs.push(path.resolve(rootDir, entry.resolution.path))
}
@@ -27,7 +27,7 @@ export function getSkillInstallPath(
rootDir: string,
installDir: string,
skillName: string,
- entry: SkillsLockEntry,
+ entry: ResolvedSkillEntry,
) {
return entry.resolution.type === 'local'
? path.resolve(rootDir, entry.resolution.path)
@@ -60,9 +60,9 @@ function createUnignoreRules(relativePath: string): string[] {
return rules
}
-export async function ensureLocalSkillGitignoreRules(rootDir: string, lockfile: SkillsLock) {
+export async function ensureLocalSkillGitignoreRules(rootDir: string, plan: ResolvedSkillsPlan) {
const desiredRules = new Set()
- for (const dir of getLocalSkillDirs(rootDir, [lockfile])) {
+ for (const dir of getLocalSkillDirs(rootDir, [plan])) {
const relativePath = toRepoRelativePath(rootDir, dir)
if (!relativePath) {
continue
diff --git a/packages/skills-package-manager/src/install/withBundledSelfSkillLock.ts b/packages/skills-package-manager/src/install/withBundledSelfSkillLock.ts
deleted file mode 100644
index 3ccce14..0000000
--- a/packages/skills-package-manager/src/install/withBundledSelfSkillLock.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import {
- getBundledSelfSkillSpecifier,
- SELF_SKILL_NAME,
- shouldInjectBundledSelfSkill,
-} from '../config/skillsManifest'
-import { resolveLockEntry } from '../config/syncSkillsLock'
-import type { SkillsLock, SkillsManifest } from '../config/types'
-
-export const installStageHooks = {
- beforeFetch: async (_rootDir: string, _manifest: SkillsManifest, _lockfile: SkillsLock) => {},
-}
-
-export async function withBundledSelfSkillLock(
- rootDir: string,
- manifest: SkillsManifest,
- lockfile: SkillsLock,
-): Promise {
- if (!shouldInjectBundledSelfSkill(manifest) || lockfile.skills[SELF_SKILL_NAME]) {
- return lockfile
- }
-
- const { entry } = await resolveLockEntry(rootDir, getBundledSelfSkillSpecifier(), SELF_SKILL_NAME)
-
- return {
- ...lockfile,
- skills: {
- ...lockfile.skills,
- [SELF_SKILL_NAME]: entry,
- },
- }
-}
diff --git a/packages/skills-package-manager/src/patches/skillPatch.ts b/packages/skills-package-manager/src/patches/skillPatch.ts
index 2e6e384..6f14516 100644
--- a/packages/skills-package-manager/src/patches/skillPatch.ts
+++ b/packages/skills-package-manager/src/patches/skillPatch.ts
@@ -3,7 +3,7 @@ import { access, cp, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/p
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { convertNodeError, ErrorCode, GitError, ParseError } from '../errors'
const execFileAsync = promisify(execFile)
@@ -14,7 +14,7 @@ export type PatchEditState = {
version: 1
skillName: string
originalSpecifier: string
- baseEntry: SkillsLockEntry
+ baseEntry: ResolvedSkillEntry
}
export async function writePatchEditState(editDir: string, state: PatchEditState) {
diff --git a/packages/skills-package-manager/src/pipeline/context.ts b/packages/skills-package-manager/src/pipeline/context.ts
index 8ec75ce..b6f8c18 100644
--- a/packages/skills-package-manager/src/pipeline/context.ts
+++ b/packages/skills-package-manager/src/pipeline/context.ts
@@ -1,6 +1,5 @@
import { lstat } from 'node:fs/promises'
import path from 'node:path'
-import { readSkillsLock } from '../config/readSkillsLock'
import { readSkillsManifest } from '../config/readSkillsManifest'
import type { NormalizedSkillsManifest } from '../config/types'
import { readInstallState } from '../install/installState'
@@ -10,7 +9,6 @@ import type { InstallState, ManifestStat, WorkspaceContext } from './types'
export async function loadConfig(cwd: string): Promise {
const manifest = await readSkillsManifest(cwd)
- const lockfile = await readSkillsLock(cwd)
const npmConfig = await loadNpmConfig(cwd)
const installDir = manifest?.installDir ?? '.agents/skills'
const installState = await readInstallState(cwd, installDir)
@@ -28,7 +26,6 @@ export async function loadConfig(cwd: string): Promise {
cwd: path.resolve(cwd),
manifest: normalizeManifest(manifest),
manifestExists: manifest !== null,
- lockfile,
npmConfig,
installState: installState as InstallState | null,
manifestStat,
diff --git a/packages/skills-package-manager/src/pipeline/fetchQueue.ts b/packages/skills-package-manager/src/pipeline/fetchQueue.ts
index aaa5556..a15d30b 100644
--- a/packages/skills-package-manager/src/pipeline/fetchQueue.ts
+++ b/packages/skills-package-manager/src/pipeline/fetchQueue.ts
@@ -52,9 +52,9 @@ async function isSkillUpToDate(
export function createFetchTaskQueue(
ctx: WorkspaceContext,
bus: PipelineBus,
- options: { concurrency: number; maxPending?: number },
+ options: { concurrency: number; maxPending?: number; installDir: string },
): FetchQueue {
- const installDir = ctx.lockfile?.installDir ?? ctx.manifest.installDir ?? '.agents/skills'
+ const { installDir } = options
async function processor(task: FetchTask): Promise {
if (await isSkillUpToDate(ctx.cwd, installDir, task.skillName, task.entry)) {
diff --git a/packages/skills-package-manager/src/pipeline/index.ts b/packages/skills-package-manager/src/pipeline/index.ts
index d806942..feb4504 100644
--- a/packages/skills-package-manager/src/pipeline/index.ts
+++ b/packages/skills-package-manager/src/pipeline/index.ts
@@ -1,11 +1,11 @@
import { access, lstat, readlink } from 'node:fs/promises'
import path from 'node:path'
-import type { SkillsLock, SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillsPlan, ResolvedSkillEntry } from '../config/types'
import { ErrorCode, getErrorMessage, SpmError } from '../errors'
import { writeInstallState } from '../install/installState'
import { ensureLocalSkillGitignoreRules, getLocalSkillDirs } from '../install/localSkills'
+import { installStageHooks } from '../install/installPlan'
import { pruneManagedSkills } from '../install/pruneManagedSkills'
-import { installStageHooks } from '../install/withBundledSelfSkillLock'
import { sha256 } from '../utils/hash'
import { createPipelineBus } from './bus'
import { createFetchTaskQueue } from './fetchQueue'
@@ -15,7 +15,7 @@ import type { PipelineOptions, PipelineResult, WorkspaceContext } from './types'
export interface RunPipelineInput {
ctx: WorkspaceContext
- entries: Record
+ plan: ResolvedSkillsPlan
skipResolve?: boolean
options?: PipelineOptions
}
@@ -61,27 +61,24 @@ async function areLinksUpToDate(
}
export async function runPipeline(input: RunPipelineInput): Promise {
- const { ctx, entries, skipResolve = false, options = {} } = input
+ const { ctx, plan, skipResolve = false, options = {} } = input
+ const { skills: entries, installDir, linkTargets } = plan
const bus = createPipelineBus(options.onProgress)
const errors: unknown[] = []
- const installDir = ctx.lockfile?.installDir ?? ctx.manifest.installDir ?? '.agents/skills'
- const linkTargets = ctx.lockfile?.linkTargets ?? ctx.manifest.linkTargets ?? []
-
// Fast path: skip all work when install state is up-to-date
const sortedSkillNames = Object.keys(entries).sort()
const sortedEntries = Object.fromEntries(
sortedSkillNames.map((skillName) => [skillName, entries[skillName]]),
- ) as Record
- const lockfileForDigest: SkillsLock = {
- lockfileVersion: '0.1',
+ ) as Record
+ const planForDigest: ResolvedSkillsPlan = {
installDir,
linkTargets,
skills: sortedEntries,
}
- const currentDigest = sha256(JSON.stringify(lockfileForDigest))
+ const currentDigest = sha256(JSON.stringify(planForDigest))
if (
- ctx.installState?.lockDigest === currentDigest &&
+ ctx.installState?.planDigest === currentDigest &&
(await areManagedSkillsInstalled(ctx.cwd, installDir, sortedSkillNames)) &&
(await areLinksUpToDate(ctx.cwd, installDir, linkTargets, sortedSkillNames))
) {
@@ -100,11 +97,14 @@ export async function runPipeline(input: RunPipelineInput): Promise 0 || results.fetched.length > 0 || skipResolve) {
- const lockfile: SkillsLock = {
- lockfileVersion: '0.1',
+ const installedPlan: ResolvedSkillsPlan = {
installDir,
linkTargets,
skills: skipResolve
- ? entries
+ ? sortedEntries
: Object.fromEntries(
results.resolved
.slice()
@@ -218,9 +206,9 @@ export async function runPipeline(input: RunPipelineInput): Promise [r.skillName, r.entry]),
),
}
- const lockDigest = sha256(JSON.stringify(lockfile))
+ const planDigest = sha256(JSON.stringify(installedPlan))
await writeInstallState(ctx.cwd, installDir, {
- lockDigest,
+ planDigest,
manifestStat: ctx.manifestStat ?? undefined,
installDir,
linkTargets,
diff --git a/packages/skills-package-manager/src/pipeline/linkQueue.ts b/packages/skills-package-manager/src/pipeline/linkQueue.ts
index c704acc..7ee570c 100644
--- a/packages/skills-package-manager/src/pipeline/linkQueue.ts
+++ b/packages/skills-package-manager/src/pipeline/linkQueue.ts
@@ -8,10 +8,9 @@ export type LinkQueue = TaskQueue
export function createLinkTaskQueue(
ctx: WorkspaceContext,
bus: PipelineBus,
- options: { concurrency: number; maxPending?: number },
+ options: { concurrency: number; maxPending?: number; installDir: string; linkTargets: string[] },
): LinkQueue {
- const installDir = ctx.lockfile?.installDir ?? ctx.manifest.installDir ?? '.agents/skills'
- const linkTargets = ctx.lockfile?.linkTargets ?? ctx.manifest.linkTargets ?? []
+ const { installDir, linkTargets } = options
async function processor(task: LinkTask): Promise {
try {
diff --git a/packages/skills-package-manager/src/pipeline/resolveQueue.ts b/packages/skills-package-manager/src/pipeline/resolveQueue.ts
index 21f1b38..f0fea62 100644
--- a/packages/skills-package-manager/src/pipeline/resolveQueue.ts
+++ b/packages/skills-package-manager/src/pipeline/resolveQueue.ts
@@ -15,7 +15,10 @@ export function createResolveTaskQueue(
async function processor(task: ResolveTask): Promise {
let normalized: NormalizedSpecifier
try {
- normalized = normalizeSpecifier(task.specifier)
+ normalized = normalizeSpecifier(task.specifier, {
+ installDir: _ctx.manifest.installDir,
+ skillName: task.skillName,
+ })
} catch (error) {
if (error instanceof ParseError) {
throw error
diff --git a/packages/skills-package-manager/src/pipeline/types.ts b/packages/skills-package-manager/src/pipeline/types.ts
index e0686a9..14c01e2 100644
--- a/packages/skills-package-manager/src/pipeline/types.ts
+++ b/packages/skills-package-manager/src/pipeline/types.ts
@@ -1,8 +1,8 @@
import type {
InstallProgressEvent,
NormalizedSkillsManifest,
- SkillsLock,
- SkillsLockEntry,
+ ResolvedSkillsPlan,
+ ResolvedSkillEntry,
} from '../config/types'
import type { NpmConfig } from '../npm/packPackage'
@@ -31,7 +31,6 @@ export interface WorkspaceContext {
cwd: string
manifest: NormalizedSkillsManifest
manifestExists: boolean
- lockfile: SkillsLock | null
npmConfig: NpmConfig
installState: InstallState | null
manifestStat: ManifestStat | null
@@ -39,7 +38,7 @@ export interface WorkspaceContext {
}
export interface InstallState {
- lockDigest: string
+ planDigest: string
manifestStat?: ManifestStat
installDir: string
linkTargets: string[]
@@ -58,17 +57,17 @@ export interface ResolveTask {
export interface ResolveResult {
skillName: string
- entry: SkillsLockEntry
+ entry: ResolvedSkillEntry
}
export interface FetchTask {
skillName: string
- entry: SkillsLockEntry
+ entry: ResolvedSkillEntry
}
export interface FetchResult {
skillName: string
- entry: SkillsLockEntry
+ entry: ResolvedSkillEntry
installPath: string
fromCache?: boolean
skipped?: boolean
@@ -76,7 +75,7 @@ export interface FetchResult {
export interface LinkTask {
skillName: string
- entry: SkillsLockEntry
+ entry: ResolvedSkillEntry
installPath: string
}
diff --git a/packages/skills-package-manager/src/resolvers/file.ts b/packages/skills-package-manager/src/resolvers/file.ts
index f273919..c94b29f 100644
--- a/packages/skills-package-manager/src/resolvers/file.ts
+++ b/packages/skills-package-manager/src/resolvers/file.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { sha256File } from '../utils/hash'
import { toPortableRelativePath } from '../utils/path'
@@ -9,7 +9,7 @@ export async function resolveFileEntry(
pathSegment: string,
skillName: string,
specifier: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const tarballPath = path.resolve(cwd, source.slice('file:'.length))
return {
skillName,
diff --git a/packages/skills-package-manager/src/resolvers/git.ts b/packages/skills-package-manager/src/resolvers/git.ts
index 9eee831..3343a84 100644
--- a/packages/skills-package-manager/src/resolvers/git.ts
+++ b/packages/skills-package-manager/src/resolvers/git.ts
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { ErrorCode, GitError } from '../errors'
import { sha256 } from '../utils/hash'
@@ -71,7 +71,7 @@ export async function resolveGitEntry(
path: string,
skillName: string,
specifier: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const commit = await resolveGitCommit(source, ref)
return {
skillName,
diff --git a/packages/skills-package-manager/src/resolvers/index.ts b/packages/skills-package-manager/src/resolvers/index.ts
index 8e4cfb6..48a3751 100644
--- a/packages/skills-package-manager/src/resolvers/index.ts
+++ b/packages/skills-package-manager/src/resolvers/index.ts
@@ -1,4 +1,4 @@
-import type { NormalizedSpecifier, SkillsLockEntry } from '../config/types'
+import type { NormalizedSpecifier, ResolvedSkillEntry } from '../config/types'
import { resolveFileEntry } from './file'
import { resolveGitEntry } from './git'
import { resolveLinkEntry } from './link'
@@ -9,7 +9,7 @@ export async function resolveEntry(
cwd: string,
normalized: NormalizedSpecifier,
skillName?: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const finalSkillName = skillName || normalized.skillName
switch (normalized.type) {
diff --git a/packages/skills-package-manager/src/resolvers/link.ts b/packages/skills-package-manager/src/resolvers/link.ts
index f3b55c4..4da5c11 100644
--- a/packages/skills-package-manager/src/resolvers/link.ts
+++ b/packages/skills-package-manager/src/resolvers/link.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { toPortableRelativePath } from '../utils/path'
export async function resolveLinkEntry(
@@ -7,7 +7,7 @@ export async function resolveLinkEntry(
source: string,
skillName: string,
specifier: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const sourceRoot = path.resolve(cwd, source.slice('link:'.length))
return {
skillName,
diff --git a/packages/skills-package-manager/src/resolvers/local.ts b/packages/skills-package-manager/src/resolvers/local.ts
index 4a711d2..524b2ec 100644
--- a/packages/skills-package-manager/src/resolvers/local.ts
+++ b/packages/skills-package-manager/src/resolvers/local.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { toPortableRelativePath } from '../utils/path'
export async function resolveLocalEntry(
@@ -7,7 +7,7 @@ export async function resolveLocalEntry(
source: string,
skillName: string,
specifier: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const sourceRoot = path.resolve(cwd, source.slice('local:'.length))
return {
skillName,
diff --git a/packages/skills-package-manager/src/resolvers/npm.ts b/packages/skills-package-manager/src/resolvers/npm.ts
index b2f7684..081fc95 100644
--- a/packages/skills-package-manager/src/resolvers/npm.ts
+++ b/packages/skills-package-manager/src/resolvers/npm.ts
@@ -1,4 +1,4 @@
-import type { SkillsLockEntry } from '../config/types'
+import type { ResolvedSkillEntry } from '../config/types'
import { resolveNpmPackage } from '../npm/packPackage'
import { sha256 } from '../utils/hash'
@@ -8,7 +8,7 @@ export async function resolveNpmEntry(
path: string,
skillName: string,
specifier: string,
-): Promise<{ skillName: string; entry: SkillsLockEntry }> {
+): Promise<{ skillName: string; entry: ResolvedSkillEntry }> {
const packageSpecifier = source.slice('npm:'.length)
const resolved = await resolveNpmPackage(cwd, packageSpecifier)
diff --git a/packages/skills-package-manager/src/specifiers/normalizeSpecifier.ts b/packages/skills-package-manager/src/specifiers/normalizeSpecifier.ts
index 6361a8c..33e8621 100644
--- a/packages/skills-package-manager/src/specifiers/normalizeSpecifier.ts
+++ b/packages/skills-package-manager/src/specifiers/normalizeSpecifier.ts
@@ -4,7 +4,55 @@ import { ErrorCode, ParseError } from '../errors'
import { normalizeLinkSource, normalizeLocalSource } from './normalizeLinkSource'
import { parseSpecifier } from './parseSpecifier'
-export function normalizeSpecifier(specifier: string): NormalizedSpecifier {
+type NormalizeSpecifierOptions = {
+ installDir?: string
+ skillName?: string
+}
+
+function normalizeSkillPath(skillPath: string): string {
+ if (!skillPath) {
+ return '/'
+ }
+ const normalized = skillPath.replace(/\\/g, '/')
+ return normalized.startsWith('/') ? normalized : `/${normalized}`
+}
+
+function normalizeGitHubSource(sourcePart: string): {
+ source: string
+ normalizedSource: string
+} | null {
+ const match = sourcePart.match(/^github:([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
+ if (!match) {
+ return null
+ }
+
+ const [, owner, repo] = match
+ const cleanRepo = repo.replace(/\.git$/, '')
+ return {
+ source: `https://github.com/${owner}/${cleanRepo}.git`,
+ normalizedSource: `github:${owner}/${cleanRepo}`,
+ }
+}
+
+function inferRootSkillName(type: NormalizedSpecifier['type'], sourcePart: string): string {
+ if (type === 'npm') {
+ const packageName = sourcePart.slice('npm:'.length).replace(/@[^@/]+$/, '')
+ return path.posix.basename(packageName)
+ }
+
+ if (type === 'git') {
+ const githubSource = normalizeGitHubSource(sourcePart)
+ const source = githubSource?.normalizedSource ?? sourcePart.replace(/\.git\/?$/, '')
+ return path.posix.basename(source)
+ }
+
+ return ''
+}
+
+export function normalizeSpecifier(
+ specifier: string,
+ options: NormalizeSpecifierOptions = {},
+): NormalizedSpecifier {
if (
(specifier.startsWith('link:') || specifier.startsWith('local:')) &&
specifier.includes('#')
@@ -46,7 +94,14 @@ export function normalizeSpecifier(specifier: string): NormalizedSpecifier {
const localSource =
type === 'link'
? normalizeLinkSource(parsed.sourcePart)
- : normalizeLocalSource(parsed.sourcePart)
+ : parsed.sourcePart === 'local:*'
+ ? normalizeLocalSource(
+ `local:${path.posix.join(
+ options.installDir ?? '.agents/skills',
+ options.skillName ?? '*',
+ )}`,
+ )
+ : normalizeLocalSource(parsed.sourcePart)
const localPath = localSource.slice(`${type}:`.length)
const skillName = path.posix.basename(localPath)
@@ -60,17 +115,21 @@ export function normalizeSpecifier(specifier: string): NormalizedSpecifier {
}
}
- const skillPath = parsed.path || '/'
- const skillName = path.posix.basename(skillPath)
+ const skillPath = normalizeSkillPath(parsed.path)
+ const skillName =
+ skillPath === '/' ? options.skillName ?? inferRootSkillName(type, parsed.sourcePart) : path.posix.basename(skillPath)
+ const githubSource = type === 'git' ? normalizeGitHubSource(parsed.sourcePart) : null
+ const source = githubSource?.source ?? parsed.sourcePart
+ const normalizedSource = githubSource?.normalizedSource ?? parsed.sourcePart
const normalized = parsed.ref
- ? `${parsed.sourcePart}#${parsed.ref}&path:${skillPath}`
+ ? `${normalizedSource}#${parsed.ref}&path:${skillPath}`
: parsed.path
- ? `${parsed.sourcePart}#path:${skillPath}`
- : parsed.sourcePart
+ ? `${normalizedSource}&path:${skillPath}`
+ : normalizedSource
return {
type,
- source: parsed.sourcePart,
+ source,
ref: parsed.ref,
path: skillPath,
normalized,
diff --git a/packages/skills-package-manager/src/specifiers/parseSpecifier.ts b/packages/skills-package-manager/src/specifiers/parseSpecifier.ts
index b991811..bf78b21 100644
--- a/packages/skills-package-manager/src/specifiers/parseSpecifier.ts
+++ b/packages/skills-package-manager/src/specifiers/parseSpecifier.ts
@@ -13,8 +13,15 @@ export function parseSpecifier(specifier: string) {
}
const hashIndex = firstHashIndex
- const sourcePart = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier
+ let sourcePart = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier
const fragment = hashIndex >= 0 ? specifier.slice(hashIndex + 1) : ''
+ let sourcePath = ''
+
+ const sourcePathIndex = sourcePart.indexOf('&path:')
+ if (sourcePathIndex >= 0) {
+ sourcePath = sourcePart.slice(sourcePathIndex + '&path:'.length)
+ sourcePart = sourcePart.slice(0, sourcePathIndex)
+ }
if (!sourcePart) {
throw new ParseError({
@@ -28,7 +35,7 @@ export function parseSpecifier(specifier: string) {
return {
sourcePart,
ref: null,
- path: '',
+ path: sourcePath,
}
}
@@ -50,6 +57,6 @@ export function parseSpecifier(specifier: string) {
return {
sourcePart,
ref,
- path: parsedPath,
+ path: parsedPath || sourcePath,
}
}
diff --git a/packages/skills-package-manager/test/add.test.ts b/packages/skills-package-manager/test/add.test.ts
index 947b3a7..3a56c28 100644
--- a/packages/skills-package-manager/test/add.test.ts
+++ b/packages/skills-package-manager/test/add.test.ts
@@ -2,8 +2,8 @@ import { execSync } from 'node:child_process'
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
+import { pathToFileURL } from 'node:url'
import { describe, expect, it } from '@rstest/core'
-import YAML from 'yaml'
import { addCommand, normalizeAddCommandInput, parseAddSourceSpecifier } from '../src/commands/add'
import { normalizeLinkSource } from '../src/specifiers/normalizeLinkSource'
import { createSkillPackage, packDirectory, startMockNpmRegistry } from './helpers'
@@ -34,6 +34,18 @@ describe('normalizeAddCommandInput', () => {
})
})
+ it('preserves repeated explicit --skill values', () => {
+ expect(
+ normalizeAddCommandInput('inference-sh/skills@other-skill', [
+ 'landing-page-design',
+ 'skill-creator',
+ ]),
+ ).toEqual({
+ specifier: 'inference-sh/skills',
+ skill: ['landing-page-design', 'skill-creator'],
+ })
+ })
+
it('does not split non-repository specifiers that contain @', () => {
expect(normalizeAddCommandInput('npm:@scope/skills-package')).toEqual({
specifier: 'npm:@scope/skills-package',
@@ -84,10 +96,16 @@ describe('parseAddSourceSpecifier', () => {
})
})
- it('rejects ambiguous GitHub tree URLs without an explicit ref', () => {
- expect(() =>
+ it('parses GitHub tree URLs without an explicit ref using the first path segment as the ref', () => {
+ expect(
parseAddSourceSpecifier('https://github.com/owner/repo/tree/main/skills/my-skill'),
- ).toThrow('Ambiguous GitHub tree URL')
+ ).toEqual({
+ type: 'repo',
+ cloneUrl: 'https://github.com/owner/repo.git',
+ displaySource: 'owner/repo',
+ ref: 'main',
+ subpath: 'skills/my-skill',
+ })
})
it('parses GitLab tree URLs with an explicit slash-containing ref', () => {
@@ -104,10 +122,16 @@ describe('parseAddSourceSpecifier', () => {
})
})
- it('rejects ambiguous GitLab tree URLs without an explicit ref', () => {
- expect(() =>
+ it('parses GitLab tree URLs without an explicit ref using the first path segment as the ref', () => {
+ expect(
parseAddSourceSpecifier('https://gitlab.com/group/subgroup/repo/-/tree/main/skills/my-skill'),
- ).toThrow('Ambiguous GitLab tree URL')
+ ).toEqual({
+ type: 'repo',
+ cloneUrl: 'https://gitlab.com/group/subgroup/repo.git',
+ displaySource: 'group/subgroup/repo',
+ ref: 'main',
+ subpath: 'skills/my-skill',
+ })
})
it('parses generic git URLs with refs', () => {
@@ -140,11 +164,10 @@ describe('parseAddSourceSpecifier', () => {
})
describe('addCommand', () => {
- it('writes manifest and lock for a file skill specifier', async () => {
+ it('writes manifest and no lock for a file skill specifier', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-'))
const packageRoot = createSkillPackage('hello-skill', '# Hello skill\n')
const tarballPath = packDirectory(packageRoot)
- const portableTarballPath = path.relative(root, tarballPath).split(path.sep).join('/')
await addCommand({
cwd: root,
@@ -152,15 +175,13 @@ describe('addCommand', () => {
})
const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(manifest.skills['hello-skill']).toBe(`file:${tarballPath}#path:/skills/hello-skill`)
- expect(lockfile.skills['hello-skill'].resolution.type).toBe('file')
- expect(lockfile.skills['hello-skill'].resolution.tarball).toBe(portableTarballPath)
- expect(lockfile.skills['hello-skill'].resolution.path).toBe('/skills/hello-skill')
+ expect(manifest.skills['hello-skill']).toBe(`file:${tarballPath}&path:/skills/hello-skill`)
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
+ expect(existsSync(path.join(root, '.agents/skills/lock.yaml'))).toBe(false)
})
- it('keeps the bundled self skill out of skills.json and skills-lock.yaml', async () => {
+ it('keeps the bundled self skill out of skills.json', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-self-skill-'))
writeFileSync(
path.join(root, 'skills.json'),
@@ -179,14 +200,13 @@ describe('addCommand', () => {
})
const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
const installedSkill = path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md')
expect(manifest.selfSkill).toBe(true)
- expect(manifest.skills['hello-skill']).toBe(`file:${tarballPath}#path:/skills/hello-skill`)
+ expect(manifest.skills['hello-skill']).toBe(`file:${tarballPath}&path:/skills/hello-skill`)
expect(manifest.skills['skills-package-manager-cli']).toBeUndefined()
- expect(lockfile.skills['skills-package-manager-cli']).toBeUndefined()
expect(existsSync(installedSkill)).toBe(true)
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
})
it('installs and links a link skill immediately after add', async () => {
@@ -283,6 +303,70 @@ describe('addCommand', () => {
expect(Object.keys(manifest.skills).sort()).toEqual(['hello-skill', 'landing-page-design'])
})
+ it('adds multiple selected skills when --skill is repeated', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-multi-skill-'))
+ const localRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-local-multi-skill-'))
+
+ mkdirSync(path.join(localRepo, 'skills/hello-skill'), { recursive: true })
+ mkdirSync(path.join(localRepo, 'guides/design/landing-page-design'), { recursive: true })
+ writeFileSync(path.join(localRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
+ writeFileSync(
+ path.join(localRepo, 'guides/design/landing-page-design/SKILL.md'),
+ '---\nname: landing-page-design\ndescription: Design landing pages\n---\n',
+ )
+
+ await addCommand({
+ cwd: root,
+ specifier: localRepo,
+ skill: ['hello-skill', 'landing-page-design'],
+ })
+
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
+ expect(Object.keys(manifest.skills).sort()).toEqual(['hello-skill', 'landing-page-design'])
+ })
+
+ it('lists available skills without writing skills.json or installing', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-list-'))
+ const localRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-local-list-'))
+
+ mkdirSync(path.join(localRepo, 'skills/hello-skill'), { recursive: true })
+ writeFileSync(path.join(localRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
+
+ const result = await addCommand({
+ cwd: root,
+ specifier: localRepo,
+ list: true,
+ })
+
+ expect(result).toEqual({
+ status: 'listed',
+ skills: [{ name: 'hello-skill', description: '', path: '/skills/hello-skill' }],
+ })
+ expect(existsSync(path.join(root, 'skills.json'))).toBe(false)
+ expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(false)
+ })
+
+ it('installs all skills to all project agents when --all is passed', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-all-'))
+ const localRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-local-all-agents-'))
+
+ mkdirSync(path.join(localRepo, 'skills/hello-skill'), { recursive: true })
+ writeFileSync(path.join(localRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
+
+ await addCommand({
+ cwd: root,
+ specifier: localRepo,
+ all: true,
+ })
+
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
+ expect(Object.keys(manifest.skills)).toEqual(['hello-skill'])
+ expect(manifest.linkTargets).toContain('.claude/skills')
+ expect(manifest.linkTargets).toContain('.continue/skills')
+ expect(manifest.linkTargets).toContain('.windsurf/skills')
+ expect(lstatSync(path.join(root, '.claude/skills/hello-skill')).isSymbolicLink()).toBe(true)
+ })
+
it('adds project agent link targets when --agent is specified', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-agents-'))
const localSkillPath = path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')
@@ -514,7 +598,7 @@ describe('addCommand', () => {
).rejects.toThrow('Invalid agents: not-a-real-agent')
})
- it('writes manifest and lock for an npm skill specifier', async () => {
+ it('writes a pinned manifest entry for an npm skill specifier', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-npm-'))
const packageRoot = createSkillPackage('hello-skill', '# Hello from npm\n')
const registry = await startMockNpmRegistry(packageRoot, { authToken: 'test-token' })
@@ -531,22 +615,17 @@ describe('addCommand', () => {
})
const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
expect(manifest.skills['hello-skill']).toBe(
- `npm:${registry.packageName}#path:/skills/hello-skill`,
+ `npm:${registry.packageName}@1.0.0&path:/skills/hello-skill`,
)
- expect(lockfile.skills['hello-skill'].resolution.type).toBe('npm')
- expect(lockfile.skills['hello-skill'].resolution.packageName).toBe('@tests/hello-skill')
- expect(lockfile.skills['hello-skill'].resolution.version).toBe('1.0.0')
- expect(lockfile.skills['hello-skill'].resolution.tarball).toBe(registry.tarballUrl)
- expect(lockfile.skills['hello-skill'].resolution.registry).toBe(registry.registryUrl)
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
} finally {
await registry.close()
}
})
- it('writes manifest and lock for a git skill specifier', async () => {
+ it('writes a pinned manifest entry for a git skill specifier', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-git-'))
const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-git-source-'))
@@ -565,13 +644,51 @@ describe('addCommand', () => {
})
const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(manifest.skills['hello-skill']).toBe(`${gitRepo}#HEAD&path:/skills/hello-skill`)
- expect(lockfile.skills['hello-skill'].resolution.type).toBe('git')
- expect(lockfile.skills['hello-skill'].resolution.url).toBe(gitRepo)
- expect(lockfile.skills['hello-skill'].resolution.commit).toBe(commit)
- expect(lockfile.skills['hello-skill'].resolution.path).toBe('/skills/hello-skill')
+ expect(manifest.skills['hello-skill']).toBe(`${gitRepo}#${commit}&path:/skills/hello-skill`)
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
+ })
+
+ it('writes GitHub adds back to skills.json with a github: pinned specifier', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-github-tree-'))
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-github-source-'))
+ const gitConfigHome = mkdtempSync(path.join(tmpdir(), 'skills-pm-add-github-config-'))
+ const previousGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL
+
+ mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
+ writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello from GitHub URL\n')
+ execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git checkout -b main', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
+ const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
+
+ process.env.GIT_CONFIG_GLOBAL = path.join(gitConfigHome, '.gitconfig')
+ writeFileSync(
+ process.env.GIT_CONFIG_GLOBAL,
+ `[url "${pathToFileURL(gitRepo).href}"]\n\tinsteadOf = https://github.com/owner/repo.git\n`,
+ )
+
+ try {
+ await addCommand({
+ cwd: root,
+ specifier: 'https://github.com/owner/repo/tree/main/skills/hello-skill',
+ })
+
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
+ expect(manifest.skills['hello-skill']).toBe(
+ `github:owner/repo#${commit}&path:/skills/hello-skill`,
+ )
+ expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
+ } finally {
+ if (previousGitConfigGlobal === undefined) {
+ delete process.env.GIT_CONFIG_GLOBAL
+ } else {
+ process.env.GIT_CONFIG_GLOBAL = previousGitConfigGlobal
+ }
+ }
})
it('adds a skill with owner/repo and --skill flag', async () => {
@@ -585,6 +702,7 @@ describe('addCommand', () => {
execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
+ const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
// Use a direct git specifier to test the --skill path builds the right specifier
// (We can't actually test owner/repo without GitHub API, so test the protocol fallback)
@@ -595,6 +713,6 @@ describe('addCommand', () => {
})
const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- expect(manifest.skills.dogfood).toBe(`${gitRepo}#HEAD&path:/dogfood`)
+ expect(manifest.skills.dogfood).toBe(`${gitRepo}#${commit}&path:/dogfood`)
})
})
diff --git a/packages/skills-package-manager/test/cli.test.ts b/packages/skills-package-manager/test/cli.test.ts
index b25326e..9243a7c 100644
--- a/packages/skills-package-manager/test/cli.test.ts
+++ b/packages/skills-package-manager/test/cli.test.ts
@@ -4,7 +4,7 @@ import path from 'node:path'
import { describe, expect, it } from '@rstest/core'
import packageJson from '../package.json'
import { runCli } from '../src/cli/runCli'
-import { writeSkillsLock } from '../src/config/writeSkillsLock'
+import type { AddCommandOptions } from '../src/config/types'
import { writeSkillsManifest } from '../src/config/writeSkillsManifest'
import { createSkillPackage, packDirectory } from './helpers'
@@ -48,19 +48,7 @@ function withHandlers(handlers: THandlers) {
describe('runCli dispatch', () => {
it('dispatches add with specifier only', async () => {
- const add = createAsyncSpy<
- [
- options: {
- cwd: string
- specifier: string
- skill?: string
- global?: boolean
- yes?: boolean
- agent?: string[]
- },
- ],
- string
- >('added')
+ const add = createAsyncSpy<[options: AddCommandOptions], string>('added')
const result = await runCli(['node', 'spm', 'add', 'github:owner/repo'], {
cwd: '/workspace/project',
@@ -77,25 +65,16 @@ describe('runCli dispatch', () => {
global: undefined,
yes: undefined,
agent: undefined,
+ list: undefined,
+ copy: undefined,
+ all: undefined,
},
],
])
})
it('dispatches add with optional --skill', async () => {
- const add = createAsyncSpy<
- [
- options: {
- cwd: string
- specifier: string
- skill?: string
- global?: boolean
- yes?: boolean
- agent?: string[]
- },
- ],
- string
- >('added')
+ const add = createAsyncSpy<[options: AddCommandOptions], string>('added')
await runCli(['node', 'spm', 'add', 'owner/repo', '--skill', 'my-skill'], {
cwd: '/workspace/project',
@@ -111,25 +90,16 @@ describe('runCli dispatch', () => {
global: undefined,
yes: undefined,
agent: undefined,
+ list: undefined,
+ copy: undefined,
+ all: undefined,
},
],
])
})
it('dispatches add with -g -y and repeated --agent', async () => {
- const add = createAsyncSpy<
- [
- options: {
- cwd: string
- specifier: string
- skill?: string
- global?: boolean
- yes?: boolean
- agent?: string[]
- },
- ],
- string
- >('added')
+ const add = createAsyncSpy<[options: AddCommandOptions], string>('added')
await runCli(
[
@@ -159,6 +129,49 @@ describe('runCli dispatch', () => {
global: true,
yes: true,
agent: ['claude-code', 'continue'],
+ list: undefined,
+ copy: undefined,
+ all: undefined,
+ },
+ ],
+ ])
+ })
+
+ it('dispatches add with skills CLI compatibility flags', async () => {
+ const add = createAsyncSpy<[options: AddCommandOptions], string>('added')
+
+ await runCli(
+ [
+ 'node',
+ 'spm',
+ 'add',
+ 'owner/repo',
+ '-s',
+ 'frontend-design',
+ '--skill',
+ 'skill-creator',
+ '--list',
+ '--copy',
+ '--all',
+ ],
+ {
+ cwd: '/workspace/project',
+ ...withHandlers({ addCommand: add.fn }),
+ },
+ )
+
+ expect(add.calls).toEqual([
+ [
+ {
+ cwd: '/workspace/project',
+ specifier: 'owner/repo',
+ skill: ['frontend-design', 'skill-creator'],
+ global: undefined,
+ yes: undefined,
+ agent: undefined,
+ list: true,
+ copy: true,
+ all: true,
},
],
])
@@ -176,6 +189,14 @@ describe('runCli dispatch', () => {
expect(install.calls).toEqual([[{ cwd: '/workspace/project' }]])
})
+ it('rejects the removed install --frozen-lockfile flag', async () => {
+ await expect(
+ runCli(['node', 'spm', 'install', '--frozen-lockfile'], {
+ cwd: '/workspace/project',
+ }),
+ ).rejects.toThrow('Unknown option `--frozenLockfile`')
+ })
+
it('dispatches patch with optional flags', async () => {
const patch = createAsyncSpy<
[options: { cwd: string; skillName: string; editDir?: string; ignoreExisting?: boolean }],
@@ -291,22 +312,6 @@ describe('runCli dispatch', () => {
'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
},
})
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'test-digest',
- },
- },
- })
const output: string[] = []
const info = console.info
@@ -348,22 +353,6 @@ describe('runCli dispatch', () => {
'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
},
})
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'test-digest',
- },
- },
- })
const writes: string[] = []
const infos: string[] = []
diff --git a/packages/skills-package-manager/test/init.test.ts b/packages/skills-package-manager/test/init.test.ts
index b2625f5..bad339c 100644
--- a/packages/skills-package-manager/test/init.test.ts
+++ b/packages/skills-package-manager/test/init.test.ts
@@ -109,7 +109,7 @@ describe('documentation', () => {
expect(readme).toContain('npx skills-package-manager --help')
expect(readme).toContain('npx skills-package-manager --version')
- expect(readme).toContain('npx skills-package-manager add [--skill ]')
+ expect(readme).toContain('npx skills-package-manager add [--skill ...]')
expect(readme).toContain('npx skills-package-manager install')
expect(readme).toContain('npx skills-package-manager update [skill...]')
expect(readme).toContain('npx skills-package-manager init [--yes]')
diff --git a/packages/skills-package-manager/test/install.test.ts b/packages/skills-package-manager/test/install.test.ts
index a23c811..eb39b86 100644
--- a/packages/skills-package-manager/test/install.test.ts
+++ b/packages/skills-package-manager/test/install.test.ts
@@ -1,6 +1,8 @@
+import { execSync } from 'node:child_process'
import {
existsSync,
lstatSync,
+ mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
@@ -11,17 +13,32 @@ import {
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from '@rstest/core'
-import YAML from 'yaml'
import { installCommand } from '../src/commands/install'
-import type { SkillsLock, SkillsManifest } from '../src/config/types'
-import { writeSkillsLock } from '../src/config/writeSkillsLock'
import { writeSkillsManifest } from '../src/config/writeSkillsManifest'
-import { runPipeline } from '../src/pipeline'
-import { loadConfig } from '../src/pipeline/context'
import { createSkillPackage, packDirectory, startMockNpmRegistry } from './helpers'
-describe('installSkills', () => {
- it('installs a linked local skill and creates symlinks', async () => {
+function expectNoLockFiles(root: string) {
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
+ expect(existsSync(path.join(root, '.agents/skills/lock.yaml'))).toBe(false)
+}
+
+function createGitSkillRepo(content: string) {
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-git-source-'))
+ mkdirSync(path.join(gitRepo, 'skills/hello-git-skill'), { recursive: true })
+ writeFileSync(path.join(gitRepo, 'skills/hello-git-skill/SKILL.md'), content)
+ execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
+ return {
+ gitRepo,
+ commit: execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim(),
+ }
+}
+
+describe('installCommand', () => {
+ it('installs a linked local skill, creates symlinks, and writes no lock files', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-'))
await writeSkillsManifest(root, {
installDir: '.agents/skills',
@@ -30,21 +47,6 @@ describe('installSkills', () => {
'hello-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
},
})
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: ['.claude/skills'],
- skills: {
- 'hello-skill': {
- specifier: `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- resolution: {
- type: 'link',
- path: path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill'),
- },
- digest: 'test-digest',
- },
- },
- })
await installCommand({ cwd: root })
@@ -53,13 +55,14 @@ describe('installSkills', () => {
expect(existsSync(installedSkill)).toBe(true)
expect(lstatSync(linkedSkill).isSymbolicLink()).toBe(true)
expect(readFileSync(installedSkill, 'utf8')).toContain('Hello skill')
+ expectNoLockFiles(root)
})
- it('uses an existing local skill directory without replacing it', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-local-'))
+ it('supports local:* for existing user-owned skills under installDir', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-local-star-'))
const skillDir = path.join(root, '.agents/skills/my-skill')
- require('node:fs').mkdirSync(skillDir, { recursive: true })
+ mkdirSync(skillDir, { recursive: true })
writeFileSync(path.join(skillDir, 'SKILL.md'), '# My skill\n')
writeFileSync(path.join(skillDir, 'notes.md'), 'keep me\n')
writeFileSync(path.join(root, '.gitignore'), '.agents/**\n')
@@ -68,7 +71,7 @@ describe('installSkills', () => {
installDir: '.agents/skills',
linkTargets: ['.claude/skills'],
skills: {
- 'my-skill': 'local:./.agents/skills/my-skill',
+ 'my-skill': 'local:*',
},
})
@@ -77,23 +80,23 @@ describe('installSkills', () => {
const linkedSkill = path.join(root, '.claude/skills/my-skill')
const gitignore = readFileSync(path.join(root, '.gitignore'), 'utf8')
-
expect(readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')).toBe('# My skill\n')
expect(readFileSync(path.join(skillDir, 'notes.md'), 'utf8')).toBe('keep me\n')
expect(lstatSync(linkedSkill).isSymbolicLink()).toBe(true)
expect(path.resolve(path.dirname(linkedSkill), readlinkSync(linkedSkill))).toBe(skillDir)
expect(gitignore.match(/!\.agents\/skills\/my-skill\/\*\*/g)).toHaveLength(1)
+ expectNoLockFiles(root)
})
it('throws when a local skill directory is missing SKILL.md', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-local-invalid-'))
- require('node:fs').mkdirSync(path.join(root, '.agents/skills/my-skill'), { recursive: true })
+ mkdirSync(path.join(root, '.agents/skills/my-skill'), { recursive: true })
await writeSkillsManifest(root, {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'my-skill': 'local:./.agents/skills/my-skill',
+ 'my-skill': 'local:*',
},
})
@@ -104,8 +107,8 @@ describe('installSkills', () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-local-patch-'))
const skillDir = path.join(root, '.agents/skills/my-skill')
- require('node:fs').mkdirSync(skillDir, { recursive: true })
- require('node:fs').mkdirSync(path.join(root, 'patches'), { recursive: true })
+ mkdirSync(skillDir, { recursive: true })
+ mkdirSync(path.join(root, 'patches'), { recursive: true })
writeFileSync(path.join(skillDir, 'SKILL.md'), '# My skill\n')
writeFileSync(path.join(root, 'patches/my-skill.patch'), 'diff --git a/SKILL.md b/SKILL.md\n')
@@ -113,7 +116,7 @@ describe('installSkills', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'my-skill': 'local:./.agents/skills/my-skill',
+ 'my-skill': 'local:*',
},
patchedSkills: {
'my-skill': 'patches/my-skill.patch',
@@ -123,40 +126,6 @@ describe('installSkills', () => {
await expect(installCommand({ cwd: root })).rejects.toThrow('cannot be patched')
})
- it('does not prune a previously local skill after it is removed from the manifest', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-local-prune-'))
- const skillDir = path.join(root, '.agents/skills/my-skill')
-
- require('node:fs').mkdirSync(skillDir, { recursive: true })
- writeFileSync(path.join(skillDir, 'SKILL.md'), '# My skill\n')
- writeFileSync(
- path.join(skillDir, '.skills-pm.json'),
- JSON.stringify({ installedBy: 'skills-package-manager' }),
- )
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {},
- })
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'my-skill': {
- specifier: 'local:./.agents/skills/my-skill',
- resolution: { type: 'local', path: '.agents/skills/my-skill' },
- digest: '',
- },
- },
- })
-
- await installCommand({ cwd: root })
-
- expect(existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true)
- })
-
it('does not install the bundled self skill when selfSkill is omitted', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-self-skill-default-off-'))
writeFileSync(
@@ -166,14 +135,13 @@ describe('installSkills', () => {
await installCommand({ cwd: root })
- const installedSkill = path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md')
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
-
- expect(existsSync(installedSkill)).toBe(false)
- expect(lockfile.skills['skills-package-manager-cli']).toBeUndefined()
+ expect(existsSync(path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md'))).toBe(
+ false,
+ )
+ expectNoLockFiles(root)
})
- it('installs the bundled self skill when selfSkill is true', async () => {
+ it('installs the bundled self skill when selfSkill is true without writing it to skills.json', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-self-skill-enabled-'))
writeFileSync(
path.join(root, 'skills.json'),
@@ -186,37 +154,12 @@ describe('installSkills', () => {
await installCommand({ cwd: root })
- const installedSkill = path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md')
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
-
- expect(existsSync(installedSkill)).toBe(true)
- expect(lockfile.skills['skills-package-manager-cli']).toBeUndefined()
- })
-
- it('installs the bundled self skill in frozen mode without requiring a lock entry', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-self-skill-frozen-'))
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- { installDir: '.agents/skills', linkTargets: [], selfSkill: true, skills: {} },
- null,
- 2,
- ),
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
+ expect(existsSync(path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md'))).toBe(
+ true,
)
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {},
- })
-
- await installCommand({ cwd: root, frozenLockfile: true })
-
- const installedSkill = path.join(root, '.agents/skills/skills-package-manager-cli/SKILL.md')
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
-
- expect(existsSync(installedSkill)).toBe(true)
- expect(lockfile.skills['skills-package-manager-cli']).toBeUndefined()
+ expect(manifest.skills['skills-package-manager-cli']).toBeUndefined()
+ expectNoLockFiles(root)
})
it('installs a file skill from a tgz package', async () => {
@@ -231,28 +174,13 @@ describe('installSkills', () => {
'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
},
})
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'test-file-digest',
- },
- },
- })
await installCommand({ cwd: root })
const installedSkill = path.join(root, '.agents/skills/hello-skill/SKILL.md')
expect(existsSync(installedSkill)).toBe(true)
expect(readFileSync(installedSkill, 'utf8')).toContain('Hello from tgz')
+ expectNoLockFiles(root)
})
it('installs an npm skill from a packed package source', async () => {
@@ -269,126 +197,30 @@ describe('installSkills', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `npm:${registry.packageName}#path:/skills/hello-skill`,
- },
- })
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `npm:${registry.packageName}#path:/skills/hello-skill`,
- resolution: {
- type: 'npm',
- packageName: registry.packageName,
- version: registry.version,
- path: '/skills/hello-skill',
- tarball: registry.tarballUrl,
- integrity: registry.integrity,
- registry: registry.registryUrl,
- },
- digest: 'test-npm-digest',
- },
+ 'hello-skill': `npm:${registry.packageName}@${registry.version}&path:skills/hello-skill`,
},
})
- await installCommand({ cwd: root, frozenLockfile: true })
+ await installCommand({ cwd: root })
const installedSkill = path.join(root, '.agents/skills/hello-skill/SKILL.md')
expect(existsSync(installedSkill)).toBe(true)
expect(readFileSync(installedSkill, 'utf8')).toContain('Hello from npm package')
+ expectNoLockFiles(root)
} finally {
await registry.close()
}
})
- it('verifies npm tarball integrity before installing', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-npm-integrity-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from npm package\n')
- const registry = await startMockNpmRegistry(packageRoot)
-
- try {
- writeFileSync(path.join(root, '.npmrc'), `registry=${registry.registryUrl}\n`)
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `npm:${registry.packageName}#path:/skills/hello-skill`,
- },
- })
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `npm:${registry.packageName}#path:/skills/hello-skill`,
- resolution: {
- type: 'npm',
- packageName: registry.packageName,
- version: registry.version,
- path: '/skills/hello-skill',
- tarball: registry.tarballUrl,
- integrity: 'sha512-invalid',
- registry: registry.registryUrl,
- },
- digest: 'test-npm-digest',
- },
- },
- })
-
- await expect(installCommand({ cwd: root, frozenLockfile: true })).rejects.toThrow(
- 'Integrity check failed',
- )
- } finally {
- await registry.close()
- }
- })
-
- it('installs a git skill from a local git repository', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-git-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-git-source-'))
-
- require('node:fs').mkdirSync(path.join(gitRepo, 'skills/hello-git-skill'), { recursive: true })
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-git-skill/SKILL.md'),
- '# Hello from git\n',
- )
- require('node:child_process').execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git config user.email test@example.com', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git config user.name test', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
+ it('installs a git skill pinned to a commit', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-git-pinned-'))
+ const { gitRepo, commit } = createGitSkillRepo('# Hello from git\n')
await writeSkillsManifest(root, {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-git-skill': `${gitRepo}#HEAD&path:/skills/hello-git-skill`,
- },
- })
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-git-skill': {
- specifier: `${gitRepo}#HEAD&path:/skills/hello-git-skill`,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: 'HEAD',
- path: '/skills/hello-git-skill',
- },
- digest: 'test-git-digest',
- },
+ 'hello-git-skill': `${gitRepo}#${commit}&path:/skills/hello-git-skill`,
},
})
@@ -397,145 +229,7 @@ describe('installSkills', () => {
const installedSkill = path.join(root, '.agents/skills/hello-git-skill/SKILL.md')
expect(existsSync(installedSkill)).toBe(true)
expect(readFileSync(installedSkill, 'utf8')).toContain('Hello from git')
- })
-
- it('installs a git skill pinned to a non-head commit', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-git-pinned-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-git-pinned-source-'))
- const remoteRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-git-pinned-remote-'))
-
- require('node:fs').mkdirSync(path.join(gitRepo, 'skills/hello-git-skill'), { recursive: true })
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-git-skill/SKILL.md'),
- '# First version\n',
- )
- require('node:child_process').execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git config user.email test@example.com', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git config user.name test', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const pinnedCommit = require('node:child_process')
- .execSync('git rev-parse HEAD', { cwd: gitRepo })
- .toString()
- .trim()
-
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-git-skill/SKILL.md'),
- '# Second version\n',
- )
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m update', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync(
- `git clone --bare ${JSON.stringify(gitRepo)} ${JSON.stringify(remoteRepo)}`,
- {
- stdio: 'ignore',
- },
- )
- const remoteUrl = `file://${remoteRepo}`
-
- const manifest: SkillsManifest = {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-git-skill': `${remoteUrl}#${pinnedCommit}&path:/skills/hello-git-skill`,
- },
- }
-
- const lockfile: SkillsLock = {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-git-skill': {
- specifier: `${remoteUrl}#${pinnedCommit}&path:/skills/hello-git-skill`,
- resolution: {
- type: 'git',
- url: remoteUrl,
- commit: pinnedCommit,
- path: '/skills/hello-git-skill',
- },
- digest: 'test-git-pinned-digest',
- },
- },
- }
-
- await runPipeline({ ctx: await loadConfig(root), entries: lockfile.skills, skipResolve: true })
-
- const installedSkill = path.join(root, '.agents/skills/hello-git-skill/SKILL.md')
- expect(existsSync(installedSkill)).toBe(true)
- expect(readFileSync(installedSkill, 'utf8')).toContain('First version')
- })
-
- it('updates stale lock entries from manifest before installing', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-stale-lock-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-git-stale-source-'))
-
- require('node:fs').mkdirSync(path.join(gitRepo, 'skills/fixed-skill'), { recursive: true })
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/fixed-skill/SKILL.md'),
- '# Fixed skill\n',
- )
- require('node:child_process').execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git config user.email test@example.com', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git config user.name test', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'fixed-skill': `${gitRepo}#HEAD&path:/skills/fixed-skill`,
- },
- })
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- '': {
- specifier: gitRepo,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: 'HEAD',
- path: '/',
- },
- digest: 'bad-digest',
- },
- },
- }),
- )
-
- await installCommand({ cwd: root })
-
- const installedSkill = path.join(root, '.agents/skills/fixed-skill/SKILL.md')
- const rewrittenLock = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
-
- expect(existsSync(installedSkill)).toBe(true)
- expect(readFileSync(installedSkill, 'utf8')).toContain('Fixed skill')
- expect(rewrittenLock.skills['fixed-skill'].specifier).toBe(
- `${gitRepo}#HEAD&path:/skills/fixed-skill`,
- )
- expect(rewrittenLock.skills['']).toBeUndefined()
+ expectNoLockFiles(root)
})
it('removes managed skills that are no longer declared', async () => {
@@ -565,9 +259,10 @@ describe('installSkills', () => {
expect(existsSync(path.join(root, '.agents/skills/obsolete-skill'))).toBe(false)
expect(existsSync(path.join(root, '.claude/skills/obsolete-skill'))).toBe(false)
expect(existsSync(path.join(root, '.agents/skills/hello-skill'))).toBe(true)
+ expectNoLockFiles(root)
})
- it('reinstalls missing managed skills even when the lock digest is unchanged', async () => {
+ it('reinstalls missing managed skills when install state is otherwise up to date', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-reinstall-missing-'))
const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
const tarballPath = packDirectory(packageRoot)
@@ -586,623 +281,62 @@ describe('installSkills', () => {
await installCommand({ cwd: root })
expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
+ expectNoLockFiles(root)
})
- it('replaces installed skill directories so deleted source files do not linger', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-replace-dir-'))
- const sourceRoot = mkdtempSync(path.join(tmpdir(), 'skills-pm-replace-dir-source-'))
- const skillDir = path.join(sourceRoot, 'hello-skill')
-
- require('node:fs').mkdirSync(skillDir, { recursive: true })
- writeFileSync(path.join(skillDir, 'SKILL.md'), '# Hello\n')
- writeFileSync(path.join(skillDir, 'legacy.txt'), 'legacy\n')
+ it('short-circuits when install state is up to date', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-uptodate-'))
+ const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
+ const tarballPath = packDirectory(packageRoot)
await writeSkillsManifest(root, {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `link:${skillDir}`,
+ 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
},
})
await installCommand({ cwd: root })
- rmSync(path.join(skillDir, 'legacy.txt'))
-
- await installCommand({ cwd: root })
-
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/legacy.txt'))).toBe(false)
- })
-
- describe('frozen-lockfile', () => {
- it('installs successfully when lock is in sync', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-ok-'))
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- resolution: {
- type: 'link',
- path: path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill'),
- },
- digest: 'test-digest',
- },
- },
- })
-
- const result = await installCommand({ cwd: root, frozenLockfile: true })
-
- expect(result.status).toBe('installed')
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
- })
-
- it('throws when lockfile is missing in frozen mode', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-missing-'))
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- },
- })
-
- await expect(installCommand({ cwd: root, frozenLockfile: true })).rejects.toThrow(
- 'Lockfile is required in frozen mode',
- )
- })
-
- it('throws when lock is out of sync with manifest', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-outofsync-'))
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'different-skill': {
- specifier: `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- resolution: {
- type: 'link',
- path: path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill'),
- },
- digest: 'test-digest',
- },
- },
- })
-
- await expect(installCommand({ cwd: root, frozenLockfile: true })).rejects.toThrow(
- 'Lockfile is out of sync',
- )
- })
-
- it('does not modify lockfile in frozen mode', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-nomodify-'))
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
- resolution: {
- type: 'link',
- path: path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill'),
- },
- digest: 'original-digest',
- },
- },
- })
-
- const lockBefore = readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8')
- await installCommand({ cwd: root, frozenLockfile: true })
- const lockAfter = readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8')
-
- expect(lockBefore).toBe(lockAfter)
- })
-
- it('accepts lock with commit when manifest has no ref', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-noref-'))
- const localSource = path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')
-
- // Manifest without ref (no commit SHA)
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${localSource}`,
- },
- })
-
- // Lock with a resolved "commit" (for file type, this is just a different format)
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `link:${localSource}`,
- resolution: {
- type: 'link',
- path: localSource,
- },
- digest: 'resolved-digest',
- },
- },
- })
-
- const result = await installCommand({ cwd: root, frozenLockfile: true })
-
- expect(result.status).toBe('installed')
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
- })
-
- it('throws when manifest ref differs from lock ref', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-ref-diff-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-ref-git-'))
-
- require('node:fs').mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-skill/SKILL.md'),
- '# Hello\n',
- )
- require('node:child_process').execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git config user.email test@example.com', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git config user.name test', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m init', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- const commit1 = require('node:child_process')
- .execSync('git rev-parse HEAD', { cwd: gitRepo })
- .toString()
- .trim()
-
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-skill/SKILL.md'),
- '# Updated\n',
- )
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m update', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- const commit2 = require('node:child_process')
- .execSync('git rev-parse HEAD', { cwd: gitRepo })
- .toString()
- .trim()
-
- // Manifest specifies first commit
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `${gitRepo}#${commit1}&path:/skills/hello-skill`,
- },
- })
-
- // Lock has second commit
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#${commit2}&path:/skills/hello-skill`,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: commit2,
- path: '/skills/hello-skill',
- },
- digest: 'digest-2',
- },
- },
- })
-
- await expect(installCommand({ cwd: root, frozenLockfile: true })).rejects.toThrow(
- 'Lockfile is out of sync',
- )
- })
-
- it('accepts when manifest ref matches lock ref exactly', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-ref-match-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-ref-match-git-'))
-
- require('node:fs').mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- require('node:fs').writeFileSync(
- path.join(gitRepo, 'skills/hello-skill/SKILL.md'),
- '# Hello\n',
- )
- require('node:child_process').execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git config user.email test@example.com', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git config user.name test', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- require('node:child_process').execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- require('node:child_process').execSync('git commit -m init', {
- cwd: gitRepo,
- stdio: 'ignore',
- })
- const commit = require('node:child_process')
- .execSync('git rev-parse HEAD', { cwd: gitRepo })
- .toString()
- .trim()
-
- // Manifest and lock both specify same commit
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `${gitRepo}#${commit}&path:/skills/hello-skill`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#${commit}&path:/skills/hello-skill`,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: commit,
- path: '/skills/hello-skill',
- },
- digest: 'digest',
- },
- },
- })
-
- const result = await installCommand({ cwd: root, frozenLockfile: true })
-
- expect(result.status).toBe('installed')
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
- })
-
- it('emits resolved/added/installed progress events in frozen mode', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-progress-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
- const tarballPath = packDirectory(packageRoot)
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'digest',
- },
- },
- })
-
- const events: string[] = []
- await installCommand({
- cwd: root,
- frozenLockfile: true,
- onProgress: (event) => {
- events.push(`${event.type}:${event.skillName}`)
- },
- })
-
- expect(events).toEqual(['resolved:hello-skill', 'added:hello-skill', 'installed:hello-skill'])
- })
-
- it('short-circuits when install state is up-to-date', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-uptodate-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
- const tarballPath = packDirectory(packageRoot)
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'digest',
- },
- },
- })
-
- // First install to materialize files and install state
- await installCommand({ cwd: root, frozenLockfile: true })
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
-
- // Second install should short-circuit: no added events, only resolved + installed
- const events: string[] = []
- await installCommand({
- cwd: root,
- frozenLockfile: true,
- onProgress: (event) => {
- events.push(`${event.type}:${event.skillName}`)
- },
- })
-
- expect(events).toEqual(['resolved:hello-skill', 'installed:hello-skill'])
- })
- it('does not short-circuit when skill files are missing', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-missing-skill-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
- const tarballPath = packDirectory(packageRoot)
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `file:${tarballPath}#path:/skills/hello-skill`,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'digest',
- },
- },
- })
-
- // First install
- await installCommand({ cwd: root, frozenLockfile: true })
-
- // Delete the installed skill to break the up-to-date check
- rmSync(path.join(root, '.agents/skills/hello-skill'), { recursive: true, force: true })
-
- // Second install should NOT short-circuit; it must re-fetch and re-link
- const events: string[] = []
- await installCommand({
- cwd: root,
- frozenLockfile: true,
- onProgress: (event) => {
- events.push(`${event.type}:${event.skillName}`)
- },
- })
-
- expect(events).toEqual(['resolved:hello-skill', 'added:hello-skill', 'installed:hello-skill'])
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
+ const events: string[] = []
+ await installCommand({
+ cwd: root,
+ onProgress: (event) => {
+ events.push(`${event.type}:${event.skillName}`)
+ },
})
- it('does not short-circuit when lockfile digest changes', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-frozen-digest-changed-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
- const packageRoot2 = createSkillPackage('hello-skill', '# Updated content\n')
- const tarballPath = packDirectory(packageRoot)
- const tarballPath2 = packDirectory(packageRoot2)
- const specifier = `file:${tarballPath}#path:/skills/hello-skill`
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': specifier,
- },
- })
-
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath),
- path: '/skills/hello-skill',
- },
- digest: 'digest',
- },
- },
- })
-
- // Install the old version
- await installCommand({ cwd: root, frozenLockfile: true })
- const oldContent = readFileSync(
- path.join(root, '.agents/skills/hello-skill/SKILL.md'),
- 'utf8',
- )
- expect(oldContent).toContain('Hello from tgz')
-
- // Update lockfile resolution while keeping specifier compatible
- // (manifest specifier stays the same, so isLockInSync still passes)
- await writeSkillsLock(root, {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier,
- resolution: {
- type: 'file',
- tarball: path.relative(root, tarballPath2),
- path: '/skills/hello-skill',
- },
- digest: 'digest2',
- },
- },
- })
-
- // Install again β should NOT short-circuit because lock digest changed
- await installCommand({ cwd: root, frozenLockfile: true })
- const newContent = readFileSync(
- path.join(root, '.agents/skills/hello-skill/SKILL.md'),
- 'utf8',
- )
- expect(newContent).toContain('Updated content')
- })
+ expect(events).toEqual(['resolved:hello-skill', 'installed:hello-skill'])
+ expectNoLockFiles(root)
})
- describe('install-dir lock copy', () => {
- it('writes a skills-lock.yaml copy into installDir after install', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-install-dir-lock-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
- const tarballPath = packDirectory(packageRoot)
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
- },
- })
-
- await installCommand({ cwd: root })
+ it('does not short-circuit when skill files are missing', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-missing-skill-'))
+ const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
+ const tarballPath = packDirectory(packageRoot)
- const installDirLockPath = path.join(root, '.agents/skills', 'lock.yaml')
- expect(existsSync(installDirLockPath)).toBe(true)
- const installDirLock = YAML.parse(readFileSync(installDirLockPath, 'utf8'))
- expect(installDirLock.skills['hello-skill']).toBeDefined()
+ await writeSkillsManifest(root, {
+ installDir: '.agents/skills',
+ linkTargets: [],
+ skills: {
+ 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
+ },
})
- it('skips resolve when installDir lock copy matches root lockfile for npm skills', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-npm-fast-path-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello npm\n')
- const registry = await startMockNpmRegistry(packageRoot)
-
- try {
- writeFileSync(path.join(root, '.npmrc'), `registry=${registry.registryUrl}\n`)
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `npm:${registry.packageName}@1.0.0#path:/skills/hello-skill`,
- },
- })
-
- // First install (cold)
- await installCommand({ cwd: root })
- expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
-
- // Second install should skip resolve entirely
- const events: string[] = []
- const logs: string[] = []
- const originalInfo = console.info
- console.info = (...args: unknown[]) => {
- logs.push(args.join(' '))
- }
-
- await installCommand({
- cwd: root,
- onProgress: (event) => {
- events.push(`${event.type}:${event.skillName}`)
- },
- })
-
- console.info = originalInfo
+ await installCommand({ cwd: root })
+ rmSync(path.join(root, '.agents/skills/hello-skill'), { recursive: true, force: true })
- expect(logs).toContain('Skills Lockfile is up to date, resolve skipped')
- // No added event because runPipeline up-to-date short-circuit also kicks in
- expect(events).toEqual(['resolved:hello-skill', 'installed:hello-skill'])
- } finally {
- await registry.close()
- }
+ const events: string[] = []
+ await installCommand({
+ cwd: root,
+ onProgress: (event) => {
+ events.push(`${event.type}:${event.skillName}`)
+ },
})
- it('skips resolve for link skills when installDir copy matches', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-link-fast-path-'))
- const skillDir = path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')
-
- await writeSkillsManifest(root, {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `link:${skillDir}`,
- },
- })
-
- // First install
- await installCommand({ cwd: root })
-
- // Second install β should skip resolve because link uses symlinks
- const logs: string[] = []
- const originalInfo = console.info
- console.info = (...args: unknown[]) => {
- logs.push(args.join(' '))
- }
-
- await installCommand({ cwd: root })
-
- console.info = originalInfo
-
- expect(logs).toContain('Skills Lockfile is up to date, resolve skipped')
- })
+ expect(events).toEqual(['resolved:hello-skill', 'added:hello-skill', 'installed:hello-skill'])
+ expect(existsSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'))).toBe(true)
+ expectNoLockFiles(root)
})
})
diff --git a/packages/skills-package-manager/test/patch.test.ts b/packages/skills-package-manager/test/patch.test.ts
index 8c01aca..1bc7ed2 100644
--- a/packages/skills-package-manager/test/patch.test.ts
+++ b/packages/skills-package-manager/test/patch.test.ts
@@ -2,7 +2,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from '@rstest/core'
-import YAML from 'yaml'
import { installCommand } from '../src/commands/install'
import { patchCommand } from '../src/commands/patch'
import { patchCommitCommand } from '../src/commands/patchCommit'
@@ -46,7 +45,7 @@ describe('patch workflow', () => {
expect(manifest?.skills['hello-skill']).toBe(`file:${tarballPath}#path:/skills/hello-skill`)
})
- it('commits a patch file, updates manifest and lockfile, and reapplies on install', async () => {
+ it('commits a patch file, updates manifest, and reapplies on install', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-patch-commit-'))
const packageRoot = createSkillPackage('hello-skill', '# Hello from tgz\n')
const tarballPath = packDirectory(packageRoot)
@@ -77,17 +76,14 @@ describe('patch workflow', () => {
'hello-skill': 'patches/hello-skill.patch',
})
expect(readFileSync(patchCommitResult.patchFile, 'utf8')).toContain('Patched locally.')
-
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8')) as {
- skills: Record
- }
- expect(lockfile.skills['hello-skill'].patch?.path).toBe('patches/hello-skill.patch')
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
+ expect(existsSync(path.join(root, '.agents/skills/lock.yaml'))).toBe(false)
expect(readFileSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'), 'utf8')).toContain(
'Patched locally.',
)
rmSync(path.join(root, '.agents/skills'), { recursive: true, force: true })
- await installCommand({ cwd: root, frozenLockfile: true })
+ await installCommand({ cwd: root })
expect(readFileSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'), 'utf8')).toContain(
'Patched locally.',
diff --git a/packages/skills-package-manager/test/specifiers.test.ts b/packages/skills-package-manager/test/specifiers.test.ts
index 13bc5ca..02ac90e 100644
--- a/packages/skills-package-manager/test/specifiers.test.ts
+++ b/packages/skills-package-manager/test/specifiers.test.ts
@@ -1,10 +1,5 @@
-import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import path from 'node:path'
import { describe, expect, it } from '@rstest/core'
-import { isLockInSync } from '../src/config/compareSkillsLock'
import { normalizeSpecifier } from '../src/specifiers/normalizeSpecifier'
-import { sha256File } from '../src/utils/hash'
describe('normalizeSpecifier', () => {
it('parses git path specifier', () => {
@@ -20,6 +15,17 @@ describe('normalizeSpecifier', () => {
})
})
+ it('parses github: protocol specifiers for skills.json', () => {
+ expect(normalizeSpecifier('github:acme/skills#abc123&path:/skills/hello')).toEqual({
+ type: 'git',
+ source: 'https://github.com/acme/skills.git',
+ ref: 'abc123',
+ path: '/skills/hello',
+ normalized: 'github:acme/skills#abc123&path:/skills/hello',
+ skillName: 'hello',
+ })
+ })
+
it('parses link specifier that points directly to a skill directory', () => {
expect(normalizeSpecifier('link:./fixtures/local-source/skills/hello-skill')).toEqual({
type: 'link',
@@ -31,7 +37,7 @@ describe('normalizeSpecifier', () => {
})
})
- it('canonicalizes link specifiers for stable comparisons', () => {
+ it('canonicalizes link and local specifiers for stable comparisons', () => {
expect(normalizeSpecifier('link:.\\fixtures\\local-source\\skills\\hello-skill/')).toEqual({
type: 'link',
source: 'link:./fixtures/local-source/skills/hello-skill',
@@ -40,10 +46,7 @@ describe('normalizeSpecifier', () => {
normalized: 'link:./fixtures/local-source/skills/hello-skill',
skillName: 'hello-skill',
})
- })
-
- it('parses local specifier that points directly to a user-owned skill directory', () => {
- expect(normalizeSpecifier('local:./.agents/skills/hello-skill')).toEqual({
+ expect(normalizeSpecifier('local:.\\.agents\\skills\\hello-skill/')).toEqual({
type: 'local',
source: 'local:./.agents/skills/hello-skill',
ref: null,
@@ -53,59 +56,61 @@ describe('normalizeSpecifier', () => {
})
})
- it('canonicalizes local specifiers for stable comparisons', () => {
- expect(normalizeSpecifier('local:.\\.agents\\skills\\hello-skill/')).toEqual({
+ it('expands local:* using the manifest skill name and installDir', () => {
+ expect(
+ normalizeSpecifier('local:*', {
+ installDir: '.agents/skills',
+ skillName: 'docs-en-improvement',
+ }),
+ ).toEqual({
type: 'local',
- source: 'local:./.agents/skills/hello-skill',
+ source: 'local:.agents/skills/docs-en-improvement',
ref: null,
path: '/',
- normalized: 'local:./.agents/skills/hello-skill',
- skillName: 'hello-skill',
+ normalized: 'local:.agents/skills/docs-en-improvement',
+ skillName: 'docs-en-improvement',
})
})
- it('rejects link specifiers with path fragments', () => {
+ it('rejects link and local specifiers with path fragments', () => {
expect(() =>
normalizeSpecifier('link:./fixtures/local-source#path:/skills/hello-skill'),
).toThrow('Invalid link specifier')
- })
-
- it('rejects local specifiers with path fragments', () => {
expect(() => normalizeSpecifier('local:./.agents/skills#path:/hello-skill')).toThrow(
'Invalid local specifier',
)
})
- it('parses file tarball specifier', () => {
+ it('parses file tarball specifiers and preserves old #path syntax', () => {
expect(normalizeSpecifier('file:./fixtures/skills.tgz#path:/skills/hello-skill')).toEqual({
type: 'file',
source: 'file:./fixtures/skills.tgz',
ref: null,
path: '/skills/hello-skill',
- normalized: 'file:./fixtures/skills.tgz#path:/skills/hello-skill',
+ normalized: 'file:./fixtures/skills.tgz&path:/skills/hello-skill',
skillName: 'hello-skill',
})
})
- it('parses npm specifier', () => {
- expect(normalizeSpecifier('npm:@acme/skills#path:/skills/hello-skill')).toEqual({
+ it('parses npm specifiers with version and &path syntax', () => {
+ expect(normalizeSpecifier('npm:@acme/skills@1.2.3&path:skills/hello-skill')).toEqual({
type: 'npm',
- source: 'npm:@acme/skills',
+ source: 'npm:@acme/skills@1.2.3',
ref: null,
path: '/skills/hello-skill',
- normalized: 'npm:@acme/skills#path:/skills/hello-skill',
+ normalized: 'npm:@acme/skills@1.2.3&path:/skills/hello-skill',
skillName: 'hello-skill',
})
})
- it('parses git specifier without ref', () => {
- expect(normalizeSpecifier('https://github.com/acme/skills.git#path:/skills/world')).toEqual({
- type: 'git',
- source: 'https://github.com/acme/skills.git',
+ it('infers a root npm skill name from the package name', () => {
+ expect(normalizeSpecifier('npm:@acme/hello-skill@1.2.3')).toEqual({
+ type: 'npm',
+ source: 'npm:@acme/hello-skill@1.2.3',
ref: null,
- path: '/skills/world',
- normalized: 'https://github.com/acme/skills.git#path:/skills/world',
- skillName: 'world',
+ path: '/',
+ normalized: 'npm:@acme/hello-skill@1.2.3',
+ skillName: 'hello-skill',
})
})
@@ -116,162 +121,4 @@ describe('normalizeSpecifier', () => {
),
).toThrow('Invalid specifier: multiple # fragments are not supported')
})
-
- it('treats equivalent link specifiers as in sync', async () => {
- await expect(
- isLockInSync(
- process.cwd(),
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': 'link:.\\fixtures\\local-source\\skills\\hello-skill/',
- },
- },
- {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: 'link:./fixtures/local-source/skills/hello-skill',
- resolution: {
- type: 'link',
- path: './fixtures/local-source/skills/hello-skill',
- },
- digest: 'sha256-test',
- },
- },
- },
- ),
- ).resolves.toBe(true)
- })
-
- it('treats equivalent local specifiers as in sync', async () => {
- await expect(
- isLockInSync(
- process.cwd(),
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': 'local:.\\.agents\\skills\\hello-skill/',
- },
- },
- {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: 'local:./.agents/skills/hello-skill',
- resolution: {
- type: 'local',
- path: './.agents/skills/hello-skill',
- },
- digest: '',
- },
- },
- },
- ),
- ).resolves.toBe(true)
- })
-
- it('ignores selfSkill when checking whether a lockfile is in sync', async () => {
- await expect(
- isLockInSync(
- process.cwd(),
- {
- installDir: '.agents/skills',
- linkTargets: [],
- selfSkill: true,
- skills: {},
- },
- {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {},
- },
- ),
- ).resolves.toBe(true)
- })
-
- it('treats stale patch metadata as out of sync', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-lock-patch-sync-'))
- const patchPath = path.join(root, 'patches', 'hello-skill.patch')
-
- mkdirSync(path.dirname(patchPath), { recursive: true })
- writeFileSync(patchPath, 'diff --git a/SKILL.md b/SKILL.md\n', 'utf8')
-
- await expect(
- isLockInSync(
- root,
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': 'link:./fixtures/local-source/skills/hello-skill',
- },
- patchedSkills: {
- 'hello-skill': 'patches/hello-skill.patch',
- },
- },
- {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: 'link:./fixtures/local-source/skills/hello-skill',
- resolution: {
- type: 'link',
- path: './fixtures/local-source/skills/hello-skill',
- },
- digest: 'sha256-test',
- patch: {
- path: 'patches/hello-skill.patch',
- digest: 'sha256-stale',
- },
- },
- },
- },
- ),
- ).resolves.toBe(false)
-
- await expect(
- isLockInSync(
- root,
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': 'link:./fixtures/local-source/skills/hello-skill',
- },
- patchedSkills: {
- 'hello-skill': 'patches/hello-skill.patch',
- },
- },
- {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: 'link:./fixtures/local-source/skills/hello-skill',
- resolution: {
- type: 'link',
- path: './fixtures/local-source/skills/hello-skill',
- },
- digest: 'sha256-test',
- patch: {
- path: 'patches/hello-skill.patch',
- digest: await sha256File(patchPath),
- },
- },
- },
- },
- ),
- ).resolves.toBe(true)
- })
})
diff --git a/packages/skills-package-manager/test/update.test.ts b/packages/skills-package-manager/test/update.test.ts
index a255a02..12b03a5 100644
--- a/packages/skills-package-manager/test/update.test.ts
+++ b/packages/skills-package-manager/test/update.test.ts
@@ -4,50 +4,122 @@ import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from '@rstest/core'
-import YAML from 'yaml'
import { updateCommand } from '../src/commands/update'
-import { resolveLockEntry } from '../src/config/syncSkillsLock'
-import type { SkillsLock, SkillsManifest } from '../src/config/types'
-import { installStageHooks } from '../src/install/withBundledSelfSkillLock'
-import { runPipeline } from '../src/pipeline'
-import { loadConfig } from '../src/pipeline/context'
-import { sha256 } from '../src/utils/hash'
+import { resolveSkillEntry } from '../src/config/resolveSkillsPlan'
import { createSkillPackage, packDirectory, startMockNpmRegistry } from './helpers'
-describe('resolveLockEntry', () => {
+function createMainBranchGitSkillRepo() {
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-git-source-'))
+ mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
+ writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# First version\n')
+ execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git checkout -b main', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
+ const firstCommit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
+
+ writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Second version\n')
+ execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git commit -m update', { cwd: gitRepo, stdio: 'ignore' })
+ const secondCommit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
+
+ return { gitRepo, firstCommit, secondCommit }
+}
+
+async function startTwoVersionRegistry() {
+ const packageV1 = createSkillPackage('hello-skill', '# Hello from npm v1\n', '1.0.0')
+ const packageV2 = createSkillPackage('hello-skill', '# Hello from npm v2\n', '2.0.0')
+ const tarballV1 = packDirectory(packageV1)
+ const tarballV2 = packDirectory(packageV2)
+ const tarballV1Buffer = readFileSync(tarballV1)
+ const tarballV2Buffer = readFileSync(tarballV2)
+ const packageName = '@tests/hello-skill'
+ let port = 0
+
+ const server = createServer((req, res) => {
+ const requestPath = req.url?.split('?')[0] ?? '/'
+ if (decodeURIComponent(requestPath.slice(1)) === packageName) {
+ res.setHeader('content-type', 'application/json')
+ res.end(
+ JSON.stringify({
+ 'dist-tags': { latest: '2.0.0' },
+ versions: {
+ '1.0.0': {
+ name: packageName,
+ version: '1.0.0',
+ dist: { tarball: `http://127.0.0.1:${port}/tarballs/v1.tgz` },
+ },
+ '2.0.0': {
+ name: packageName,
+ version: '2.0.0',
+ dist: { tarball: `http://127.0.0.1:${port}/tarballs/v2.tgz` },
+ },
+ },
+ }),
+ )
+ return
+ }
+
+ if (requestPath === '/tarballs/v1.tgz') {
+ res.setHeader('content-type', 'application/octet-stream')
+ res.end(tarballV1Buffer)
+ return
+ }
+
+ if (requestPath === '/tarballs/v2.tgz') {
+ res.setHeader('content-type', 'application/octet-stream')
+ res.end(tarballV2Buffer)
+ return
+ }
+
+ res.statusCode = 404
+ res.end('not found')
+ })
+
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
+ const address = server.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('Failed to start test registry')
+ }
+ port = address.port
+
+ return {
+ packageName,
+ registryUrl: `http://127.0.0.1:${port}/`,
+ close: async () => {
+ await new Promise((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ )
+ },
+ }
+}
+
+describe('resolveSkillEntry', () => {
it('uses empty digest for link resolutions', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-link-'))
const skillDir = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-link-source-'))
writeFileSync(path.join(skillDir, 'SKILL.md'), '# Hello skill\n')
- const first = await resolveLockEntry(root, `link:${skillDir}`)
+ const first = await resolveSkillEntry(root, `link:${skillDir}`)
writeFileSync(path.join(skillDir, 'SKILL.md'), '# Updated skill\n')
- const second = await resolveLockEntry(root, `link:${skillDir}`)
+ const second = await resolveSkillEntry(root, `link:${skillDir}`)
expect(first.entry.resolution.type).toBe('link')
expect(second.entry.resolution.type).toBe('link')
expect(first.entry.digest).toBe('')
expect(second.entry.digest).toBe('')
- expect(first.entry.digest).toBe(second.entry.digest)
})
it('resolves git specifiers to the current commit', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-git-'))
+ const { gitRepo, secondCommit } = createMainBranchGitSkillRepo()
- const { skillName, entry } = await resolveLockEntry(
+ const { skillName, entry } = await resolveSkillEntry(
root,
- `${gitRepo}#HEAD&path:/skills/hello-skill`,
+ `${gitRepo}#main&path:/skills/hello-skill`,
)
expect(skillName).toBe('hello-skill')
@@ -55,81 +127,10 @@ describe('resolveLockEntry', () => {
if (entry.resolution.type !== 'git') {
throw new Error('Expected git resolution')
}
- expect(entry.resolution.commit).toBe(commit)
+ expect(entry.resolution.commit).toBe(secondCommit)
expect(entry.resolution.path).toBe('/skills/hello-skill')
})
- it('resolves a full commit sha to the matching commit', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-sha-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-sha-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- const { entry } = await resolveLockEntry(root, `${gitRepo}#${commit}&path:/skills/hello-skill`)
-
- expect(entry.resolution.type).toBe('git')
- if (entry.resolution.type !== 'git') {
- throw new Error('Expected git resolution')
- }
- expect(entry.resolution.commit).toBe(commit)
- })
-
- it('resolves a short commit sha to the matching commit', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-short-sha-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-short-sha-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
- const shortCommit = commit.slice(0, 7)
-
- const { entry } = await resolveLockEntry(
- root,
- `${gitRepo}#${shortCommit}&path:/skills/hello-skill`,
- )
-
- expect(entry.resolution.type).toBe('git')
- if (entry.resolution.type !== 'git') {
- throw new Error('Expected git resolution')
- }
- expect(entry.resolution.commit).toBe(commit)
- })
-
- it('resolves an annotated tag to the tagged commit', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-tag-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-tag-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello skill\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
- execSync('git tag -a v1.0.0 -m v1.0.0', { cwd: gitRepo, stdio: 'ignore' })
-
- const { entry } = await resolveLockEntry(root, `${gitRepo}#v1.0.0&path:/skills/hello-skill`)
-
- expect(entry.resolution.type).toBe('git')
- if (entry.resolution.type !== 'git') {
- throw new Error('Expected git resolution')
- }
- expect(entry.resolution.commit).toBe(commit)
- })
-
it('resolves npm registry from scoped .npmrc entries', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-npm-registry-'))
const packageRoot = createSkillPackage('hello-skill', '# Hello registry\n')
@@ -141,9 +142,9 @@ describe('resolveLockEntry', () => {
`registry=http://127.0.0.1:9/\n@tests:registry=${registry.registryUrl}\n${registry.authTokenConfigLine}\n`,
)
- const { entry } = await resolveLockEntry(
+ const { entry } = await resolveSkillEntry(
root,
- 'npm:@tests/hello-skill#path:/skills/hello-skill',
+ 'npm:@tests/hello-skill&path:/skills/hello-skill',
)
expect(entry.resolution.type).toBe('npm')
@@ -156,161 +157,12 @@ describe('resolveLockEntry', () => {
await registry.close()
}
})
-
- it('changes npm digest when resolved integrity changes at the same version', async () => {
- const rootA = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-npm-digest-a-'))
- const rootB = mkdtempSync(path.join(tmpdir(), 'skills-pm-resolve-npm-digest-b-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello registry\n')
- const tarballPath = packDirectory(packageRoot)
- const tarballBuffer = readFileSync(tarballPath)
- const packageName = '@tests/hello-skill'
- const version = '1.0.0'
- let integrity = 'sha512-first'
- let port = 0
-
- const server = createServer((req, res) => {
- const requestPath = req.url?.split('?')[0] ?? '/'
- if (decodeURIComponent(requestPath.slice(1)) === packageName) {
- res.setHeader('content-type', 'application/json')
- res.end(
- JSON.stringify({
- 'dist-tags': { latest: version },
- versions: {
- [version]: {
- name: packageName,
- version,
- dist: {
- tarball: `http://127.0.0.1:${port}/tarballs/hello-skill.tgz`,
- integrity,
- },
- },
- },
- }),
- )
- return
- }
-
- if (requestPath === '/tarballs/hello-skill.tgz') {
- res.setHeader('content-type', 'application/octet-stream')
- res.end(tarballBuffer)
- return
- }
-
- res.statusCode = 404
- res.end('not found')
- })
-
- try {
- await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
- const address = server.address()
- if (!address || typeof address === 'string') {
- throw new Error('Failed to start test registry')
- }
- port = address.port
-
- writeFileSync(path.join(rootA, '.npmrc'), `registry=http://127.0.0.1:${port}/\n`)
- writeFileSync(path.join(rootB, '.npmrc'), `registry=http://127.0.0.1:${port}/\n`)
-
- const first = await resolveLockEntry(rootA, `npm:${packageName}#path:/skills/hello-skill`)
- integrity = 'sha512-second'
- const second = await resolveLockEntry(rootB, `npm:${packageName}#path:/skills/hello-skill`)
-
- expect(first.entry.resolution.type).toBe('npm')
- expect(second.entry.resolution.type).toBe('npm')
- expect(first.entry.digest).not.toBe(second.entry.digest)
- } finally {
- await new Promise((resolve, reject) =>
- server.close((error) => (error ? reject(error) : resolve())),
- )
- }
- })
-})
-
-describe('install stages', () => {
- it('materializes and links skills from a provided lockfile', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-fetch-link-'))
- const sourceRoot = mkdtempSync(path.join(tmpdir(), 'skills-pm-local-source-'))
- mkdirSync(path.join(sourceRoot, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(sourceRoot, 'skills/hello-skill/SKILL.md'), '# Hello stage\n')
-
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: ['.claude/skills'],
- skills: {
- 'hello-skill': `link:${path.join(sourceRoot, 'skills/hello-skill')}`,
- },
- },
- null,
- 2,
- ),
- )
-
- const lockfile: SkillsLock = {
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: ['.claude/skills'],
- skills: {
- 'hello-skill': {
- specifier: `link:${path.join(sourceRoot, 'skills/hello-skill')}`,
- resolution: { type: 'link', path: path.join(sourceRoot, 'skills/hello-skill') },
- digest: 'sha256-test',
- },
- },
- }
-
- await runPipeline({
- ctx: await loadConfig(root),
- entries: lockfile.skills,
- skipResolve: true,
- })
-
- const installed = readFileSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'), 'utf8')
- expect(installed).toContain('Hello stage')
- expect(existsSync(path.join(root, '.claude/skills/hello-skill'))).toBe(true)
- })
-})
-
-describe('updateCommand validation', () => {
- it('fails when a named skill is not present in skills.json', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-missing-'))
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify({ skills: { alpha: 'link:./alpha' } }, null, 2),
- )
-
- await expect(updateCommand({ cwd: root, skills: ['missing'] })).rejects.toThrow(
- 'Unknown skill: missing',
- )
- })
})
-describe('updateCommand resolve', () => {
- it('updates git targets and skips link targets', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-targets-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-git-'))
- const fileRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-file-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Version 1\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const oldCommit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Version 2\n')
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m update', { cwd: gitRepo, stdio: 'ignore' })
- const newCommit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- mkdirSync(path.join(fileRepo, 'local-skill'), { recursive: true })
- writeFileSync(path.join(fileRepo, 'local-skill/SKILL.md'), '# Local\n')
- mkdirSync(path.join(root, '.agents/skills/owned-skill'), { recursive: true })
- writeFileSync(path.join(root, '.agents/skills/owned-skill/SKILL.md'), '# Owned\n')
+describe('updateCommand', () => {
+ it('updates git skills to the latest main commit in skills.json and installs them', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-git-'))
+ const { gitRepo, firstCommit, secondCommit } = createMainBranchGitSkillRepo()
writeFileSync(
path.join(root, 'skills.json'),
@@ -319,9 +171,7 @@ describe('updateCommand resolve', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `${gitRepo}#HEAD&path:/skills/hello-skill`,
- 'local-skill': `link:${fileRepo}/local-skill`,
- 'owned-skill': 'local:./.agents/skills/owned-skill',
+ 'hello-skill': `${gitRepo}#${firstCommit}&path:/skills/hello-skill`,
},
},
null,
@@ -329,108 +179,23 @@ describe('updateCommand resolve', () => {
),
)
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#HEAD&path:/skills/hello-skill`,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: oldCommit,
- path: '/skills/hello-skill',
- },
- digest: `sha256-${oldCommit}`,
- },
- 'local-skill': {
- specifier: `link:${fileRepo}/local-skill`,
- resolution: { type: 'link', path: `${fileRepo}/local-skill` },
- digest: 'sha256-local',
- },
- 'owned-skill': {
- specifier: 'local:./.agents/skills/owned-skill',
- resolution: { type: 'local', path: '.agents/skills/owned-skill' },
- digest: '',
- },
- },
- }),
- )
-
- const result = await updateCommand({ cwd: root })
+ const result = await updateCommand({ cwd: root, skills: ['hello-skill'] })
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
+ expect(result.status).toBe('updated')
expect(result.updated).toEqual(['hello-skill'])
- expect(result.skipped).toEqual([
- { name: 'local-skill', reason: 'link-specifier' },
- { name: 'owned-skill', reason: 'local-specifier' },
- ])
- expect(result.failed).toEqual([])
- expect(result.unchanged).toEqual([])
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(lockfile.skills['hello-skill'].resolution.commit).toBe(newCommit)
- })
-
- it('updates git targets when the path changes even if the commit is the same', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-git-path-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-git-path-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- mkdirSync(path.join(gitRepo, 'skills/alt-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Version 1\n')
- writeFileSync(path.join(gitRepo, 'skills/alt-skill/SKILL.md'), '# Version 2\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `${gitRepo}#HEAD&path:/skills/alt-skill`,
- },
- },
- null,
- 2,
- ),
+ expect(manifest.skills['hello-skill']).toBe(
+ `${gitRepo}#${secondCommit}&path:/skills/hello-skill`,
)
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#HEAD&path:/skills/hello-skill`,
- resolution: { type: 'git', url: gitRepo, commit, path: '/skills/hello-skill' },
- digest: 'sha256-old',
- },
- },
- }),
+ expect(readFileSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'), 'utf8')).toContain(
+ 'Second version',
)
-
- const result = await updateCommand({ cwd: root })
-
- expect(result.updated).toEqual(['hello-skill'])
- expect(result.unchanged).toEqual([])
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(lockfile.skills['hello-skill'].resolution.path).toBe('/skills/alt-skill')
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
})
- it('updates npm targets when the resolved package version changes', async () => {
+ it('updates npm skills to the latest version in skills.json and installs them', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-npm-'))
- const packageRoot = createSkillPackage('hello-skill', '# Version 1\n')
- const registry = await startMockNpmRegistry(packageRoot)
+ const registry = await startTwoVersionRegistry()
try {
writeFileSync(path.join(root, '.npmrc'), `registry=${registry.registryUrl}\n`)
@@ -441,7 +206,7 @@ describe('updateCommand resolve', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `npm:${registry.packageName}#path:/skills/hello-skill`,
+ 'hello-skill': `npm:${registry.packageName}@1.0.0&path:/skills/hello-skill`,
},
},
null,
@@ -449,160 +214,30 @@ describe('updateCommand resolve', () => {
),
)
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `npm:${registry.packageName}#path:/skills/hello-skill`,
- resolution: {
- type: 'npm',
- packageName: registry.packageName,
- version: '0.9.0',
- path: '/skills/hello-skill',
- tarball: `${registry.registryUrl}tarballs/old.tgz`,
- integrity: 'sha512-old',
- registry: registry.registryUrl,
- },
- digest: 'sha256-old',
- },
- },
- }),
- )
-
- const result = await updateCommand({ cwd: root })
-
- expect(result.updated).toEqual(['hello-skill'])
- expect(result.failed).toEqual([])
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(lockfile.skills['hello-skill'].resolution.version).toBe('1.0.0')
- expect(lockfile.skills['hello-skill'].resolution.tarball).toBe(registry.tarballUrl)
- } finally {
- await registry.close()
- }
- })
-
- it('updates npm targets when integrity changes at the same version', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-npm-integrity-'))
- const packageRoot = createSkillPackage('hello-skill', '# Version 1\n')
- const registry = await startMockNpmRegistry(packageRoot)
-
- try {
- writeFileSync(path.join(root, '.npmrc'), `registry=${registry.registryUrl}\n`)
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `npm:${registry.packageName}#path:/skills/hello-skill`,
- },
- },
- null,
- 2,
- ),
- )
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `npm:${registry.packageName}#path:/skills/hello-skill`,
- resolution: {
- type: 'npm',
- packageName: registry.packageName,
- version: registry.version,
- path: '/skills/hello-skill',
- tarball: registry.tarballUrl,
- integrity: 'sha512-old',
- registry: registry.registryUrl,
- },
- digest: 'sha256-old',
- },
- },
- }),
- )
-
const result = await updateCommand({ cwd: root })
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- expect(result.updated).toEqual(['hello-skill'])
- expect(result.unchanged).toEqual([])
- } finally {
- await registry.close()
- }
- })
-
- it('updates npm targets when the resolved registry changes at the same version', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-npm-registry-change-'))
- const packageRoot = createSkillPackage('hello-skill', '# Version 1\n')
- const registry = await startMockNpmRegistry(packageRoot)
-
- try {
- writeFileSync(path.join(root, '.npmrc'), `registry=${registry.registryUrl}\n`)
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `npm:${registry.packageName}#path:/skills/hello-skill`,
- },
- },
- null,
- 2,
- ),
- )
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `npm:${registry.packageName}#path:/skills/hello-skill`,
- resolution: {
- type: 'npm',
- packageName: registry.packageName,
- version: registry.version,
- path: '/skills/hello-skill',
- tarball: registry.tarballUrl,
- integrity: registry.integrity,
- registry: `${registry.registryUrl}mirror/`,
- },
- digest: 'sha256-old',
- },
- },
- }),
+ expect(result.status).toBe('updated')
+ expect(manifest.skills['hello-skill']).toBe(
+ `npm:${registry.packageName}@2.0.0&path:/skills/hello-skill`,
)
-
- const result = await updateCommand({ cwd: root })
-
- expect(result.updated).toEqual(['hello-skill'])
- expect(result.unchanged).toEqual([])
- const lockfile = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(lockfile.skills['hello-skill'].resolution.registry).toBe(registry.registryUrl)
+ expect(
+ readFileSync(path.join(root, '.agents/skills/hello-skill/SKILL.md'), 'utf8'),
+ ).toContain('Hello from npm v2')
+ expect(existsSync(path.join(root, 'skills-lock.yaml'))).toBe(false)
} finally {
await registry.close()
}
})
- it('marks file tarball targets unchanged when the tarball digest matches', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-file-unchanged-'))
- const packageRoot = createSkillPackage('hello-skill', '# Packed skill\n')
+ it('skips link, local, and file specifiers but still installs the full manifest', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-skip-'))
+ const localSkill = path.join(root, '.agents/skills/local-skill')
+ const packageRoot = createSkillPackage('file-skill', '# File skill\n')
const tarballPath = packDirectory(packageRoot)
- const { entry } = await resolveLockEntry(root, `file:${tarballPath}#path:/skills/hello-skill`)
+ mkdirSync(localSkill, { recursive: true })
+ writeFileSync(path.join(localSkill, 'SKILL.md'), '# Local skill\n')
writeFileSync(
path.join(root, 'skills.json'),
JSON.stringify(
@@ -610,7 +245,9 @@ describe('updateCommand resolve', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
+ 'link-skill': `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
+ 'local-skill': 'local:*',
+ 'file-skill': `file:${tarballPath}#path:/skills/file-skill`,
},
},
null,
@@ -618,41 +255,20 @@ describe('updateCommand resolve', () => {
),
)
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': entry,
- },
- }),
- )
-
const result = await updateCommand({ cwd: root })
- expect(result.unchanged).toEqual(['hello-skill'])
- expect(result.updated).toEqual([])
+ expect(result.status).toBe('skipped')
+ expect(result.skipped).toEqual([
+ { name: 'link-skill', reason: 'link-specifier' },
+ { name: 'local-skill', reason: 'local-specifier' },
+ { name: 'file-skill', reason: 'file-specifier' },
+ ])
+ expect(existsSync(path.join(root, '.agents/skills/link-skill/SKILL.md'))).toBe(true)
+ expect(existsSync(path.join(root, '.agents/skills/file-skill/SKILL.md'))).toBe(true)
})
- it('does not write the new lockfile when fetch fails', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-atomic-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-atomic-source-'))
-
- mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Atomic v1\n')
- execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const oldCommit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Atomic v2\n')
- execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
- execSync('git commit -m update', { cwd: gitRepo, stdio: 'ignore' })
-
+ it('throws for unknown target skills', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-unknown-'))
writeFileSync(
path.join(root, 'skills.json'),
JSON.stringify(
@@ -660,7 +276,7 @@ describe('updateCommand resolve', () => {
installDir: '.agents/skills',
linkTargets: [],
skills: {
- 'hello-skill': `${gitRepo}#HEAD&path:/skills/hello-skill`,
+ existing: `link:${path.resolve(__dirname, 'fixtures/local-source/skills/hello-skill')}`,
},
},
null,
@@ -668,184 +284,36 @@ describe('updateCommand resolve', () => {
),
)
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#HEAD&path:/skills/hello-skill`,
- resolution: {
- type: 'git',
- url: gitRepo,
- commit: oldCommit,
- path: '/skills/hello-skill',
- },
- digest: `sha256-${oldCommit}`,
- },
- },
- }),
+ await expect(updateCommand({ cwd: root, skills: ['missing'] })).rejects.toThrow(
+ 'Unknown skill: missing',
)
-
- installStageHooks.beforeFetch = async () => {
- throw new Error('Simulated fetch failure')
- }
-
- try {
- await expect(updateCommand({ cwd: root })).rejects.toThrow('Simulated fetch failure')
-
- const persisted = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(persisted.skills['hello-skill'].resolution.commit).toBe(oldCommit)
- } finally {
- installStageHooks.beforeFetch = async () => {}
- }
})
- it('marks a target as unchanged when the resolved commit matches the current lock', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-unchanged-'))
- const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-unchanged-source-'))
-
+ it('returns failed and keeps skills.json unchanged when a target cannot resolve', async () => {
+ const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-failed-'))
+ const gitRepo = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-failed-source-'))
mkdirSync(path.join(gitRepo, 'skills/hello-skill'), { recursive: true })
- writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Stable\n')
+ writeFileSync(path.join(gitRepo, 'skills/hello-skill/SKILL.md'), '# Hello\n')
execSync('git init', { cwd: gitRepo, stdio: 'ignore' })
+ execSync('git checkout -b dev', { cwd: gitRepo, stdio: 'ignore' })
execSync('git config user.email test@example.com', { cwd: gitRepo, stdio: 'ignore' })
execSync('git config user.name test', { cwd: gitRepo, stdio: 'ignore' })
execSync('git add .', { cwd: gitRepo, stdio: 'ignore' })
execSync('git commit -m init', { cwd: gitRepo, stdio: 'ignore' })
- const commit = execSync('git rev-parse HEAD', { cwd: gitRepo }).toString().trim()
-
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `${gitRepo}#HEAD&path:/skills/hello-skill`,
- },
- },
- null,
- 2,
- ),
- )
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': {
- specifier: `${gitRepo}#HEAD&path:/skills/hello-skill`,
- resolution: { type: 'git', url: gitRepo, commit, path: '/skills/hello-skill' },
- digest: sha256(`${gitRepo}:${commit}:/skills/hello-skill`),
- },
- },
- }),
- )
-
- const result = await updateCommand({ cwd: root })
- expect(result.unchanged).toEqual(['hello-skill'])
- expect(result.updated).toEqual([])
- expect(result.status).toBe('skipped')
- })
-
- it('returns failed when any target cannot resolve and keeps the old lockfile', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-resolve-fail-'))
-
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- broken: '/definitely/missing/repo.git#main&path:/skills/broken',
- },
- },
- null,
- 2,
- ),
- )
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- YAML.stringify({
- lockfileVersion: '0.1',
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {},
- }),
- )
-
- const result = await updateCommand({ cwd: root })
- expect(result.status).toBe('failed')
- expect(result.failed).toHaveLength(1)
- const persisted = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- expect(persisted.skills).toEqual({})
- })
-
- it('treats semantically identical file entries with different key order as unchanged', async () => {
- const root = mkdtempSync(path.join(tmpdir(), 'skills-pm-update-stable-equal-'))
- const packageRoot = createSkillPackage('hello-skill', '# Hello stable\n')
- const tarballPath = packDirectory(packageRoot)
-
- writeFileSync(
- path.join(root, 'skills.json'),
- JSON.stringify(
- {
- installDir: '.agents/skills',
- linkTargets: [],
- skills: {
- 'hello-skill': `file:${tarballPath}#path:/skills/hello-skill`,
- },
- },
- null,
- 2,
- ),
- )
-
- writeFileSync(
- path.join(root, 'skills-lock.yaml'),
- [
- "lockfileVersion: '0.1'",
- 'installDir: .agents/skills',
- 'linkTargets: []',
- 'skills:',
- ' hello-skill:',
- ` digest: ${sha256('stable')}`,
- ' specifier: file:../../ignored#path:/skills/hello-skill',
- ' resolution:',
- ' path: /skills/hello-skill',
- ` tarball: ${JSON.stringify(path.relative(root, tarballPath))}`,
- ' type: file',
- '',
- ].join('\n'),
- )
-
- const resolved = await resolveLockEntry(
- root,
- `file:${tarballPath}#path:/skills/hello-skill`,
- 'hello-skill',
- )
- const existingLock = YAML.parse(readFileSync(path.join(root, 'skills-lock.yaml'), 'utf8'))
- existingLock.skills['hello-skill'] = {
- digest: resolved.entry.digest,
- specifier: resolved.entry.specifier,
- resolution: {
- path: resolved.entry.resolution.type === 'file' ? resolved.entry.resolution.path : '/',
- tarball: resolved.entry.resolution.type === 'file' ? resolved.entry.resolution.tarball : '',
- type: 'file',
+ const initialManifest = {
+ installDir: '.agents/skills',
+ linkTargets: [],
+ skills: {
+ 'hello-skill': `${gitRepo}#old&path:/skills/hello-skill`,
},
}
- writeFileSync(path.join(root, 'skills-lock.yaml'), YAML.stringify(existingLock))
+ writeFileSync(path.join(root, 'skills.json'), JSON.stringify(initialManifest, null, 2))
const result = await updateCommand({ cwd: root })
+ const manifest = JSON.parse(readFileSync(path.join(root, 'skills.json'), 'utf8'))
- expect(result.updated).toEqual([])
- expect(result.unchanged).toEqual(['hello-skill'])
+ expect(result.status).toBe('failed')
+ expect(result.failed[0].name).toBe('hello-skill')
+ expect(manifest).toEqual(initialManifest)
})
})
diff --git a/website/docs/_pnpm.mdx b/website/docs/_pnpm.mdx
index 8078e66..ec868ce 100644
--- a/website/docs/_pnpm.mdx
+++ b/website/docs/_pnpm.mdx
@@ -33,24 +33,30 @@ npx skills-package-manager init --yes
npx skills-package-manager add vercel-labs/skills
npx skills-package-manager add https://github.com/rstackjs/agent-skills
-# Non-interactive β add a specific skill by name
+# Non-interactive β add one or more specific skills by name
npx skills-package-manager add vercel-labs/skills --skill find-skills
+npx skills-package-manager add vercel-labs/skills -s frontend-design -s skill-creator
npx skills-package-manager add vercel-labs/skills@find-skills
+# skills CLI-compatible inspection and broad install flags
+npx skills-package-manager add vercel-labs/skills --list
+npx skills-package-manager add vercel-labs/skills --all
+
# Direct repo subpath or local path
-npx skills-package-manager add https://github.com/tool-belt/skills/tree/main/guides/design/landing-page-design#main
+npx skills-package-manager add https://github.com/tool-belt/skills/tree/main/guides/design/landing-page-design
npx skills-package-manager add ./my-skills
```
+GitHub sources are written to `skills.json` using pinned `github:owner/repo#&path:` specifiers.
+
Example `skills.json`:
```json
{
"installDir": ".agents/skills",
"linkTargets": [".claude/skills"],
- "selfSkill": false,
"skills": {
- "find-skills": "https://github.com/vercel-labs/skills.git#path:/skills/find-skills"
+ "find-skills": "github:vercel-labs/skills#abc1234&path:/skills/find-skills"
}
}
```
@@ -59,7 +65,7 @@ This manifest defines:
- `installDir`: where managed skills are materialized
- `linkTargets`: which agent-specific directories receive links
-- `selfSkill`: whether to auto-install the bundled `skills-package-manager-cli` skill for help with `skills.json`, `skills-lock.yaml`, and `npx skills-package-manager` commands. This helper skill is not written to `skills-lock.yaml`. Defaults to `false`
+- `selfSkill`: whether to auto-install the bundled `skills-package-manager-cli` skill for help with `skills.json` and `npx skills-package-manager` commands. Defaults to `false`
- `skills`: which skills should be installed
### Run pnpm as usual
@@ -74,9 +80,8 @@ During this process, the plugin will:
1. Read `skills.json`
2. Resolve each skill source
-3. Generate or update `skills-lock.yaml`
-4. Materialize skills into `installDir`
-5. Link them into every directory listed in `linkTargets`
+3. Materialize skills into `installDir`
+4. Link them into every directory listed in `linkTargets`
That means your regular pnpm bootstrap flow now also restores agent skill context.
@@ -99,12 +104,13 @@ When you want to introduce another skill, you can either edit `skills.json` dire
```bash
npx skills-package-manager add vercel-labs/skills --skill find-skills
npx skills-package-manager add https://github.com/rstackjs/agent-skills --skill rspress-custom-theme
+npx skills-package-manager add vercel-labs/skills --list
```
## 3. Refresh remote skill versions
-When remote git-based (URL) or `npm:` skills should move forward to newer resolved versions, run:
+When remote git-based (URL or `github:`) or `npm:` skills should move forward, run:
```bash
npx skills-package-manager update
@@ -116,4 +122,4 @@ Then run:
pnpm install
```
-This keeps the resolved lockfile state and linked directories aligned with the latest declared versions.
+`update` writes newer pins back to `skills.json`; `pnpm install` then restores the linked directories from that manifest.
diff --git a/website/docs/api/commands.mdx b/website/docs/api/commands.mdx
index cdfb1a2..4d498d8 100644
--- a/website/docs/api/commands.mdx
+++ b/website/docs/api/commands.mdx
@@ -2,142 +2,79 @@
## `npx skills-package-manager add`
-Add one or more skills, then install and link them immediately after writing the manifest.
-
-For the `npx` workflow, this is also the main migration pitch: teams already familiar with `npx skills add ...` can usually start by replacing it with `npx skills-package-manager add ...` and keep the same high-level add habit.
-
-### Options
+Add one or more skills, write pinned specifiers to `skills.json`, then install and link them immediately.
| Option | Description |
|--------|-------------|
-| `--skill ` | Select a specific skill when the source contains multiple skills. |
+| `-s, --skill ` | Select one or more specific skills when the source contains multiple skills. Repeat the flag for multiple skills. |
+| `-l, --list` | List available skills without writing `skills.json` or installing. |
| `-y, --yes` | Skip prompts. When `--skill` is omitted and multiple skills are discovered, add all of them. |
-| `-a, --agent ` | Append compatible agent `linkTargets` for this add. Repeat the flag to target multiple agents. Agents that already read from `.agents/skills` locally do not add a separate project `linkTarget`. |
-| `-g, --global` | Add to the global skills workspace instead of the current project. On first global use, provide at least one `--agent` so the global manifest knows which directories to link into. |
-
-### Examples
+| `-a, --agent ` | Append compatible agent `linkTargets` for this add. Repeat the flag to target multiple agents. |
+| `--all` | Add all discovered skills to all known project agent targets. |
+| `--copy` | Accepted for `npx skills add` compatibility. SPM still uses one canonical install directory and agent links. |
+| `-g, --global` | Add to the global skills workspace instead of the current project. |
```bash
-# Add one skill from a multi-skill source
+npx skills-package-manager add vercel-labs/agent-skills
+npx skills-package-manager add https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines
npx skills-package-manager add https://github.com/rstackjs/agent-skills --skill rspress-custom-theme
-
-# Add all discovered skills and link them into multiple local agent directories
+npx skills-package-manager add vercel-labs/agent-skills -s frontend-design -s skill-creator
+npx skills-package-manager add vercel-labs/agent-skills --list
+npx skills-package-manager add vercel-labs/agent-skills --all
npx skills-package-manager add ../local-skills --agent claude-code --agent continue -y
-
-# Add to the global workspace using an agent-specific global directory
-npx skills-package-manager add ../local-skills -g --agent claude-code --skill hello-skill -y
+npx skills-package-manager add npm:@scope/skills-package@1.0.0&path:/skills/my-skill
```
+GitHub sources added through shorthand, full GitHub URLs, or GitHub tree URLs are persisted in `skills.json` as `github:owner/repo#&path:`.
+
## `npx skills-package-manager init`
Initialize a new `skills.json` manifest.
- `npx skills-package-manager init`: interactive initialization for `installDir` and additional `linkTargets`
- `npx skills-package-manager init --yes`: non-interactive initialization with defaults
-- `selfSkill` defaults to `false` in the generated manifest
- Fails if `skills.json` already exists
## `npx skills-package-manager install`
-Install all skills defined in `skills.json`, synchronize `skills-lock.yaml`, `installDir`, and `linkTargets`, and also install the bundled `skills-package-manager-cli` self skill when `selfSkill` is enabled. The helper skill is not written to `skills-lock.yaml`.
+Install all skills defined in `skills.json`, materialize managed skills into `installDir`, link them into every `linkTarget`, and inject the bundled `skills-package-manager-cli` self skill when `selfSkill` is enabled.
-If `patchedSkills` contains an entry for a managed skill, the referenced patch file is applied after the base skill content is materialized. `local:` skills cannot be patched because their source directories are user-owned.
-
-### Options
-
-| Option | Description |
-|--------|-------------|
-| `--frozen-lockfile` | Fail if lockfile is out of sync instead of updating it. Requires lockfile to exist and match manifest specifiers. |
+`install` does not create a separate lock file. Remote reproducibility comes from pinned specifiers in `skills.json`.
-### Specifier Compatibility
-
-When using `--frozen-lockfile`, the manifest and lockfile specifiers are compared semantically:
-
-| Manifest | Lockfile | Result |
-|----------|----------|--------|
-| `git://repo.git#path:/skill` | `git://repo.git#abc123&path:/skill` | β Compatible (manifest accepts any ref) |
-| `git://repo.git#main&path:/skill` | `git://repo.git#main&path:/skill` | β Compatible (refs match) |
-| `git://repo.git#abc123&path:/skill` | `git://repo.git#abc123&path:/skill` | β Compatible (refs match) |
-| `git://repo.git#abc123&path:/skill` | `git://repo.git#def456&path:/skill` | β Incompatible (refs differ) |
-| `git://repo.git#path:/skill-a` | `git://repo.git#abc123&path:/skill-b` | β Incompatible (paths differ) |
-
-Manifest specifiers without a ref (commit/branch) are compatible with any lockfile ref. This allows teams to omit specific commits in `skills.json` while still using `--frozen-lockfile` in CI.
-
-### Examples
-
-#### First-time install
-
-When setting up a project for the first time (no lockfile exists):
-
-```bash
-npx skills-package-manager install
-```
-
-This will:
-
-- Resolve all git refs to specific commits
-- Create `skills-lock.yaml` with pinned versions
-- Install skills to `installDir`
-- Create symlinks in `linkTargets`
-
-#### CI install with frozen lockfile
-
-In CI/CD pipelines, use `--frozen-lockfile` for reproducible builds:
-
-```bash
-npx skills-package-manager install --frozen-lockfile
-```
-
-This ensures:
-
-- The exact versions in `skills-lock.yaml` are installed
-- No network requests to resolve git refs (faster)
-- CI fails if lockfile is out of sync (catching misconfigurations)
-
-#### After adding a new skill
-
-After running `npx skills-package-manager add` or manually editing `skills.json`:
+If `patchedSkills` contains an entry for a managed skill, the referenced patch file is applied after the base skill content is materialized. `local:` skills cannot be patched because their source directories are user-owned.
```bash
npx skills-package-manager install
```
-This will:
-
-- Resolve the new skill's git ref
-- Update `skills-lock.yaml` with the new entry
-- Install and link the new skill
-
-#### Troubleshooting out-of-sync errors
+## `npx skills-package-manager update`
-If you get "Lockfile is out of sync" with `--frozen-lockfile`:
+Refresh selected remote skills and write the new pins back to `skills.json` after the updated install succeeds.
```bash
-# Option 1: Update the lockfile (recommended for local dev)
-npx skills-package-manager install
-
-# Option 2: Check what changed
-git diff skills.json skills-lock.yaml
+npx skills-package-manager update
+npx skills-package-manager update find-skills rspress-custom-theme
```
-## `npx skills-package-manager update`
+Behavior:
-Refresh the resolvable skills declared in the manifest to their latest locked versions, with the option to update only selected skills.
+- Git and `github:` skills are updated to the latest `main` commit.
+- `npm:` skills are updated to the registry `latest` version.
+- `link:`, `local:`, and `file:` skills are skipped.
+- Unknown skill names fail before any install or manifest write.
## `npx skills-package-manager patch`
Prepare a skill for patching.
-### Options
-
| Option | Description |
|--------|-------------|
| `--edit-dir ` | Extract the editable copy into a specific directory instead of a generated temporary directory. |
| `--ignore-existing` | Start from the unpatched base content even if the skill already has a committed patch. |
-### Workflow
+Workflow:
-1. Resolve the currently locked content for the target skill.
+1. Resolve the current manifest content for the target skill.
2. Extract an editable copy.
3. Reapply the committed patch by default so you continue from the current patched state.
4. Write patch edit metadata for `patch-commit`.
@@ -146,17 +83,14 @@ Prepare a skill for patching.
Generate a patch file from an edited patch directory and commit it into the project.
-### Options
-
| Option | Description |
|--------|-------------|
| `--patches-dir ` | Directory where the generated patch file should be written. Defaults to `patches/`, or reuses the existing patch path for that skill if one already exists. |
-### Workflow
+Workflow:
1. Read the patch edit metadata from the directory created by `patch`.
2. Diff the edited copy against the original resolved skill content.
3. Write a patch file such as `patches/hello-skill.patch`.
4. Record the patch under `patchedSkills` in `skills.json`.
-5. Update `skills-lock.yaml` with patch path and digest metadata.
-6. Reinstall and relink the patched skill immediately.
+5. Reinstall and relink the patched skill immediately.
diff --git a/website/docs/api/specifiers.mdx b/website/docs/api/specifiers.mdx
index aa9a9a0..97771d5 100644
--- a/website/docs/api/specifiers.mdx
+++ b/website/docs/api/specifiers.mdx
@@ -5,7 +5,8 @@ skills-package-manager uses specifiers to describe where skills come from.
## General format
```text
-git/file/npm: #[ref&]path:
+git/file/npm: [#ref][&path:]
+github: github:/#&path:
link: link:
local: local:
```
@@ -15,20 +16,23 @@ local: local:
| Type | Example |
| --- | --- |
| GitHub shorthand | `owner/repo` |
+| GitHub pinned shorthand | `github:owner/repo#6cb0992&path:/skills/my-skill` |
| GitHub URL | `https://github.com/owner/repo` |
| Git + path | `https://github.com/owner/repo.git#path:/skills/my-skill` |
| Git + ref + path | `https://github.com/owner/repo.git#main&path:/skills/my-skill` |
| Git + commit SHA + path | `https://github.com/owner/repo.git#6cb0992a176f2ca142e19f64dca8ac12025b035e&path:/skills/my-skill` |
| Local link | `link:./local-dir/skills/my-skill` |
| Existing local skill | `local:./.agents/skills/my-skill` |
+| Existing skill under `installDir` | `local:*` |
| Local tarball | `file:./skills-package.tgz#path:/skills/my-skill` |
-| npm package | `npm:@scope/skills-package#path:/skills/my-skill` |
+| npm package | `npm:@scope/skills-package@1.0.0&path:skills/my-skill` |
## Notes
- `source`: A Git repository URL, a local `link:` path, a user-owned `local:` path, a local `file:` tarball, or an `npm:` package name
- `ref`: An optional git ref, such as `main`, a tag, a full commit SHA, or a short commit SHA
- `path`: The directory of the skill inside the source repository or package
+- `local:*`: Resolves to `${installDir}/${skillName}` for skills already managed inside the configured install directory
## Resolution types
@@ -36,6 +40,6 @@ local: local:
- `link`: Symlink a local skill directory into `installDir`
- `local`: Use an existing user-owned skill directory in place, without copying or replacing it
- `file`: Extract the local `tgz` package and copy the skill files
-- `npm`: Resolve the package from the configured registry, lock the tarball URL/version/integrity, and copy the skill files
+- `npm`: Resolve the package from the configured registry and copy the skill files from the package or requested subdirectory
`npm:` reads `registry` and scoped `@scope:registry` from `.npmrc`, and reuses matching auth entries for private registries.
diff --git a/website/docs/architecture/_meta.json b/website/docs/architecture/_meta.json
index 3c3ba59..a9c58bd 100644
--- a/website/docs/architecture/_meta.json
+++ b/website/docs/architecture/_meta.json
@@ -1 +1 @@
-["manifest-and-lockfile", "cli-commands", "pnpm-plugin", "how-it-works"]
+["manifest", "cli-commands", "pnpm-plugin", "how-it-works"]
diff --git a/website/docs/architecture/cli-commands.mdx b/website/docs/architecture/cli-commands.mdx
index 04fabc5..bc4ef84 100644
--- a/website/docs/architecture/cli-commands.mdx
+++ b/website/docs/architecture/cli-commands.mdx
@@ -1,194 +1,49 @@
# CLI commands
-The documented CLI workflow centers on `npx skills-package-manager` for the six main actions of skill management: add, init, install, patch, patch-commit, and update.
+The CLI workflow centers on six actions: add, init, install, patch, patch-commit, and update.
## `npx skills-package-manager add`
-Add a skill to your project.
+`add` discovers or accepts a skill specifier, writes the selected skill to `skills.json`, and installs it immediately.
-```bash
-# Interactive discovery and selection
-npx skills-package-manager add owner/repo
-npx skills-package-manager add https://github.com/owner/repo
+For remote sources, `add` writes pinned specifiers:
-# Non-interactively add a specific skill
-npx skills-package-manager add owner/repo --skill find-skills
-npx skills-package-manager add owner/repo@find-skills
-npx skills-package-manager add owner/repo#main@find-skills
-
-# Direct repo subpath or local path
-npx skills-package-manager add owner/repo/skills/my-skill
-npx skills-package-manager add https://github.com/owner/repo/tree/main/skills/my-skill#main
-npx skills-package-manager add ./local-source
-
-# Use a full specifier directly
-npx skills-package-manager add https://github.com/owner/repo.git#path:/skills/my-skill
-npx skills-package-manager add link:./local-source/skills/my-skill
-npx skills-package-manager add local:./.agents/skills/my-skill
-npx skills-package-manager add file:./skills-package.tgz#path:/skills/my-skill
-npx skills-package-manager add npm:@scope/skills-package#path:/skills/my-skill
-```
-
-Behavior overview:
-
-1. Perform a shallow clone of the GitHub repository
-2. Scan `SKILL.md` files and discover candidate skills
-3. Select interactively or target a specific skill with `--skill`
-4. Write to `skills.json`
-5. Immediately install and link the new skill
+- GitHub and git sources are written with a resolved commit.
+- npm sources are written with a resolved version.
## `npx skills-package-manager init`
-Initialize `skills.json` in the current project.
-
-```bash
-# Interactive
-npx skills-package-manager init
-
-# Non-interactive defaults
-npx skills-package-manager init --yes
-```
-
-Init behavior:
-
-- `npx skills-package-manager init` prompts for `installDir` and additional `linkTargets`
-- `npx skills-package-manager init --yes` writes default values directly
-- The generated manifest sets `selfSkill` to `false`
-- Fails if `skills.json` already exists
-- Does not create `skills-lock.yaml`
+`init` creates `skills.json` in the current project. It prompts for `installDir` and `linkTargets` unless `--yes` is passed.
## `npx skills-package-manager install`
-Install all skills declared in `skills.json`.
-
-```bash
-npx skills-package-manager install
-```
-
-Useful for:
-
-- Initializing a new environment
-- Restoring skill dependencies in CI
-- Re-syncing the managed skill directory after changing the manifest
-
-### Install Process
-
-When you run `npx skills-package-manager install`, the following happens:
-
-1. **Load configuration** β Build `WorkspaceContext` from `skills.json`, `skills-lock.yaml`, `.npmrc`, and install state
- - If `selfSkill` is enabled, inject the bundled `skills-package-manager-cli` helper skill for this run without writing it to `skills-lock.yaml`
-2. **Resolve** β Run `ResolveTaskQueue` to resolve each skill specifier to a lock entry
- - This step may involve git/network requests, even when a corresponding entry already exists in `skills-lock.yaml`
-3. **Prune** β Remove skills that are no longer in the manifest
-4. **Fetch** β Run `FetchTaskQueue` to download/copy skills to `installDir`
- - Uses the content-addressable KV cache for npm tarballs
-5. **Link** β Run `LinkTaskQueue` to create symlinks in `linkTargets`
-6. **Write lockfile** β Save updated `skills-lock.yaml`
-
-The resolve, fetch, and link stages operate as a **pipeline**: as soon as a skill is resolved it can begin fetching, and as soon as it is fetched it can begin linking. Backpressure pauses upstream queues when downstream queues exceed their pending limits.
-
-### `--frozen-lockfile`
-
-Prevent lockfile modifications and fail if it's out of sync.
-
-```bash
-npx skills-package-manager install --frozen-lockfile
-```
-
-**When to use:**
+`install` restores everything declared in `skills.json`.
-- CI/build environments where you want reproducible installs
-- When you want to ensure the lockfile is not accidentally modified
-- When you want faster installs (no git network requests to resolve refs)
+Install process:
-**Behavior differences from normal install:**
+1. Load `skills.json`, `.npmrc`, cache, and install state.
+2. Resolve the manifest into an in-memory install plan.
+3. Inject the bundled helper skill when `selfSkill` is enabled.
+4. Prune managed skills that are no longer declared.
+5. Fetch managed skills into `installDir`.
+6. Link skills into `linkTargets`.
+7. Write `.skills-pm-install-state.json` for repeat-install fast paths.
-| Aspect | Normal install | `--frozen-lockfile` |
-|--------|---------------|---------------------|
-| Lockfile updates | Yes, if manifest changed | No, fails if out of sync |
-| Git network requests | Yes, to resolve refs | No, uses locked commits |
-| First-time setup | Works without lockfile | Requires existing lockfile |
-| Use case | Development, updating deps | CI, reproducible builds |
-
-**Error scenarios:**
-
-| Error | Cause | Solution |
-|-------|-------|----------|
-| "Lockfile is required in frozen mode" | No `skills-lock.yaml` exists | Run `npx skills-package-manager install` without flag first |
-| "Lockfile is out of sync" | Manifest specifiers don't match lockfile | Run `npx skills-package-manager install` without flag to update lockfile |
-
-### Troubleshooting
-
-**Install fails with "Lockfile is out of sync"**
-
-This means the specifiers in `skills.json` don't match what's in `skills-lock.yaml`. Common causes:
-
-1. You edited `skills.json` manually without updating the lockfile
-2. Someone else committed a new lockfile and your manifest is outdated
-
-Solutions:
-
-- Run `npx skills-package-manager install` without `--frozen-lockfile` to update the lockfile
-- Or revert your manifest changes to match the lockfile
+No separate lock file is written.
## `npx skills-package-manager update`
-Refresh already-declared resolvable skills.
-
-```bash
-npx skills-package-manager update
-npx skills-package-manager update find-skills rspress-custom-theme
-```
+`update` refreshes already-declared remote skills.
-Update behavior:
-
-- Uses `skills.json` as the source of truth
-- Re-resolves git refs and npm package targets
-- Skips local `link:` and `local:` skills, including the bundled self skill
-- Fails immediately on unknown skill names
-- Writes back `skills-lock.yaml` only after fetch and link both succeed
+- Git and `github:` skills move to the latest `main` commit.
+- `npm:` skills move to the registry `latest` version.
+- `link:`, `local:`, and `file:` skills are skipped.
+- `skills.json` is written only after the updated install succeeds.
## `npx skills-package-manager patch`
-Prepare a skill for local editing.
-
-```bash
-npx skills-package-manager patch hello-skill
-npx skills-package-manager patch hello-skill --edit-dir ./tmp/hello-skill
-```
-
-Patch behavior:
-
-- Resolves the currently locked content of the target skill
-- Extracts an editable copy into a temporary directory by default
-- Reapplies the committed patch unless `--ignore-existing` is passed
-- Stores patch edit metadata for `patch-commit`
+`patch` resolves the current manifest content for a skill, extracts an editable copy, and records patch edit metadata.
## `npx skills-package-manager patch-commit`
-Commit an edited patch directory back into the project.
-
-```bash
-npx skills-package-manager patch-commit /tmp/skills-pm-patch-hello-skill-12345
-npx skills-package-manager patch-commit ./tmp/hello-skill --patches-dir ./custom-patches
-```
-
-Patch-commit behavior:
-
-- Reconstructs the original unpatched skill content from the edit metadata
-- Generates a unified diff patch file
-- Writes that patch file to `patches/` by default
-- Records the patch in `skills.json` under `patchedSkills`
-- Stores patch path and digest metadata in `skills-lock.yaml`
-- Reinstalls the patched skill immediately so `installDir` stays current
-
-## When to use which
-
-| Command | Typical scenario |
-| --- | --- |
-| `npx skills-package-manager add` | Introduce a new skill for the first time |
-| `npx skills-package-manager init` | Create the initial `skills.json` manifest |
-| `npx skills-package-manager install` | Restore all skills from the manifest |
-| `npx skills-package-manager patch` | Open an installed skill for patch editing |
-| `npx skills-package-manager patch-commit` | Save an edited patch and reapply it to the project |
-| `npx skills-package-manager update` | Refresh versions of resolvable git or npm skills |
+`patch-commit` generates a patch file, records it under `patchedSkills` in `skills.json`, and immediately reinstalls the patched skill.
diff --git a/website/docs/architecture/how-it-works.mdx b/website/docs/architecture/how-it-works.mdx
index e203977..2c63fe7 100644
--- a/website/docs/architecture/how-it-works.mdx
+++ b/website/docs/architecture/how-it-works.mdx
@@ -1,132 +1,55 @@
# How it works
-The installation flow of skills-package-manager is built around a **unified pipeline** with three concurrent task queues: **resolve**, **fetch**, and **link**. For `spm install`, the lockfile is resolved first (via `syncSkillsLock`) so that the pipeline can skip the resolve stage and stream fetch/link work directly. In programmatic use (e.g. `runPipeline` with `skipResolve: false`), the queues run concurrently with backpressure control, maximizing I/O throughput while keeping memory usage bounded.
+The installation flow is built around a unified pipeline with three concurrent task queues: **resolve**, **fetch**, and **link**. `spm install` resolves `skills.json` into an in-memory install plan, streams that plan through the pipeline, and records only lightweight install state for fast repeat installs.
## 1. Load configuration
-The pipeline starts by loading all configuration into a single `WorkspaceContext`:
+The pipeline starts by loading:
-- `skills.json` (manifest)
-- `skills-lock.yaml` (lockfile)
-- `.npmrc` (npm registry config)
-- `.skills-pm-install-state.json` (install state, if present)
-
-This context is passed through every stage, eliminating redundant file reads and giving each queue access to shared state and cache.
+- `skills.json`
+- `.npmrc` npm registry config
+- `.skills-pm-install-state.json` from `installDir`, when present
## 2. Resolve specifiers
-The `ResolveTaskQueue` resolves each skill specifier in parallel (default concurrency: 8):
-
-- GitHub shorthand: `owner/repo`
-- Git URL: `https://github.com/owner/repo.git`
-- Git URL + `path:`
-- Git URL + `ref` + `path:`
-- Local `link:` and `local:` skills
-- Local `file:` tarballs
-- `npm:` package sources
+The resolver turns manifest specifiers into install-plan entries:
-Resolvers are pluggable: each specifier type has its own resolver module under `src/resolvers/`.
+- `github:` and git URL sources resolve to a concrete commit
+- `npm:` sources resolve to package metadata and tarball details
+- `file:` tarballs resolve to a local package path and skill subpath
+- `link:` and `local:` sources resolve to local directories
## 3. Fetch into installDir
-As soon as a skill is resolved, it is pushed to the `FetchTaskQueue` (default concurrency: 4). Fetching does **not** wait for all resolutions to finish β the pipeline streams results forward.
-
-The fetch stage includes a **content-addressable KV cache** for npm tarballs and other downloadable assets, avoiding redundant network requests across installs.
-
-Fetchers are also pluggable, living under `src/fetchers/`.
+Resolved skills are fetched into `installDir`. The npm fetcher uses a persistent tarball cache to avoid redundant downloads.
## 4. Link to target directories
-Once a skill is fetched, it flows into the `LinkTaskQueue` (default concurrency: 16), which creates symlinks from `installDir` to each `linkTarget` directory (e.g., `.claude/skills`).
+Fetched skills flow into the link queue, which creates symlinks from `installDir` to each `linkTarget` directory.
## 5. Prune old skills
-Before any fetch begins, the system prunes skills in `installDir` that are no longer in the manifest or lockfile, preventing stale content from accumulating.
+Before fetch begins, managed skills that are no longer declared in `skills.json` are removed from `installDir` and `linkTargets`.
## Pipeline architecture
```mermaid
flowchart LR
- subgraph Config ["1. Config Loader"]
- A["skills.json"]
- B["skills-lock.yaml"]
- C[".npmrc"]
- D["install state"]
- end
-
- subgraph Context ["WorkspaceContext"]
- E["manifest + lock + npmConfig + cache"]
- end
-
- subgraph Resolve ["2. ResolveTaskQueue"]
- F["GitResolver"]
- G["NpmResolver"]
- H["FileResolver"]
- I["LinkResolver"]
- end
-
- subgraph Fetch ["3. FetchTaskQueue"]
- J["KV Cache"]
- K["Git Fetcher"]
- L["Npm Fetcher"]
- M["File Fetcher"]
- N["Link Fetcher"]
- end
-
- subgraph Link ["4. LinkTaskQueue"]
- O["symlink to linkTargets"]
- end
-
- subgraph Output ["5. Output"]
- P["installDir/ (physical files)"]
- Q["linkTargets/ (symlinks)"]
- R["updated lockfile"]
- end
-
- A & B & C & D --> E
- E --> Resolve
- F & G & H & I -->|emit FetchTask| Fetch
- J --> K & L & M & N
- K & L & M & N -->|emit LinkTask| O
- O --> P & Q
- Fetch --> R
-
- style E fill:#2dd4bf,stroke:#14b8a6,color:#000
- style P fill:#f1fa8c,stroke:#d1d100,color:#000
- style Q fill:#bd93f9,stroke:#9b6dff,color:#000
-```
-
-## Pipelining & backpressure
-
-```mermaid
-sequenceDiagram
- participant R as ResolveTaskQueue
- participant B as PipelineBus
- participant F as FetchTaskQueue
- participant L as LinkTaskQueue
-
- R->>B: emitResolved(skillA)
- B->>F: enqueue FetchTask(skillA)
- R->>B: emitResolved(skillB)
- B->>F: enqueue FetchTask(skillB)
- F->>B: emitFetched(skillA)
- B->>L: enqueue LinkTask(skillA)
- F->>B: emitFetched(skillB)
- B->>L: enqueue LinkTask(skillB)
- L->>B: emitLinked(skillA)
- L->>B: emitLinked(skillB)
+ A["skills.json"] --> B["WorkspaceContext"]
+ C[".npmrc"] --> B
+ D["install state"] --> B
+ B --> E["ResolveTaskQueue"]
+ E --> F["FetchTaskQueue"]
+ F --> G["LinkTaskQueue"]
+ G --> H["installDir"]
+ G --> I["linkTargets"]
+ F --> J["install state"]
```
-- **Backpressure**: If `FetchTaskQueue` exceeds 20 pending tasks, `ResolveTaskQueue` pauses. If `LinkTaskQueue` exceeds 20 pending tasks, `FetchTaskQueue` pauses.
-- **Concurrency limits**: Resolve (8), Fetch (4), Link (16). These can be tuned per command.
-
## Design goals
-The goal of this flow is not to treat skills as ordinary npm packages, but to bring "AI agent capability units" into an engineering workflow:
-
- **Declarative**: `skills.json` is the single source of truth.
-- **Reproducible**: `skills-lock.yaml` pins exact versions and digests.
-- **Linkable**: One install, many agent directories.
-- **Updatable**: Selective updates with `spm update`.
-- **Auditable**: Every resolution and fetch is recorded.
+- **Pinned**: Git commits and npm versions live directly in `skills.json`.
+- **Linkable**: One install can serve many agent directories.
+- **Updatable**: `spm update` selectively refreshes git and npm pins.
- **Concurrent**: Pipeline parallelism minimizes install time.
diff --git a/website/docs/architecture/manifest-and-lockfile.mdx b/website/docs/architecture/manifest-and-lockfile.mdx
deleted file mode 100644
index 5e019b5..0000000
--- a/website/docs/architecture/manifest-and-lockfile.mdx
+++ /dev/null
@@ -1,111 +0,0 @@
-# Manifest and lockfile
-
-skills-package-manager uses two files to describe and lock the installed state of skills.
-
-## `skills.json`
-
-`skills.json` is a declarative manifest that describes which skills a project needs and where they should be installed.
-
-```json
-{
- "installDir": ".agents/skills",
- "linkTargets": [".claude/skills"],
- "selfSkill": false,
- "patchedSkills": {
- "find-skills": "patches/find-skills.patch"
- },
- "skills": {
- "find-skills": "https://github.com/vercel-labs/skills.git#path:/skills/find-skills",
- "my-local-skill": "link:./local-source/skills/my-local-skill",
- "my-existing-skill": "local:./.agents/skills/my-existing-skill",
- "my-packed-skill": "file:./skills-package.tgz#path:/skills/my-packed-skill",
- "my-npm-skill": "npm:@scope/skills-package#path:/skills/my-npm-skill"
- }
-}
-```
-
-Field descriptions:
-
-- `installDir`: The directory where managed skills are written.
-- `linkTargets`: A list of target directories where symbolic links should be created.
-- `selfSkill`: Whether to auto-install the bundled `skills-package-manager-cli` skill during install. It is not written to `skills-lock.yaml`. Defaults to `false`.
-- `patchedSkills`: Optional mapping from skill name to a committed patch file path.
-- `skills`: A mapping from skill name to specifier.
-
-## `skills-lock.yaml`
-
-`skills-lock.yaml` locks resolved results so installations produce consistent content across different machines and points in time.
-
-```yaml
-lockfileVersion: "0.1"
-installDir: .agents/skills
-skills:
- find-skills:
- specifier: https://github.com/vercel-labs/skills.git#path:/skills/find-skills
- resolution:
- type: git
- url: https://github.com/vercel-labs/skills.git
- commit: abc1234...
- path: /skills/find-skills
- digest: sha256-...
- patch:
- path: patches/find-skills.patch
- digest: sha256-...
-```
-
-It typically contains:
-
-- The resolved source type: `git`, `link`, `local`, `file`, or `npm`
-- A specific commit, local content digest, tarball reference, or resolved package version and integrity
-- The skill path
-- The final content digest
-- Optional patch path and patch digest metadata
-
-## Why both exist
-
-- `skills.json` captures the team-maintained **intent**.
-- `skills-lock.yaml` captures the installer-resolved **result**.
-
-The former is suitable for review; the latter is suitable for reproducibility.
-
-## Recommended practices
-
-1. Always commit both `skills.json` and `skills-lock.yaml`
-2. Standardize `installDir` and `linkTargets` across the team
-3. Prefer explicit GitHub specifiers with `path:` for external skills
-4. Use `local:` for existing user-owned skill directories that should not be copied or replaced
-
-## Specifier Compatibility
-
-When comparing manifest and lockfile specifiers (e.g., with `npx skills-package-manager install --frozen-lockfile`), the following rules apply:
-
-1. **Source must match**: The git URL, local path, tarball path, or npm source must be identical
-2. **Path must match**: The skill path within the repository must be identical
-3. **Ref compatibility**:
- - Manifest without ref β compatible with any lock ref (use lock version)
- - Manifest with ref β must match lock ref exactly
-
-This means you can omit the commit SHA in `skills.json`:
-
-```json
-{
- "skills": {
- "my-skill": "https://github.com/owner/repo.git#path:/skills/my-skill"
- }
-}
-```
-
-And the lockfile will pin the exact version:
-
-```yaml
-skills:
- my-skill:
- specifier: https://github.com/owner/repo.git#abc1234&path:/skills/my-skill
- resolution:
- type: git
- url: https://github.com/owner/repo.git
- commit: abc1234...
- path: /skills/my-skill
-```
-
-The `--frozen-lockfile` flag will accept this combination because the manifest specifier (without ref) is satisfied by the lockfile specifier (with resolved commit).
diff --git a/website/docs/architecture/manifest.mdx b/website/docs/architecture/manifest.mdx
new file mode 100644
index 0000000..10db2a1
--- /dev/null
+++ b/website/docs/architecture/manifest.mdx
@@ -0,0 +1,48 @@
+# Manifest
+
+skills-package-manager uses `skills.json` as the single user-maintained configuration file.
+
+## `skills.json`
+
+`skills.json` describes which skills a project needs, where they should be installed, and which agent directories should receive symlinks.
+
+```json
+{
+ "installDir": ".agents/skills",
+ "linkTargets": [".claude/skills"],
+ "selfSkill": false,
+ "patchedSkills": {
+ "find-skills": "patches/find-skills.patch"
+ },
+ "skills": {
+ "find-skills": "github:vercel-labs/skills#abc1234&path:/skills/find-skills",
+ "my-linked-skill": "link:./local-source/skills/my-linked-skill",
+ "my-existing-skill": "local:*",
+ "my-packed-skill": "file:./skills-package.tgz&path:/skills/my-packed-skill",
+ "my-npm-skill": "npm:@scope/skills-package@1.0.0&path:/skills/my-npm-skill"
+ }
+}
+```
+
+Field descriptions:
+
+- `installDir`: The directory where managed skills are written.
+- `linkTargets`: Target directories where symbolic links should be created.
+- `selfSkill`: Whether to auto-install the bundled `skills-package-manager-cli` skill during install. Defaults to `false`.
+- `patchedSkills`: Optional mapping from skill name to a committed patch file path.
+- `skills`: A mapping from skill name to specifier.
+
+## Pinning Model
+
+Remote versions are pinned in `skills.json`:
+
+- `add` resolves git refs to commits and npm packages to versions before writing the manifest.
+- `update` moves git skills to the latest `main` commit and npm skills to the registry `latest` version.
+- `install` resolves the manifest into an in-memory install plan and does not write a separate lock file.
+
+## Recommended Practices
+
+1. Commit `skills.json`.
+2. Ignore generated skill directories such as `.agents/skills` and `.claude/skills`.
+3. Prefer explicit `github:` or git specifiers with `path:` for external skills.
+4. Use `local:*` for existing user-owned skills that live under `${installDir}/${skillName}`.
diff --git a/website/docs/architecture/pnpm-plugin.mdx b/website/docs/architecture/pnpm-plugin.mdx
index 0cc594d..d66ed6b 100644
--- a/website/docs/architecture/pnpm-plugin.mdx
+++ b/website/docs/architecture/pnpm-plugin.mdx
@@ -7,10 +7,10 @@
The plugin hooks into pnpm's `preResolution` lifecycle and performs skill synchronization before dependency resolution. A typical run will:
1. Read `skills.json` from the workspace root
-2. Resolve and synchronize `skills-lock.yaml`
+2. Resolve the manifest into an in-memory installation plan
3. Materialize skills into `installDir`
4. Create symbolic links for all `linkTargets`
-5. Take the fast path when the lockfile has not changed
+5. Update the internal install state for future incremental runs
## Enable it
@@ -25,7 +25,7 @@ Then add the following to the workspace root:
"installDir": ".agents/skills",
"linkTargets": [".claude/skills"],
"skills": {
- "my-skill": "https://github.com/owner/repo.git#path:/skills/my-skill"
+ "my-skill": "github:owner/repo#abc1234&path:/skills/my-skill"
}
}
```
diff --git a/website/docs/getting-started.mdx b/website/docs/getting-started.mdx
index b0d6b0c..0a957c0 100644
--- a/website/docs/getting-started.mdx
+++ b/website/docs/getting-started.mdx
@@ -32,6 +32,10 @@ npx skills-package-manager add rstackjs/agent-skills
# Explicitly add a specific skill
npx skills-package-manager add rstackjs/agent-skills --skill pr-creator
+
+# List skills or install all skills using skills CLI-compatible flags
+npx skills-package-manager add rstackjs/agent-skills --list
+npx skills-package-manager add rstackjs/agent-skills --all
```
### Materialize & Link
@@ -41,7 +45,7 @@ npx skills-package-manager install
```
This command will:
-1. Resolve dependencies and generate `skills-lock.yaml`.
+1. Resolve each specifier from `skills.json`.
2. Download/Copy files to `.agents/skills`.
3. Symlink them to your agent folders (e.g., `.claude/skills`).
@@ -54,7 +58,7 @@ Remember to add `.agents/skills` and your agent link targets to your `.gitignore
## 2. Advanced Workflow
### Update to latest versions
-To move your git-based skills to their latest commits:
+To move remote git skills to the latest `main` commit and npm skills to the registry `latest` version:
```bash
npx skills-package-manager update
```
diff --git a/website/docs/index.mdx b/website/docs/index.mdx
index a83817c..d4eac49 100644
--- a/website/docs/index.mdx
+++ b/website/docs/index.mdx
@@ -4,7 +4,7 @@ pageType: home
hero:
name: skills-package-manager
text: The Next-Gen Package Manager for Agent Skills
- tagline: Seamlessly manage, install, and link AI agent skills with lockfile-driven reproducibility and multi-protocol support.
+ tagline: Seamlessly manage, install, and link AI agent skills from a single skills.json manifest.
image:
src: /logo-light.svg
alt: skills-package-manager logo
@@ -17,9 +17,9 @@ hero:
link: /introduction
features:
- - title: Lockfile-Driven Versioning
- details: Ditch heavy git commits. `skills-lock.yaml` ensures every team member and CI/CD environment runs on identical skill versions.
- icon: lock
+ - title: Single-File Pins
+ details: Keep exact GitHub commits, npm versions, local links, install directories, and agent link targets in `skills.json`.
+ icon: manifest
- title: Any Source, Any Skill
details: Mix local `link:` or `local:`, versioned `npm:`, or direct `git:` repos with easeβeven sub-folders within `.tgz` archives.
icon: globe
@@ -51,7 +51,7 @@ concepts:
- title: 'skills.json: The Manifest'
details: 'The single source of truth for your required skills across any protocol.'
icon: 'manifest'
- - title: 'skills-lock.yaml: The Lockfile'
- details: 'Locks dependencies to ensure deterministic, reproducible installations every time.'
- icon: 'lockfile'
+ - title: 'Pinned Specifiers'
+ details: '`add` and `update` write resolved git commits and npm versions back to the manifest.'
+ icon: 'lock'
---
diff --git a/website/rspress.config.ts b/website/rspress.config.ts
index a3d96ea..4fa9945 100644
--- a/website/rspress.config.ts
+++ b/website/rspress.config.ts
@@ -9,7 +9,7 @@ export default defineConfig({
title: 'skills-package-manager | The Next-Gen Agent Skill Manager',
logoText: 'skills-package-manager',
description:
- 'Manage, install, and link SKILL.md-based AI agent skills with lockfile-driven reproducibility and multi-protocol support.',
+ 'Manage, install, and link SKILL.md-based AI agent skills from one pinned skills.json manifest.',
logo: {
light: '/logo-light.svg',
dark: '/logo-dark.svg',
diff --git a/website/theme/components/HomePage/index.tsx b/website/theme/components/HomePage/index.tsx
index 9693015..3b2903a 100644
--- a/website/theme/components/HomePage/index.tsx
+++ b/website/theme/components/HomePage/index.tsx
@@ -136,7 +136,7 @@ function getConceptIcon(icon: string) {
)
}
- if (icon === 'lockfile') {
+ if (icon === 'lock') {
return (
@@ -307,7 +307,7 @@ function Terminal() {
β Linking .cursor/skills
,
- β Updating skills-lock.yaml
+ β Writing install state
,
β¨ Done in 1.2s
@@ -365,8 +365,6 @@ function Terminal() {
}
function ConfigViewer() {
- const [activeTab, setActiveTab] = useState<'manifest' | 'lock'>('manifest')
-
const manifestCode = [
{ line: 1, text: '{' },
{
@@ -379,29 +377,16 @@ function ConfigViewer() {
{ line: 6, text: ' "skills": {' },
{
line: 7,
- text: ' "pr-creator": "https://github.com/rstackjs/agent-skills.git#89bd10a...&path:/skills/pr-creator",',
+ text: ' "pr-creator": "github:rstackjs/agent-skills#89bd10a&path:/skills/pr-creator",',
},
- { line: 8, text: ' "npm-skill": "npm:@scope/agent-logic@^1.2.0",' },
+ { line: 8, text: ' "npm-skill": "npm:@scope/agent-logic@1.2.0&path:skills/agent-logic",' },
{ line: 9, text: ' "custom-dev": "link:./packages/my-custom-skill",' },
- { line: 10, text: ' "local-dev": "local:./.agents/skills/local-dev",' },
+ { line: 10, text: ' "local-dev": "local:*",' },
{ line: 11, text: ' "legacy-v1": "file:./backups/old-agent.tgz"' },
{ line: 12, text: ' }' },
{ line: 13, text: '}' },
]
- const lockCode = [
- { line: 1, text: 'lockfileVersion: "0.1"' },
- { line: 2, text: 'installDir: .agents/skills' },
- { line: 3, text: 'linkTargets:' },
- { line: 4, text: ' - .claude/skills' },
- { line: 5, text: 'skills:' },
- { line: 6, text: ' pr-creator:' },
- { line: 7, text: ' specifier: https://github.com/rstackjs/agent-skills.git#89bd10a...' },
- { line: 8, text: ' resolution:' },
- { line: 9, text: ' type: git' },
- { line: 10, text: ' commit: 89bd10a842356073382b281509b4c8af7f9eb5a8' },
- ]
-
const highlightLine = (text: string) => {
const parts: ReactNode[] = []
const regex = /("[^"]+")(:?)|([{}[\],])|(\w+:)/g
@@ -426,14 +411,14 @@ function ConfigViewer() {
)
if (isKey) parts.push(':')
} else if (match[3]) {
- // JSON/YAML punctuation
+ // JSON punctuation
parts.push(
{match[3]}
,
)
} else if (match[4]) {
- // YAML key
+ // Object key
parts.push(
{match[4]}
@@ -451,8 +436,6 @@ function ConfigViewer() {
return parts
}
- const code = activeTab === 'manifest' ? manifestCode : lockCode
-
return (