Skip to content

Commit f9be1d8

Browse files
authored
feat: add local skill protocol (#32)
## Summary - Add `local:` skill specifiers for existing user-owned skill directories. - Install `local:` skills in place without copying, replacing, patching, or pruning their source directories. - Link agent targets directly to the local source directory and maintain `.gitignore` unignore rules for repo-local skills. - Update tests, docs, and the bundled `skills-package-manager-cli` guidance. ## Related Links web-infra-dev/rsbuild#7565 <!-- Provide links to related issues, discussions, or design notes --> ## Checklist <!-- Check and mark with an "x" --> - [x] Tests updated (or not required). - [x] Documentation updated (or not required). Testing: - `pnpm build` - `pnpm test` - `pnpm build:website` - `pnpm check` (passes with existing warnings)
1 parent de9b79a commit f9be1d8

34 files changed

Lines changed: 474 additions & 57 deletions

packages/skills-package-manager/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ npx skills-package-manager add https://github.com/owner/repo/tree/main/skills/my
5050
# Direct specifier — skip discovery
5151
npx skills-package-manager add https://github.com/owner/repo.git#path:/skills/my-skill
5252
npx skills-package-manager add link:./local-source/skills/my-skill
53+
npx skills-package-manager add local:./.agents/skills/my-skill
5354
npx skills-package-manager add ./local-source
5455
npx skills-package-manager add file:./skills-package.tgz#path:/skills/my-skill
5556
npx skills-package-manager add npm:@scope/skills-package#path:/skills/my-skill
5657
```
5758

58-
After `npx skills-package-manager add`, the newly added skills are resolved, materialized into `installDir`, and linked to each configured `linkTarget` immediately.
59+
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.
5960

6061
#### How it works
6162

@@ -104,9 +105,9 @@ Install all skills declared in `skills.json`:
104105
npx skills-package-manager install
105106
```
106107

107-
This resolves each skill from its specifier, materializes it into `installDir` (default `.agents/skills/`), and creates symlinks for each `linkTarget`.
108+
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`.
108109
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`.
109-
If `patchedSkills` contains an entry for a skill, the corresponding patch file is applied after the skill is materialized.
110+
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.
110111

111112
### `npx skills-package-manager patch`
112113

@@ -154,7 +155,7 @@ Behavior:
154155

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

@@ -183,11 +184,12 @@ const skills = await listRepoSkills('vercel-labs', 'skills')
183184
```text
184185
git/file/npm: <source>#[ref&]path:<skill-path>
185186
link: link:<path-to-skill-dir>
187+
local: local:<path-to-existing-skill-dir>
186188
```
187189

188190
| Part | Description | Example |
189191
|------|-------------|---------|
190-
| `source` | Git URL, direct `link:` skill path, `file:` tarball, or `npm:` package name | `https://github.com/o/r.git`, `link:./local/skills/my-skill`, `file:./skills.tgz`, `npm:@scope/pkg` |
192+
| `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` |
191193
| `ref` | Optional git ref | `main`, `v1.0.0`, `HEAD`, `6cb0992`, `6cb0992a176f2ca142e19f64dca8ac12025b035e` |
192194
| `path` | Path to skill directory within source | `/skills/my-skill` |
193195

@@ -196,7 +198,8 @@ link: link:<path-to-skill-dir>
196198
### Resolution Types
197199

198200
- **`git`** — Clones the repo, resolves commit hash, copies skill files
199-
- **`link`** — Reads from a local directory and copies the selected skill
201+
- **`link`** — Symlinks a local skill directory into `installDir`
202+
- **`local`** — Uses an existing user-owned skill directory in place
200203
- **`file`** — Extracts a local `tgz` package and copies the selected skill
201204
- **`npm`** — Resolves a package from the configured npm registry, locks the tarball URL/version/integrity, and installs from the downloaded tarball
202205

packages/skills-package-manager/skills/skills-package-manager-cli/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
4040

4141
4. `npx skills-package-manager update [skill...]`
4242
- Refreshes resolvable entries in `skills-lock.yaml`.
43-
- Skips `link:` skills, including the bundled `skills-package-manager-cli` self skill.
43+
- Skips local `link:` and `local:` skills, including the bundled `skills-package-manager-cli` self skill.
4444

4545
## How To Triage User Questions
4646

@@ -59,6 +59,7 @@ Use this skill for repositories that already use `skills-package-manager`, or wh
5959
## Specifier Reminders
6060

6161
- `link:./path/to/skill-dir` points to a local skill directory.
62+
- `local:./path/to/existing-skill-dir` keeps an existing user-owned skill directory in place.
6263
- `file:./pkg.tgz#path:/skills/name` points to a packaged tarball plus skill path.
6364
- `npm:@scope/pkg#path:/skills/name` resolves a package from the configured registry.
6465
- GitHub shorthand or Git URLs resolve remote repositories and may need `--skill` when multiple skills are available.

packages/skills-package-manager/src/commands/add.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function buildLinkSpecifier(sourceRoot: string, skillPath: string): string {
6666
function isDirectSkillSpecifier(specifier: string): boolean {
6767
return (
6868
specifier.startsWith('link:') ||
69+
specifier.startsWith('local:') ||
6970
specifier.startsWith('file:') ||
7071
specifier.startsWith('npm:') ||
7172
specifier.includes('#path:') ||

packages/skills-package-manager/src/commands/install.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ export async function installCommand(options: InstallCommandOptions) {
8383
} else {
8484
// Normal mode: check install-dir lock copy for fast-path skip.
8585
// Only skip when there are no file: skills, because tarball contents
86-
// may change without the lockfile being modified. link: skills use
87-
// symlinks so they always reflect the current source.
86+
// may change without the lockfile being modified. link: and local:
87+
// skills always reflect the current source.
8888
const hasLocalSource = Object.values(ctx.manifest.skills).some((s) => s.startsWith('file:'))
8989
const installDirLock = await readInstallDirLock(options.cwd, installDir)
9090
if (

packages/skills-package-manager/src/commands/update.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export async function updateCommand(options: UpdateCommandOptions): Promise<Upda
7070
result.skipped.push({ name: skillName, reason: 'link-specifier' })
7171
continue
7272
}
73+
if (normalized.type === 'local') {
74+
result.skipped.push({ name: skillName, reason: 'local-specifier' })
75+
continue
76+
}
7377

7478
const { entry } = await resolveLockEntry(options.cwd, specifier)
7579
const nextEntry = await attachManifestPatchToEntry(

packages/skills-package-manager/src/config/compareSkillsLock.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'node:path'
22
import type { ManifestStat } from '../pipeline/types'
3-
import { normalizeLinkSource } from '../specifiers/normalizeLinkSource'
3+
import { normalizeLinkSource, normalizeLocalSource } from '../specifiers/normalizeLinkSource'
44
import { parseSpecifier } from '../specifiers/parseSpecifier'
55
import { sha256File } from '../utils/hash'
66
import { toPortableRelativePath } from '../utils/path'
@@ -15,10 +15,15 @@ interface ParsedSpecifier {
1515
function parseForComparison(specifier: string): ParsedSpecifier {
1616
const parsed = parseSpecifier(specifier)
1717
const isLink = parsed.sourcePart.startsWith('link:')
18+
const isLocal = parsed.sourcePart.startsWith('local:')
1819
return {
19-
sourcePart: isLink ? normalizeLinkSource(parsed.sourcePart) : parsed.sourcePart,
20-
ref: isLink ? null : parsed.ref,
21-
path: isLink ? '/' : parsed.path || '/',
20+
sourcePart: isLink
21+
? normalizeLinkSource(parsed.sourcePart)
22+
: isLocal
23+
? normalizeLocalSource(parsed.sourcePart)
24+
: parsed.sourcePart,
25+
ref: isLink || isLocal ? null : parsed.ref,
26+
path: isLink || isLocal ? '/' : parsed.path || '/',
2227
}
2328
}
2429

packages/skills-package-manager/src/config/syncSkillsLock.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export async function attachManifestPatchToEntry(
4848
return entry
4949
}
5050

51+
if (entry.resolution.type === 'local') {
52+
throw new Error(`local: skill ${skillName} cannot be patched because its source is user-owned`)
53+
}
54+
5155
const absolutePatchPath = path.resolve(cwd, patchPath)
5256
return {
5357
...entry,

packages/skills-package-manager/src/config/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type NormalizedSkillsManifest = {
2525
}
2626

2727
export type NormalizedSpecifier = {
28-
type: 'git' | 'link' | 'file' | 'npm'
28+
type: 'git' | 'link' | 'local' | 'file' | 'npm'
2929
source: string
3030
ref: string | null
3131
path: string
@@ -37,6 +37,7 @@ export type SkillsLockEntry = {
3737
specifier: string
3838
resolution:
3939
| { type: 'link'; path: string }
40+
| { type: 'local'; path: string }
4041
| { type: 'file'; tarball: string; path: string }
4142
| { type: 'git'; url: string; commit: string; path: string }
4243
| {
@@ -111,7 +112,7 @@ export type UpdateCommandResult = {
111112
status: 'updated' | 'skipped' | 'failed'
112113
updated: string[]
113114
unchanged: string[]
114-
skipped: Array<{ name: string; reason: 'link-specifier' }>
115+
skipped: Array<{ name: string; reason: 'link-specifier' | 'local-specifier' }>
115116
failed: Array<{ name: string; reason: string }>
116117
}
117118

packages/skills-package-manager/src/errors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export function formatErrorForDisplay(error: unknown): string {
117117
output += `\n - owner/repo (GitHub shorthand)`
118118
output += `\n - https://github.com/owner/repo.git`
119119
output += `\n - link:./path/to/skill-dir`
120+
output += `\n - local:./path/to/existing-skill-dir`
120121
output += `\n - file:./path/to/skill-package.tgz#path:/skills/my-skill`
121122
output += `\n - npm:@scope/skill-package#path:/skills/my-skill`
122123
}

packages/skills-package-manager/src/fetchers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { CacheManager } from '../pipeline/types'
33
import { fetchFileSkill } from './file'
44
import { fetchGitSkill } from './git'
55
import { fetchLinkSkill } from './link'
6+
import { fetchLocalSkill } from './local'
67
import { fetchNpmSkill } from './npm'
78

89
export async function fetchSkill(
@@ -15,6 +16,8 @@ export async function fetchSkill(
1516
switch (entry.resolution.type) {
1617
case 'link':
1718
return { installPath: await fetchLinkSkill(rootDir, skillName, entry, installDir) }
19+
case 'local':
20+
return { installPath: await fetchLocalSkill(rootDir, entry) }
1821
case 'file':
1922
return { installPath: await fetchFileSkill(rootDir, skillName, entry, installDir) }
2023
case 'git':

0 commit comments

Comments
 (0)