Skip to content

RFC: Install skills from npm packages #2323

Open

Description

@antfu

Why

Many libraries are published as npm packages. A library author who writes a skill for their library has no good way to ship it with the package today. Users must find the skill in a Git repository and run skills add. When the library updates, the skill does not.

skills-npm solves this as a separate tool. It imports the internals of this CLI through a Git submodule. This RFC proposes to enhance skills experimental_sync by porting the features of skills-npm into it, in small steps that each ship on their own. When the port is complete, the command graduates to skills sync.

Examples

To demonstrate the idea better, there are a few example scenarios:

A library ships its skill with the package

agent-browser is an npm package and has a skill in the same repository. Today a user installs the package and the skill in two unrelated steps:

pnpm add agent-browser
npx skills add vercel-labs/agent-browser

The two can drift apart. The package updates with pnpm update, the skill does not.

With this RFC, the author has two options.

Option A: ship the skills/ folder inside the npm package. agent-browser already does this (skills is in its files list). The user adds one script:

{
  "private": true,
  "scripts": { 
    "prepare": "skills experimental_sync" // <---
  }
}

After pnpm install, .agents/skills/agent-browser is a symlink into node_modules/agent-browser/skills/agent-browser. The skill version always matches the installed package version. The link is committed, so every clone gets the same skill.

Option B: keep the skill in the Git repository and declare it in package.json of the npm package:

{
  "name": "agent-browser",
  "files": ["dist"],
  "version": "1.0.0",
  // ...
  "skills": ["vercel-labs/agent-browser"] // <--
}

skills experimental_sync sees that agent-browser is an installed dependency, reads its skills field, and runs the equivalent of npx skills add vercel-labs/agent-browser. The user does not need to know that the skill exists.

A skills pack

The skills field also makes a package that ships no code and only declares skills. This works like a shareable ESLint configuration preset:

{
  "name": "@antfu/skills",
  "dependencies" {
    "agent-browser": "^1.0.0"
  },
  "skills": [
    "npm:agent-browser",
    "vercel-labs/agent-skills@react-best-practices",
    { "source": "antfu/skills", "ref": "v2", "skills": ["vue", "vitest"] }
  ]
}

Teams can publish packages like @vercel/skills, indivisuals can publish packages like @antfu/skills. A project adds the pack as a dev dependency, and skills experimental_sync installs every skill it declares. The pack is versioned in the package manager lock file, so the whole team gets the same set of skills.

npm:<package> means the skills that <package> ships. Any other string is a source that skills add accepts, except a local path. The full grammar is the skills-npm SPEC.

Where a package keeps skills

my-lib/
  SKILL.md              # the whole package is one skill
  skills/<name>/SKILL.md
  dist/skills/<name>/SKILL.md

Features

Read direct dependencies

Sync reads dependencies and devDependencies from the project's package.json. It looks only at those packages. Transitive packages are reached through npm:<package> in a skills field.

Link, do not copy

The canonical directory .agents/skills/<name> becomes a relative symlink into node_modules:

.agents/skills/slidev -> ../../node_modules/@slidev/cli/skills/slidev

Agent directories such as .claude/skills/<name> link to the canonical directory, as they do for skills add. When the package updates, the skill updates with it. The links are committed to Git. Sync does not edit .gitignore.

--copy copies instead of linking. The Eve agent always copies, because it rewrites SKILL.md on install.

Before npm install runs, the links point at nothing. skills list does not show them until the packages are installed.

Name by the skill, not the package

The installed name is the sanitized name from the frontmatter, the same rule that skills add uses. There is no npm- prefix. A SKILL.md without name and description is reported and skipped.

Resolve conflicts safely

When two skills share a name, sync applies these rules in order:

  1. A skill that the user installed with skills add always wins. Sync never shadows it.
  2. Sync never replaces a directory or a link that it does not own. It warns and skips.
  3. A skill shipped in a package beats a skill declared as a remote source.
  4. A skill from a direct dependency beats a skill from a transitive dependency.
  5. If two skills still tie, sync installs neither and warns.

Sync owns a link when its target is under node_modules/. Sync owns a directory when skills-lock.json says its source is node_modules.

Remove what is gone

After each run, sync removes links whose target is under node_modules/ and that this run did not produce. It also removes remote skills that no skills field requests anymore. --no-cleanup turns this off.

Follow skills fields

Sync reads the skills field of the project and of every direct dependency. When an npm: entry points at a package, sync reads that package's skills field too. A visited set stops cycles.

If the project's own field names a package that is not installed, sync exits with an error. If a dependency's field does, sync warns and skips. A malformed entry in the project's field is an error. A malformed field in a dependency is ignored with a warning.

