1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code に「bun を使え」が通じないので、Hook でしつけてやった話

1
Posted at

Node.js環境でClaude Codeを使ってコーディングをしているとき、こんなことはありませんか?

  • Claude Code がパッケージを追加するときに、bun のプロジェクトなのに npm install を叩かれた。
  • CLAUDE.md に「bun を使ってください」と書き足したら、今度は pnpm add してきた。
  • 挙げ句の果てには package.json を直接編集して、古いバージョンを突っ込んできた。

お前は言うことを聞かんのか。

ただ、 Permissions.DenyBash(npm *)Bash(pnpm *) を弾けばいいかというと、それだけでは足りません。

  • エージェントは npm, pnpm など色々なパッケージマネージャを使おうとする。全パターンを deny に列挙し続けるのはめんどくさい現実的でない
  • Bash コマンドを塞いでも、Edit/Write ツールで package.json を直接書き換える抜け道が残る

指示は破られるし、Deny だけでは塞ぎきれない。

しかし、Hook なら lockfile を見て不適切なパッケージマネージャーを判定でき、直接ファイル編集も含めてブロックできます。この記事では、実際に運用している 2 つの Hook を紹介します。

なお、Hook スクリプトは Node.js(.mjs)で書いています。私はWindows と Mac の両方で Claude Code を使っており、シェルスクリプトだと沼にハマってしまうので・・・(もう諦めました)。

1. 間違ったパッケージマネージャをブロックする

bun プロジェクトで npm install されると package-lock.json が生成されて lockfile が二重になり、node_modules との整合性も崩れます。そこで、lockfile を見て正しいパッケージマネージャを判定し、違うものが来たらブロックします。

lockfile 許可 ブロック
bun.lock bun / bunx npm, npx, pnpm, pnpx
package-lock.json npm / npx bun, bunx, pnpm, pnpx
pnpm-lock.yaml pnpm / pnpx npm, npx, bun, bunx

settings.json にはこう書きます。~/.claude/settings.json に置けば全プロジェクトに適用されます。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node /path/to/enforce-package-manager.mjs"
          }
        ]
      }
    ]
  }
}

スクリプト本体はこちら。

#!/usr/bin/env node
import { readFileSync, existsSync } from "node:fs";

function deny(reason) {
  console.log(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: reason,
      },
    })
  );
  process.exit(0);
}

const input = JSON.parse(readFileSync(0, "utf-8") || "{}");
const command = input.tool_input?.command || "";

const pmPatterns = {
  npm: /(?:^|&&|;|\||\$\()\s*(?:npm|npx)\s+/i,
  bun: /(?:^|&&|;|\||\$\()\s*(?:bun|bunx)\s+/i,
  pnpm: /(?:^|&&|;|\||\$\()\s*(?:pnpm|pnpx)\s+/i,
};

const lockfiles = {
  "bun.lock": { allow: "bun", block: ["npm", "pnpm"], name: "bun" },
  "package-lock.json": { allow: "npm", block: ["bun", "pnpm"], name: "npm" },
  "pnpm-lock.yaml": { allow: "pnpm", block: ["npm", "bun"], name: "pnpm" },
};

const detected = Object.entries(lockfiles).find(([file]) => existsSync(file));
if (!detected) process.exit(0);

const [, config] = detected;

for (const blocked of config.block) {
  if (pmPatterns[blocked].test(command)) {
    deny(`このプロジェクトでは ${config.name} を使用してください(${detected[0]} を検出)。`);
  }
}

&&; でチェーンされたコマンドも正規表現で拾います。ブロック時は process.exit(0) で抜けます(非ゼロだと Hook 自体のエラー扱いになるので注意)。

2. package.json の deps を直接いじらせない

Claude Code はあほなので、パッケージマネージャのコマンドを使わず、Edit/Write ツールで package.json を直接書き換えてくることがあります。こうなると lockfile は更新されないし、バージョンも古いものになります。

しかも、そのまま動いちゃうのでぼーっとしているとバージョンが非常に古いままになるということが発生します。いい加減にしろ。Renovate で気が付いて、アップデート破壊的にならないよな・・・とか気を遣うのはもうやめたい。

そこで、scripts 等の編集は通しつつ、dependencies / devDependencies への編集だけを Hooks でブロックします。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node /path/to/deny-package-json-deps-edit.mjs"
          }
        ]
      }
    ]
  }
}

スクリプト本体はこちら。

#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

function deny(reason) {
  console.log(
    JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: reason,
      },
    })
  );
  process.exit(0);
}

const input = JSON.parse(readFileSync(0, "utf-8") || "{}");
const toolInput = input.tool_input || {};
const filePath = toolInput.file_path || "";

if (!filePath.endsWith("package.json")) {
  process.exit(0);
}

// Write は package.json 全体の書き換えになるため無条件ブロック
if (input.tool_name === "Write") {
  deny(
    "package.json 全体の書き換えは禁止です。パッケージマネージャの add / remove コマンドを使用してください。"
  );
}

// Edit は編集箇所が deps セクション内かを判定
const oldString = toolInput.old_string || "";

try {
  const absPath = resolve(filePath);
  const content = readFileSync(absPath, "utf-8");
  const editPos = content.indexOf(oldString);

  if (editPos === -1) process.exit(0);

  const sections = ["dependencies", "devDependencies"];
  for (const section of sections) {
    const sectionKeyPos = content.indexOf(`"${section}":`);
    if (sectionKeyPos === -1) continue;

    const braceStart = content.indexOf("{", sectionKeyPos);
    if (braceStart === -1) continue;

    let depth = 0;
    let braceEnd = -1;
    for (let i = braceStart; i < content.length; i++) {
      if (content[i] === "{") depth++;
      if (content[i] === "}") depth--;
      if (depth === 0) {
        braceEnd = i;
        break;
      }
    }

    if (editPos >= sectionKeyPos && editPos <= braceEnd) {
      deny(
        `package.json の ${section} を直接編集しないでください。\n` +
          "パッケージマネージャの add / remove コマンドで追加・削除してください。\n" +
          "バージョンは学習データの記憶に頼らず、未指定で最新版をインストールしてください。\n" +
          "特定バージョンが必要な場合は事前に確認してください。"
      );
    }
  }
} catch {
  // ファイル読み取りエラー時は許可(安全側に倒す)
}

やっていることは、Edit ツールの old_string が package.json 内のどこにあるかを indexOf で調べて、それが dependencies / devDependencies{} の中だったらブロック、というシンプルな判定です。JSON パーサーを使わず文字列走査しているのは、パースすると位置情報が消えるからです。

まとめ

防ぎたいこと Hook やっていること
違うパッケージマネージャの使用 enforce-package-manager lockfile から判定し、違うコマンドをブロック
package.json の直接編集 deny-package-json-deps-edit deps セクションへの Edit/Write をブロック

これで、もう勝手に npm install されることはなくなりました。言うことを聞かないなら、聞かざるを得ない仕組みを作るのです。

(もっといいやり方があったら教えてください笑)

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?