Runs an async lockfile-frozen install (npm ci / pnpm install --frozen-lockfile / yarn install --frozen-lockfile, picked by whichever lockfile is present) when…

/plugin install npm-automations@kwitsch-plugins
Centralizes npm-lifecycle automation hooks, run in-process by Claude Code (no process starts per tool call; only the install itself runs as a background process). Every hook below is on by default and independently toggleable via its own plugin setting (only the literal value false disables it).
Both hooks pick the package manager from whichever lockfile is present (pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm — pnpm/yarn take priority if more than one lockfile exists) and look for that manager's binary at ~/.local/bin in addition to the inherited PATH, covering standalone/corepack installs that land there.
EnterWorktree creates or switches into a worktree whose project has a lockfile, runs that manager's lockfile-frozen install there in the background (npm ci / `pnpm install--frozen-lockfile / yarn install --frozen-lockfile; never blocks the agent). Toggle: npm_ci_on_worktree. Silent on success; a real install failure or a missing binary on PATH` surfaces as context on the next turn.
Write/Edit to any package.json whose dependencies/devDependencies/ optionalDependencies actually changed, runs an install scoped to only the changed specs in the background (npm install/pnpm add/yarn add, or npm by default when no lockfile exists yet) — a version-only (or other non-dependency) change triggers no call at all. Toggle: npm_install_on_package_change. Silent on success; a failure or a missing binary surfaces as context on the next turn.Installs into the same directory within a session run one after another, so two installs never race there.
hooks/register.ts 88 lines1import type { EngineInterface, Register } from "claude-code";
2import {
3 DEFAULT_MANAGER,
4 DELIVERY_FRAME,
5 INSTALL_TIMEOUT_MS,
6 detectPackageManager,
7 dirname,
8 installArgsFor,
9 isEnabled,
10 isPackageJson,
11 outcomeMessage,
12 pathWithLocalBin,
13 planInstall,
14} from "./policy.mjs";
15
16type Install = { hook: string; cwd: string; bin: string; args: string[]; command: string };
17
18// One install chain per directory: installs into one node_modules run one after another,
19// never racing on it or on the lockfile. Module memory, so it spans this session only.
20const chains = new Map<string, Promise<void>>();
21
22// The directory's entry names, or none when it cannot be listed.
23async function entryNames($: EngineInterface, dir: string): Promise<string[]> {
24 return (await $.fs.list(dir).catch(() => [])).map((entry) => entry.name);
25}
26
27// Runs one install to its end and leaves a failure line for the model's next request.
28async function runInstall($: EngineInterface, job: Install): Promise<void> {
29 const path = pathWithLocalBin(await $.env.get("PATH"), await $.env.get("HOME"));
30 const outcome = await $.process
31 .run([job.bin, ...job.args], { cwd: job.cwd, timeoutMs: INSTALL_TIMEOUT_MS, ...(path ? { env: { PATH: path } } : {}) })
32 .catch((err: unknown) => ({ error: String(err) }));
33 const text = outcomeMessage(job.hook, job.bin, job.command, job.cwd, outcome);
34 if (text !== null) await $.session.append({ message: { type: "user", content: [{ type: "text", text: DELIVERY_FRAME + text }] } });
35}
36
37// Fire and forget: queued behind the directory's running install; the hook never waits.
38function enqueue($: EngineInterface, job: Install): void {
39 const run = (chains.get(job.cwd) ?? Promise.resolve()).then(() => runInstall($, job)).catch(() => {});
40 chains.set(job.cwd, run);
41 void run.then(() => {
42 if (chains.get(job.cwd) === run) chains.delete(job.cwd);
43 });
44}
45
46// A Write or Edit just landed on filePath: install what its package.json change asks for.
47async function onPackageWrite($: EngineInterface, tool: string, input: Record<string, unknown>, filePath: string): Promise<void> {
48 if (!isPackageJson(filePath)) return;
49 const plan = planInstall(tool, input, await $.fs.read(filePath));
50 if (plan === null) return;
51 const cwd = dirname(filePath);
52 const manager = detectPackageManager(await entryNames($, cwd)) ?? DEFAULT_MANAGER;
53 const args = installArgsFor(manager.name, plan.specs);
54 enqueue($, { hook: "npm-install-on-package-change", cwd, bin: manager.name, args, command: `${manager.name} ${args[0]}` });
55}
56
57export const register: Register = (on, options) => {
58 if (isEnabled(options.npm_ci_on_worktree)) {
59 on("tool.call", { tool: "EnterWorktree" }, async ($, e, next) => {
60 const r = await next(e);
61 if (r.deny !== undefined || r.isError) return r;
62 try {
63 const cwd = r.result.worktreePath;
64 const manager = detectPackageManager(await entryNames($, cwd));
65 if (manager) {
66 enqueue($, { hook: "npm-ci-on-worktree", cwd, bin: manager.name, args: manager.ciArgs, command: `${manager.name} ${manager.ciArgs.join(" ")}` });
67 }
68 } catch {
69 // fail open: the tool's result goes back unchanged
70 }
71 return r;
72 });
73 }
74
75 if (isEnabled(options.npm_install_on_package_change)) {
76 on("tool.call", { tool: "Write" }, async ($, e, next) => {
77 const r = await next(e);
78 if (r.deny === undefined && !r.isError) await onPackageWrite($, e.tool, e, e.file_path).catch(() => {});
79 return r;
80 });
81 on("tool.call", { tool: "Edit" }, async ($, e, next) => {
82 const r = await next(e);
83 if (r.deny === undefined && !r.isError) await onPackageWrite($, e.tool, e, e.file_path).catch(() => {});
84 return r;
85 });
86 }
87};
88hooks/policy.mjs 166 lines1// hooks/policy.mjs -- npm-automations: the pure decision logic behind hooks/register.ts.
2// Zero-dep and side-effect free: no `node:` import and no reference to the mods `$`
3// (the engine rejects both in a file the hooks module imports). Every file system,
4// environment and process access happens in register.ts and is handed in here as data.
5
6// Heads a delivered outcome line: a user-role meta row carries no hook framing, and without it the model reads the
7// line as something the user pasted (seen live).
8export const DELIVERY_FRAME = "[npm-automations plugin: automated background install result, not a user message]\n";
9
10export const INSTALL_TIMEOUT_MS = 300000; // the old async command hooks' own `timeout: 300`
11const MAX_CONTEXT_CHARS = 4000; // same cap as universal-lint's lint-file.mjs
12const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
13
14/** @typedef {{ name: string, lockfile: string, ciArgs: string[] }} PackageManager */
15
16// Lockfile presence selects the package manager, checked in this order so a project
17// mid-migration (several lockfiles briefly coexisting) prefers the newer lockfile over
18// npm's. `ciArgs` is each manager's lockfile-frozen equivalent of `npm ci`: pnpm's
19// `--frozen-lockfile` and yarn classic's `--frozen-lockfile` (yarn berry only
20// deprecated it in favor of `--immutable`, it did not remove it).
21/** @type {readonly PackageManager[]} */
22export const PACKAGE_MANAGERS = [
23 { name: "pnpm", lockfile: "pnpm-lock.yaml", ciArgs: ["install", "--frozen-lockfile"] },
24 { name: "yarn", lockfile: "yarn.lock", ciArgs: ["install", "--frozen-lockfile"] },
25 { name: "npm", lockfile: "package-lock.json", ciArgs: ["ci"] },
26];
27// The install hook's manager when no lockfile exists yet (first-ever install).
28export const DEFAULT_MANAGER = PACKAGE_MANAGERS[2];
29
30/** @param {Iterable<string>} names the directory's entry names @returns {PackageManager | null} */
31export function detectPackageManager(names) {
32 const present = new Set(names);
33 return PACKAGE_MANAGERS.find((pm) => present.has(pm.lockfile)) ?? null;
34}
35
36// Standalone/corepack pnpm and yarn installs land in ~/.local/bin, which the session's
37// PATH may lack. APPENDED, never prepended: the inherited PATH wins, so a stale
38// ~/.local/bin binary can never shadow a canonical one.
39// shortcut: POSIX separators only (":" and "/"); a native-Windows host needs ";" and "\\".
40/** @param {string | undefined} current the session's PATH @param {string | undefined} home @returns {string} "" when both are unset */
41export function pathWithLocalBin(current, home) {
42 return [current, home ? `${home}/.local/bin` : ""].filter(Boolean).join(":");
43}
44
45// Fail-open: only a literal `false` (boolean or string) disables -- a deliberate
46// exception to plugin-userconfig.md's state-creating-toggle default; see hooks/CLAUDE.md.
47/** @param {unknown} value @returns {boolean} */
48export function isEnabled(value) {
49 return value !== false && value !== "false";
50}
51
52/** @param {string} text @returns {string} */
53export function truncate(text) {
54 const t = text.trim();
55 return t.length > MAX_CONTEXT_CHARS ? `${t.slice(0, MAX_CONTEXT_CHARS)}\n... (truncated)` : t;
56}
57
58/** @param {string} filePath @returns {boolean} a package.json outside node_modules */
59export function isPackageJson(filePath) {
60 return /(^|[\\/])package\.json$/.test(filePath) && !/[\\/]node_modules[\\/]/.test(filePath);
61}
62
63/** @param {string} filePath @returns {string} */
64export function dirname(filePath) {
65 const cut = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
66 return cut < 0 ? "." : cut === 0 ? filePath[0] : filePath.slice(0, cut);
67}
68
69// pnpm/yarn use `add <spec>...` to install/update specific specs (both verified to
70// update an existing entry in place, same as npm) and bare `install` to reconcile from
71// the current package.json, same as npm's bare `install`.
72/** @param {string} managerName @param {string[] | null} specs @returns {string[]} */
73export function installArgsFor(managerName, specs) {
74 if (specs === null) return ["install"];
75 return managerName === "npm" ? ["install", ...specs] : ["add", ...specs];
76}
77
78/** @param {string} haystack @param {string} needle @returns {number} */
79export function countOccurrences(haystack, needle) {
80 if (!needle) return 0;
81 let count = 0;
82 let idx = 0;
83 while ((idx = haystack.indexOf(needle, idx)) !== -1) {
84 count++;
85 idx += needle.length;
86 }
87 return count;
88}
89
90// Rebuilds the pre-edit content from the Edit tool's own old_string/new_string. Null
91// when that is not safely possible: Write carries no prior content; an Edit whose
92// new_string is not unique post-edit (e.g. replace_all) cannot pin the occurrence.
93/** @param {string} toolName @param {Record<string, unknown>} toolInput @param {string} newContent @returns {string | null} */
94export function reconstructOld(toolName, toolInput, newContent) {
95 if (toolName !== "Edit") return null;
96 const oldString = toolInput?.old_string;
97 const newString = toolInput?.new_string;
98 if (typeof oldString !== "string" || typeof newString !== "string") return null;
99 if (countOccurrences(newContent, newString) !== 1) return null;
100 return newContent.replace(newString, () => oldString);
101}
102
103// The `name@range` specs new or changed in DEP_FIELDS; removed entries are ignored
104// (no uninstall -- out of scope). peerDependencies are deliberately left out.
105/** @param {Record<string, any>} oldPkg @param {Record<string, any>} newPkg @returns {string[]} */
106export function collectChangedSpecs(oldPkg, newPkg) {
107 const specs = [];
108 for (const field of DEP_FIELDS) {
109 const newDeps = newPkg?.[field] ?? {};
110 const oldDeps = oldPkg?.[field] ?? {};
111 for (const [name, range] of Object.entries(newDeps)) {
112 if (oldDeps[name] !== range) specs.push(`${name}@${range}`);
113 }
114 }
115 return specs;
116}
117
118// What a Write/Edit of a package.json asks for: null = nothing (invalid post-edit
119// JSON, or no dependency change, e.g. a version-only edit); `{ specs: null }` = a bare
120// install (Write, or an Edit that cannot be reconstructed); `{ specs }` = targeted.
121/** @param {string} toolName @param {Record<string, unknown>} toolInput @param {string} newContent @returns {{ specs: string[] | null } | null} */
122export function planInstall(toolName, toolInput, newContent) {
123 let newPkg;
124 try {
125 newPkg = JSON.parse(newContent);
126 } catch {
127 return null;
128 }
129 const oldContent = reconstructOld(toolName, toolInput, newContent);
130 if (oldContent === null) return { specs: null };
131 let oldPkg;
132 try {
133 oldPkg = JSON.parse(oldContent);
134 } catch {
135 return { specs: null };
136 }
137 const specs = collectChangedSpecs(oldPkg, newPkg);
138 return specs.length > 0 ? { specs } : null;
139}
140
141// The context line one finished install leaves for the model, or null to stay silent
142// (clean exit, or our own timeout kill). `outcome` is `$.process.run`'s result, or
143// `{ error }` with the text of its rejection: "... failed to start: <CODE>: ..." when
144// the binary could not start, "... aborted: still running after <N>ms" on the timeout.
145/**
146 * @param {string} hook @param {string} bin @param {string} command @param {string} cwd
147 * @param {{ exitCode: number, stdout: string, stderr: string } | { error: string }} outcome
148 * @returns {string | null}
149 */
150export function outcomeMessage(hook, bin, command, cwd, outcome) {
151 let reason;
152 let output = "";
153 if ("error" in outcome) {
154 if (/aborted: still running after/.test(outcome.error)) return null;
155 const code = /failed to start: ([A-Z0-9_]+)/.exec(outcome.error)?.[1];
156 if (code === "ENOENT") return `${hook}: ${bin} not found on PATH, skipped in ${cwd}`;
157 reason = `spawn error ${code ?? outcome.error}`;
158 } else {
159 if (outcome.exitCode === 0) return null;
160 reason = `exit code ${outcome.exitCode}`;
161 // each stream truncated on its own before joining, as before
162 output = `${truncate(outcome.stdout)}\n${truncate(outcome.stderr)}`;
163 }
164 return `${hook}: \`${command}\` failed in ${cwd} (${reason}):\n${truncate(output)}`;
165}
166