Remote entries install in the same process through the same code path as skills add. --no-remote skips them.

Extended lock file

Sync writes to skills-lock.json. An entry for a shipped skill:

"slidev": {
  "source": "@slidev/cli",
  "sourceType": "node_modules",
  "skillPath": "skills/slidev",
  "version": "52.0.0",
  "computedHash": "sha256:...",
  "via": "@my-org/skill-pack"
}

via names the package whose skills field requested the skill. For the project's own field it is .. Remote skills that a field requested also carry via. Sync uses it to know which entries it can remove.

Filters

--include <patterns...> and --exclude <patterns...> match a package name or a skill name. * matches inside one path segment, ** matches across segments, ? matches one character.

Scan cache

Sync caches the scan result in node_modules/.cache/skills/scan.json. The key is a hash of the package manager lock file plus the scan options. --force ignores the cache.

Workspaces

--recursive also reads each workspace package. Sync finds them from pnpm-workspace.yaml and from workspaces in package.json.

Other flags

--dry-run prints the plan and changes nothing.

Not ported

  • The skills-npm.config.ts configuration file. The skills field is the declarative surface.
  • Probing $PATH for agent binaries. The CLI already detects agents.
  • The setup command. The README documents the prepare pattern instead.

Steps

Each step is one PR and ships on its own. Steps 1 to 9 live under skills experimental_sync. Step 10 makes it skills sync.

  • Step 1: Scan. Read direct dependencies. Look in root SKILL.md, skills/, dist/skills/. Name by frontmatter. Add skillPath and version to the lock entry. (#2349)
  • Step 2: Link. Add a link install mode to installSkillForAgent. Apply conflict rules 1, 2, 4, and 5. Add --copy and --dry-run. (#2353)
  • Step 3: Cleanup. Remove stale links and lock entries. Add --no-cleanup. (#2398)
  • Step 4: --include and --exclude. (#2399)
  • Step 5: skills field with npm: entries. Parse per SPEC, resolve from the declaring package, follow chains, record via. (#2400)
  • Step 6: Extract the resolve-discover-install core of runAdd into a function that returns results and never exits. runAdd and experimental_install call it. No user-facing change. (#2401, #2402)
  • Step 7: Remote entries in the skills field. Use the function from step 6. Record via. Remove orphans. Add --no-remote. Apply conflict rule 3. (#2403)
  • Step 8: Scan cache.
  • Step 9: --recursive.
  • Step 10: Graduate to skills sync. Keep experimental_sync as an alias. Document the command, the skills field, and the prepare pattern in the README.

Activity

  1. antfubot commented on Oct 9, 2026

    @antfubot
    Contributor

    Status update on the steps:

    Step 8 (scan cache): dropped. Since step 1, sync reads only direct dependencies, so the scan is small. I measured experimental_sync --dry-run on a synthetic project with 500 direct dependencies, 10 of which ship skills. It takes about 370 to 470 ms, against a CLI startup of about 175 ms with no dependencies. The scan therefore costs about 200 to 300 ms in that extreme case, and typical projects with tens of direct dependencies spend tens of milliseconds. A cache keyed on the package manager lock file would also go stale when the project's own skills field or a linked workspace package changes, because neither changes the lock file.

    Filters (step 4): the syntax changed in #2399. --exclude foo excludes package foo, and --exclude foo#bar excludes only skill bar of package foo. A bare pattern matches package names only. --include uses the same syntax.

    Remaining: step 9 (--recursive) and step 10 (graduate to skills sync, docs).

  2. mkondratek commented on Oct 10, 2026

    @mkondratek

    Step 6 (#2401) extracted exactly what an embedding host needs: a resolve → discover → install function that returns results and never exits. I've opened #2455 asking to export it (and the related primitives) as a documented entry point, e.g. skills/api via package.json exports, with types and a progress/log hook in place of the spinner.

    Would step 10 be a good point to do that, since it already touches the public surface and the README? Cline Desktop would consume it directly instead of spawning npx skills, and it would remove the reason skills-npm imports the internals through a submodule. Happy to do the PR once you're past steps 9 and 10, so it doesn't collide with your work on add.ts / install.ts.

  3. gtbuchanan commented on Oct 30, 2026

    @gtbuchanan

    Where does agent detection (getDetectedAgents) fall on this list? One of the biggest ancillary wins of skills-npm was that it didn't dump a bunch of unnecessary agent directories into my worktree. I currently pass the result of getDetectedAgents to skills so I don't have to configure it explicitly in a config.

  4. added
    featNew feature or request
    source:npmInstall from local npm packages
    on Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featNew feature or requestsource:npmInstall from local npm packages

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions