HookStack
Back to catalogue
WorkflowSessionStart· startup

Install project dependencies on init

Fresh clone opens with node_modules ready

Detects the project's package manager from lock files (pnpm, yarn, npm) and runs a frozen install on first session startup. Ensures Claude Code always starts with a complete node_modules without requiring a manual install step.

What does the Install project dependencies on init hook do?

Install project dependencies on init is a Claude Code SessionStart hook matching startup. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Fresh clone opens with node_modules ready.

As a SessionStart hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Auto-install dependencies when Claude Code opens a freshly cloned repository
  • Guarantee node_modules is present before any tool calls run in a new project
  • Unify the dev environment setup across team members via a shared hook

Tags

#setup#npm#pnpm#yarn#dependencies#onboarding

settings.json fragment

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/setup-install-deps.mjs",
            "type": "command"
          }
        ],
        "matcher": "startup"
      }
    ]
  }
}

Script · .claude/hooks/setup-install-deps.mjs

#!/usr/bin/env node
// @hookstack setup-install-deps
import { execSync } from "node:child_process";
// @hookstack setup-install-deps
// Installe les dépendances au démarrage si node_modules est absent (SessionStart/WorktreeCreate).
// Dans un worktree distinct, update-deps.mjs gère l'install en mode détaché — ce hook
// s'abstient pour éviter la race condition (deux pnpm install concurrents → ENOTEMPTY).
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

export function run(input, { exec, exists = existsSync } = {}) {
	const cwd = input.cwd;
	const has = (f) => exists(`${cwd}/${f}`);

	// Skip in a distinct worktree: update-deps.mjs already spawned pnpm there.
	try {
		const list = execSync("git worktree list", {
			cwd,
			encoding: "utf8",
			timeout: 5_000,
		}).trim();
		const mainDir = list.split("\n")[0]?.split(/\s+/)[0] ?? "";
		if (mainDir && mainDir !== cwd) return null;
	} catch {
		/* not a git repo — fall through */
	}

	if (has("node_modules")) return null; // already installed

	let cmd = null;
	if (has("pnpm-lock.yaml")) cmd = "pnpm install --frozen-lockfile";
	else if (has("yarn.lock")) cmd = "yarn install --frozen-lockfile";
	else if (has("package-lock.json")) cmd = "npm ci";
	else if (has("package.json")) cmd = "npm install";

	if (!cmd) return null;

	const doExec =
		exec ?? ((c) => execSync(c, { cwd, stdio: "inherit", timeout: 180_000 }));
	try {
		doExec(cmd);
		return { cmd, message: `[setup-install-deps] Running: ${cmd}\n` };
	} catch (e) {
		return {
			cmd,
			error: e.message,
			message: `[setup-install-deps] Failed: ${e.message}\n`,
		};
	}
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks