| name | apply-npm-updates |
| description | Use when a single-project npm update command (`/experiments:npm-update-patch`, `/experiments:npm-update-minor`, `/experiments:npm-update-major`, and their deep variants) or the `commander-update-orchestrator` (once per project) needs to perform the mechanical apply of a fully-resolved update set โ generic `package.json` bumps via `npm-check-updates`, catalog source edits (`pnpm-workspace.yaml` for pnpm, root `package.json` for Bun), override commands, and one install. Level-agnostic (parameterized by `target`). Also documents the caller-invoked override-resolution procedure (registry load โ first-win glob match โ `{version}` resolution โ GENERIC/OVERRIDE_RUN/OVERRIDE_SKIP partition). Performs writes only; redirects ncu/install/override output to an on-disk log (digest to the conversation, bounded tail on failure only); returns a structured result fragment (with `logPath`) and NEVER prints a consumer summary or abort message. Never commits/pushes/opens PRs autonomously. |
apply-npm-updates
The single source of truth for the single-project npm apply mechanism. The caller resolves conflict policy, override decisions, and --filter membership; this skill performs the writes and returns a structured fragment. It is parameterized solely by target (= the update level) โ the same skill serves patch, minor, and major callers identically. (engines is NOT an apply target here โ the runtime/toolchain bump is applied by apply-engine-bumps, which performs no ncu.)
Two things live here:
- (a) A mechanical apply contract โ Steps A0โA5 below. Caller passes a fully-resolved per-project apply spec; the skill writes manifests, runs overrides, runs one install, and redirects
ncu/install/override stdout/stderr to an on-disk run log (one-line digests to the conversation; a bounded tail only on failure). Verbatim streaming into the conversation is repealed.
- (b) A reusable override-resolution procedure โ the "Override-resolution procedure" section below. Callers that opt into overrides invoke it to turn a candidate package set into matched entries + interpolated commands + a
GENERIC/OVERRIDE_RUN/OVERRIDE_SKIP partition. The interactive prompt and the resolution scope stay caller-owned.
When to use
/experiments:npm-update-patch / /experiments:npm-update-minor (shallow single-project) โ caller resolves overrides via procedure (b) + its own prompt, then invokes (a) once.
/experiments:npm-update-deep-patch / /experiments:npm-update-deep-minor (deep single-project) โ caller invokes (a) once with an empty overrideCommands set (the deep path consults NO override registry).
commander-update-orchestrator โ invokes (a) once per project with that project's resolved spec; resolves overrides cross-project via procedure (b) + its own cross-project prompt.
The skill is meant for command-layer / skill-layer composition. It performs writes only โ it does NOT scan, group, prompt for an apply path, or compose the user-facing summary.
This skill is implemented entirely with Claude Code built-in tools (Read, Bash, Edit, Write). It introduces no new runtime dependency, library, or sidecar package.
Mechanical apply contract (a)
Input spec
The caller passes a fully-resolved, single-project apply spec with exactly these fields:
| Field | Type | Required | Notes |
|---|
packageManager | "pnpm" | "npm" | "yarn" | "bun" | "deno" | yes | Selects the runner prefix and the install command. Rejected if unknown. |
cwd | string (absolute) | yes | Project root whose manifests are bumped. Every Bash call runs with this working directory (cd "<cwd>" && โฆ) or uses absolute --packageFile paths. No shell-state leak. |
target | "patch" | "minor" | "major" | yes | Mapped to an internal ncuTarget before reaching ncu --target (see Step A1). Rejected if unknown. |
cooldown | string | no | Release-age period for ncu --cooldown. Omitted for pnpm (ncu reads pnpm-workspace.yaml natively). |
manifestBumps | Array<{ sourceFile, names: string[], includeFilter: bool }> | no | One package.json manifest per element. One ncu call per element. |
catalogEdits | Array<{ name, targetVersion, catalogSource? }> | no | In-place catalog source edits. catalogSource = { sourceFile, manager: "pnpm" | "bun", field: { kind: "default" } | { kind: "named", name }, underWorkspaces? }. When omitted, defaults to the legacy pnpm target { sourceFile: "pnpm-workspace.yaml", manager: "pnpm", field: { kind: "default" } } โ byte-identical for existing pnpm callers. |
overrideCommands | Array<{ id, command }> | no | Already-interpolated override commands, in declaration order. |
skipInstall | boolean (default false) | no | When true, skip the final install (every accepted package was handled by an override that runs its own install). |
runDir | string (absolute) | no | Caller-provided run directory. When supplied, the run log is written under <runDir>/logs/; otherwise the skill uses a temporary path. The resolved log file is returned as logPath. |
The spec is consumed as-is: the skill performs no override matching, no conflict resolution, and no pick-subset parsing of its own. The caller already partitioned manifestBumps / catalogEdits / overrideCommands.
Step A0 โ Validate before any side effect
- If
packageManager is not one of pnpm / npm / yarn / bun / deno: abort with Error: invalid packageManager "<value>". Expected pnpm|npm|yarn|bun|deno. and perform NO ncu, catalog edit, override command, or install.
- If
target is not one of patch / minor / major: abort with Error: invalid target "<value>". Expected patch|minor|major. and perform NO side effect.
Resolve the runner prefix from packageManager:
packageManager | runner prefix | install command |
|---|
pnpm | pnpm dlx | pnpm install |
npm | npx -y | npm install |
yarn | yarn dlx | yarn install |
bun | bunx | bun install |
deno | deno run --allow-read --allow-net npm: | deno install |
Maintain an appliedSoFar: string[] buffer (manifest paths / catalog file already written, plus override ids executed) for failure reporting. Maintain the result accumulators appliedGeneric: [{ name, location }], appliedOverrides: [{ id, command, matchedNames }], installRan: boolean.
Resolve the run log (output handling โ replaces verbatim streaming). Resolve logPath once: <runDir>/logs/apply-<unix-ts>.log when runDir is supplied (create logs/ if needed), else a temporary path. Every ncu / override / install invocation below appends its full stdout+stderr to this log (>> "<logPath>" 2>&1). The conversation receives one-line digests only; on a failing step, surface a bounded tail of the log (at most ~40 lines). Verbatim streaming of ncu/override/install output into the conversation SHALL NOT occur.
Step A1 โ Generic package.json bumps (one ncu per manifestBumps element)
For each manifestBumps element (each a distinct package.json sourceFile), invoke npm-check-updates@21.0.2 exactly once:
<runner-prefix> npm-check-updates@21.0.2 \
-p <packageManager> \
--target <ncuTarget> \
--upgrade \
--removeRange \
--packageFile <sourceFile> \
[--cooldown <cooldown>]
[--filter "<names>"]
Resolve <ncuTarget> from target (same table scan-npm-updates uses)
The skill SHALL NOT pass target verbatim. Resolve <ncuTarget> first:
target (= level) | <ncuTarget> | extra flag |
|---|
patch | patch | โ |
minor | minor | โ |
major | latest | โ |
The mapping is an identity for patch/minor (their --target is unchanged). major resolves to --target latest. The target validation list (Step A0) is patch|minor|major. (engines is not an apply target โ the toolchain bump is applied by apply-engine-bumps, which runs no ncu.)
Rules:
-p <packageManager> is always passed โ mirror scan semantics and prevent ncu auto-detect drift (e.g. ncu otherwise auto-detects deno when a sibling deno.json exists, collapsing --dep to ['imports'] and dropping dependencies/devDependencies updates).
--removeRange is always passed, at every level and every bump type โ see "Exact version pinning" below.
--cooldown <cooldown> is included when cooldown is set and packageManager !== "pnpm"; omitted otherwise.
--filter "<names>" membership: <names> = the element's names, joined by single spaces, double-quoted. It is a literal list โ ncu treats it as exact names (see scan-npm-updates/research/ncu-filter-spike.md).
- When
<ncuTarget> === latest (i.e. target is major), --filter "<names>" is ALWAYS included regardless of the element's includeFilter value โ the caller's names list is authoritative. Required because scan-npm-updates builds the latest-level candidate set by running ncu --target latest and then post-filtering (e.g. major-only); re-running ncu --target latest without --filter would bump every dependency with any newer version, exceeding the accepted set.
- Otherwise (
patch/minor), --filter is included only when the element's includeFilter === true; when false it is omitted (ncu's own detected set equals the target set for this file).
- Catalog-reference guard (package-manager-agnostic, defense-in-depth). Never add to
--filter any package whose declared value in <sourceFile> matches /^catalog:/, and never write a pinned version over any consumer value matching /^catalog:/. A catalog:* specifier is a reference, not a version โ its source is bumped via catalogEdits (Step A2), never here. At the pinned ncu@21.0.2 this is a no-op (ncu already skips catalog:* for both pnpm and bun โ see scan-npm-updates/the issue spike); the guard prevents a silent regression should a future ncu stop skipping them.
- Redirect
ncu stdout/stderr to the run log (logPath) and surface a one-line digest per manifest (e.g. ncu <sourceFile>: <N> package(s) bumped โ log: <logPath>). Do NOT stream the output verbatim into the conversation; diffs remain observable in the log and via git diff.
Exact version pinning (--removeRange, family-wide)
--removeRange is passed on every ncu bump, at all levels (patch/minor/major) and both shallow/deep. Each bumped dependency is therefore written as an exact version โ "react": "19.0.2", never "^19.0.2"/"~19.0.2". This is a deliberate, family-wide behavior change: the whole update cascade pins exact, so it is NOT byte-equivalent to the pre-change patch/minor output (which preserved the existing range operator). Override-managed families (run via overrideCommands) pin according to their own upgrade tool and are out of scope of this rule.
On success, append each bumped package to appliedGeneric (with its location from the caller's spec context) and push <sourceFile> to appliedSoFar.
If ncu exits non-zero on a manifest, stop immediately, surface a bounded tail of the run log (at most ~40 lines), and return the structured failure:
{ step: "ncu", sourceFile: "<sourceFile>", exitCode: <code>, appliedSoFar: [...] }
Do NOT run any catalog edit, override command, or install after this point. Do NOT print a consumer-specific abort message (Re-run โฆ / Stopping the run โฆ) โ the caller owns that copy.
Step A2 โ Catalog source edits
For each catalogEdits element (name, targetVersion, optional catalogSource), bump the entry in its catalog source, resolving the source from catalogSource. When catalogSource is omitted, default to the legacy pnpm target { sourceFile: "pnpm-workspace.yaml", manager: "pnpm", field: { kind: "default" } } (byte-identical for existing pnpm callers). In every case:
- Replace the value with the exact version โ
targetVersion with any leading range operator (^/~/=) stripped (e.g. ^3.24.1 โ 3.24.1) โ keeping catalog entries consistent with the family-wide exact-pin rule applied to package.json bumps via --removeRange. Preserve surrounding whitespace, comments, and the order of other keys.
- This is always an in-place
Edit, never an ncu invocation (ncu 21.0.2 does not rewrite catalog sources for pnpm or bun โ see scan-npm-updates/research/ncu-catalog-spike.md and research/ncu-bun-catalog-spike.md).
- Do NOT touch any consumer
package.json entry that references catalog: โ only the catalog source file is edited.
Route by catalogSource.manager:
- pnpm (
manager === "pnpm", or catalogSource omitted): in pnpm-workspace.yaml at <cwd>/<catalogSource.sourceFile>, locate the catalog block from catalogSource.field โ { kind: "default" } (or omitted catalogSource) โ the top-level catalog: block; { kind: "named", name } โ the catalogs.<name> block under the catalogs: map โ then locate the key matching name within that block and replace its value. This is a targeted in-place Edit, never a YAML round-trip (parseโdump), so formatting, comments, and key order are preserved.
- Scope the match (non-unique token). If the
name: <version> token is not unique within the file (the same dep appears in more than one catalog block, e.g. catalog.react and catalogs.react17.react, or in both catalog and catalogs.default), include enough surrounding context in the Edit old_string (neighboring keys or the enclosing block's opening line) to scope the replacement to the block resolved from catalogSource.field.
- bun (
manager === "bun"): in the root package.json at <cwd>/<catalogSource.sourceFile>, locate the catalog block from catalogSource.field โ { kind: "default" } โ the catalog map; { kind: "named", name } โ the catalogs.<name> map โ nested under workspaces when underWorkspaces is true. Replace the matched "name": "<version>" token via a targeted Edit. Do NOT round-trip the file through JSON.parseโJSON.stringify (it would reformat indentation / key order / trailing newline, breaking the minimal-diff contract the pnpm YAML path honors) and do NOT use bun pm pkg set (its dot-delimited key path silently mangles a package name containing a dot โ socket.io โ "socket": { "io": โฆ } โ with no documented escape; see research/bun-cli-spike.md).
- Scope the match (non-unique token). If the
"name": "<version>" token is not unique within the file (the same dep appears in two catalog blocks, e.g. catalog.react and catalogs.testing.react, or in both catalog and catalogs.default), include enough surrounding context in the Edit old_string (neighboring keys or the enclosing block's opening line) to scope the replacement to the block resolved from catalogSource.field + underWorkspaces.
On success, append the package to appliedGeneric (location catalog:<key> / the caller-supplied location) and push the resolved catalogSource.sourceFile to appliedSoFar.
If a catalog key (or its resolved block) is unexpectedly missing, stop immediately and return:
{ step: "catalog", name: "<name>", exitCode: null, appliedSoFar: [...] }
Do NOT run any override command or install. Do NOT print a consumer abort message.
Step A3 โ Override commands (declaration order)
After every generic manifest write (A1) and catalog edit (A2) for the project has succeeded, execute each overrideCommands element's command exactly once, in declaration order, redirecting stdout/stderr to the run log and surfacing a one-line digest per command (no verbatim streaming).
On success, append { id, command, matchedNames } to appliedOverrides (the caller supplies matchedNames context, or the skill records the command's id) and push the entry id to appliedSoFar.
If any override exits non-zero, stop immediately, surface a bounded tail of the run log (at most ~40 lines), and return:
{ step: "override", entryId: "<id>", exitCode: <code>, appliedSoFar: [...] }
Do NOT run ncu --upgrade as a fallback for the matched packages (leaves the tree consistent and reviewable). Do NOT run the final install on this path. Do NOT print a consumer abort message.
Step A4 โ Single install with skip rule
After all generic bumps, catalog edits, and override commands land successfully:
- If
skipInstall === true, run no install command; set installRan = false; record that the install was delegated to the override command(s). (This is set by the caller when every accepted package was handled by an override that runs its own install and nothing was written outside the override.)
- Otherwise run exactly one install command for
packageManager (per the Step A0 table), redirecting its stdout/stderr to the run log and surfacing a one-line digest (e.g. <pm> install: ok โ log: <logPath>). On success set installRan = true.
If the install exits non-zero, surface a bounded tail of the run log (at most ~40 lines) and return:
{ step: "install", exitCode: <code>, appliedSoFar: [...] }
Do NOT print a consumer abort message.
Step A5 โ Return the structured result
On success return:
{
appliedGeneric: [{ name, location }, ...],
appliedOverrides: [{ id, command, matchedNames }, ...],
installRan: boolean,
logPath: "<string>",
failure: null
}
On failure return the same shape with failure populated per the failing step (A1/A2/A3/A4) and the partial accumulators reflecting what landed before the failure.
The skill writes ncu / install / override stdout/stderr to the on-disk log referenced by logPath (observability moves to disk; verbatim streaming into the conversation SHALL NOT occur) and prints NO consumer-facing summary block (## โฆ-<level> summary) and NO consumer-specific abort copy. The caller composes those so single-project and cross-project consumers each preserve their own wording and exit semantics.
Override-resolution procedure (b) โ caller-invoked
Callers that opt into overrides invoke this procedure to turn a candidate package set into the inputs for the apply contract. The procedure is the matching / version-resolution / partition algorithm only โ the interactive run-override / skip-matched / force-generic prompt and the scope of resolution (which packages, single-project vs cross-project) remain caller-owned.
R1 โ Load the registry
Read the override registry from the caller-supplied path (default claude-plugins/experiments/skills/scan-npm-updates/data/pkg-upgrade-overrides.yaml). Parse it as YAML into a list of entries under the top-level overrides: key. Required fields per entry: id, matches, command, versionSource. Optional: fallbackVersionSource, reference, notes.
If the file is missing, unreadable, fails to parse, or lacks overrides: emit a single-line warning Override registry unavailable: <reason>. Proceeding without overrides., treat the registry as empty, and do NOT abort. (Graceful degradation โ legacy generic-only behavior.)
R2 โ First-win glob match
For each candidate package, find the first entry (in declaration order) whose matches list includes a pattern matching the package name. Glob semantics:
* matches any run of characters within a package name (so @storybook/* matches @storybook/react and @storybook/addon-essentials; storybook-addon-* matches storybook-addon-themes).
- No other glob metacharacters. Exact strings (
storybook) match only that literal name.
Matching is first-win โ a package binds to at most one entry. Candidates binding to no entry remain GENERIC. Build MATCHED_BY_ENTRY = { entry.id โ [candidates bound to this entry] }.
R3 โ Resolve {version} and interpolate
For each matched entry resolve versionSource against the caller's candidate set (the caller passes the resolution source โ the single-project accepted set, or the cross-project proposedTarget set):
target-of:<name> โ the target version of the candidate whose name equals <name>, prefix-stripped (^/~/=). Unresolved if that candidate is absent.
max-target-of:<glob> โ the max semver across target versions (prefix-stripped) of candidates whose names match <glob>. Unresolved if no candidate matches.
latest โ the literal string latest.
If versionSource is unresolved and fallbackVersionSource is defined, try it. If both fail, emit the canonical warning Cannot resolve {version} for override {id}: neither {versionSource} nor {fallbackVersionSource} produced a value. Falling back to generic ncu --upgrade for matched packages. (substitute the entry's fields), drop the entry from MATCHED_BY_ENTRY, and let its matched candidates rejoin GENERIC. Otherwise interpolate the resolved version into command by replacing the literal token {version}.
R4 โ Partition (after the caller obtains a per-entry action)
The caller raises its own AskUserQuestion per matched entry and records an action โ { run-override, skip-matched, force-generic }. Given those actions, partition the candidate set into three disjoint subsets:
OVERRIDE_RUN = candidates bound to a run-override entry โ handled by the interpolated override command; excluded from generic ncu.
OVERRIDE_SKIP = candidates bound to a skip-matched entry โ excluded from everything.
GENERIC = candidates bound to no entry, PLUS candidates bound to a force-generic entry, PLUS candidates whose entry was dropped in R3.
The caller then builds the apply spec: GENERIC package.json candidates โ manifestBumps (set includeFilter when the GENERIC set for a file is a strict subset of ncu's detectable candidates โ i.e. pick-subset partial inclusion OR any OVERRIDE_RUN/OVERRIDE_SKIP touching the file); GENERIC catalog candidates (location catalog:default / catalog:<name>) โ catalogEdits, each carrying the scan record's catalogSource so the writer targets the exact source (pnpm โ pnpm-workspace.yaml; bun โ the root package.json block); run-override interpolated commands โ overrideCommands; skipInstall when every accepted package was handled by run-override and nothing is written outside the override commands.
Procedure scenarios
- First-win glob:
@storybook/react with a first matching storybook entry (patterns include @storybook/*) binds to storybook and to no later entry.
- Version fallback: entry
versionSource: target-of:storybook, fallbackVersionSource: max-target-of:@storybook/*; no storybook candidate present but @storybook/react resolves 8.1.2 โ interpolate 8.1.2.
- Missing registry: file absent โ procedure returns empty matches, emits the
Override registry unavailable: โฆ warning, does NOT abort.
- Prompt/scope not included: the procedure returns matches / interpolated commands / partitions only; it does NOT raise the override
AskUserQuestion itself.
Level-agnostic operation
The skill contains no level-specific branching logic. Behavior is parameterized solely by target, which is mapped to an ncuTarget (patchโpatch, minorโminor, majorโlatest) threaded through every ncu --target call. --removeRange is applied uniformly at all levels. The validation list for target is patch|minor|major. A target: "minor" invocation differs from target: "patch" only in the mapped --target value; a target: "major" invocation differs from minor only in the mapped target (latest) and the forced --filter โ nothing else changes. (engines is out of scope โ apply-engine-bumps handles the toolchain bump with no ncu.)
Hard rules
- SHALL NOT create commits, push, or open pull requests autonomously; the skill stops for human-in-the-loop review before any such outward/VCS action (opt-in isolation branch/worktree creation via
update-isolation is permitted).
- SHALL NOT mutate any consumer
package.json entry that is a catalog: reference โ only the catalog source file (pnpm-workspace.yaml for pnpm, the root package.json catalog/catalogs.<name> map for Bun).
- SHALL NOT run
ncu --upgrade as a fallback after an override command fails.
- SHALL NOT read or write the override registry data file except via the read-only resolution procedure (R1).
- SHALL NOT print a consumer-facing summary heading or a consumer-specific abort message โ those are caller-owned.
- SHALL NOT stream
ncu/override/install output verbatim into the conversation โ output goes to the on-disk run log (logPath); the conversation gets one-line digests and, on failure only, a bounded tail (โค ~40 lines).
See also
/experiments:npm-update-patch, /experiments:npm-update-minor โ shallow single-project consumers (procedure (b) + contract (a)).
/experiments:npm-update-deep-patch, /experiments:npm-update-deep-minor โ deep single-project consumers (contract (a), empty overrides).
commander-update-orchestrator โ cross-project consumer (procedure (b) cross-project + contract (a) once per project).
scan-npm-updates/data/pkg-upgrade-overrides.yaml โ the single, level-independent override registry consumed via procedure (b).