定制 gerard/claude-code-hooks 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

gerard/claude-code-hooks

最新稳定版本:v0.0.1

Composer 安装命令:

composer require gerard/claude-code-hooks

包简介

Type-safe Claude Code hooks for PHP — 29 events, multi-source resolver, transcript reader, linter, doc-drift conformance.

README 文档

README

🪝 gerard/claude-code-hooks

Type-safe Claude Code hooks for PHP. Every event, every tool input, every response — fully typed, mutation-tested, and kept in sync with the Anthropic spec by a daily drift job.

PHP License: MIT PHPStan Psalm Coverage MSI Doc-drift Status PRs welcome

🤔 What is this?

Claude Code — Anthropic's official CLI for Claude — emits hook events at every interesting moment of a coding session: a tool is about to run, the user submitted a prompt, a session is starting, the context just got compacted, and so on. There are 29 documented events today, each with its own JSON wire shape and decision protocol.

If you want to observe, intercept, redirect, or veto what Claude Code does — from a PHP backend, a CLI, or a Symfony bundle — this package gives you a first-class SDK for it:

  • 📥 Decode any inbound hook payload into an immutable typed event. No more array_key_exists() ladders.
  • 📤 Encode valid response payloads with fluent builders that match the documented decision shapes byte-for-byte.
  • 🔍 Inspect Claude Code's runtime state: stream transcripts, scan installed plugins / skills / agents / MCP servers, compute Sonnet pricing.
  • 🛡️ Lint your settings.json so misconfigured hooks fail in dev, not in production.
  • 📡 Stay current — a daily CI job diffs the live Anthropic doc and opens a GitHub issue the moment a new event ships.

It's the layer you'd otherwise have to reinvent in every project that touches Claude Code from PHP.

✨ Highlights

🎯 29 typed events Every documented hook (Session, Turn, Tool, Perm, Compact, Ctx, Team) as an immutable DTO. Forward-compatible: unknown event names surface as UnknownHookEvent, never as exceptions.
🧰 12 typed tool inputs Bash, Edit, Write, Read, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion, TodoWrite, Skill. Plus a RawToolInput fallback that handles mcp__<server>__<tool> invocations and any first-party tool not yet modelled.
📤 13 response builders Immutable, fluent. with*() clones, toArray() ships. Decision shapes match the docs exactly.
🪜 Multi-source resolver Honours the documented precedence: Policy → User → Project → Local → Plugin → Runtime. Filesystem and in-memory loaders included; bring your own by implementing SettingsLoader.
📜 Streaming transcripts Generator-based JSONL reader with an 8 MB per-line cap (SEC-02), compact_boundary events, and a sidechain grouper. Tail huge transcripts without loading them into memory.
💰 Cost calculator Sonnet 4.5 price table out of the box; swap with your own PriceTable. Integer-cent arithmetic via a Money value object — no float drift.
🔎 Linter 6 core rules ship today (broad matchers in policy, unknown events, missing matchers, HTTP handlers without timeout, secret literals, broad matchers on prompt/agent hooks). 18 more on the roadmap (see CHANGELOG.md).
🛡️ Security-first realpath-based path-traversal protection (SEC-01), Authorization header redaction (SEC-09), single JsonDecoder chokepoint with JSON_THROW_ON_ERROR, safe-only YAML parsing (SEC-03), 10 MB body cap on the doc-drift fetch (SEC-06).
🧪 100 % covered, mutation-tested PHPStan max + strict, Psalm errorLevel 1, Deptrac layered architecture, MSI ≥ 80 / Covered MSI ≥ 85 — all enforced in CI.
📡 Daily doc-drift Scheduled CI job re-fetches https://code.claude.com/docs/en/hooks and opens a GitHub issue the moment Anthropic ships a new event. SHA-256 sidecar pins captured fixtures against silent edits.

🚀 Quick start — 60 seconds

The whole loop is decode → match → respond:

use Gerard\ClaudeCodeHooks\Event\HookEventFactory;
use Gerard\ClaudeCodeHooks\Event\Tool\PreToolUseEvent;
use Gerard\ClaudeCodeHooks\Event\Tool\Input\BashInput;
use Gerard\ClaudeCodeHooks\Response\Tool\PreToolUseResponse;
use Gerard\ClaudeCodeHooks\Support\JsonDecoder;

// 1. Decode the inbound payload (always go through JsonDecoder).
$payload = JsonDecoder::decode(file_get_contents('php://input'), 'incoming-hook');

// 2. Resolve to a typed event.
$event = (new HookEventFactory())->fromPayload($payload);

// 3. Pattern-match — read typed properties, decide, respond.
$response = match (true) {
    $event instanceof PreToolUseEvent
        && $event->toolInput instanceof BashInput
        && str_starts_with($event->toolInput->command, 'rm -rf /')
            => PreToolUseResponse::deny('blocked: dangerous command'),
    default => PreToolUseResponse::allow(),
};

echo json_encode($response->toArray(), JSON_THROW_ON_ERROR);

That's it. No manual JSON spelunking, no copy-paste from the docs, no hand-rolled response builders.

📦 Installation

composer require gerard/claude-code-hooks

Requirements

  • PHP 8.3+
  • Extensions: ext-json, ext-mbstring

Runtime dependencies

  • psr/log ^3.0
  • symfony/http-client ^7.0 (used by AnthropicSpecExtractor)
  • symfony/yaml ^7.0 (used by the plugin / skill / agent scanners)

📚 Cookbook

🎯 Intercepting a tool call

PreToolUseEvent::toolInput is typed against the ToolInput interface — pattern-match on the concrete class to read the typed properties.

use Gerard\ClaudeCodeHooks\Event\Tool\PreToolUseEvent;
use Gerard\ClaudeCodeHooks\Event\Tool\Input\BashInput;
use Gerard\ClaudeCodeHooks\Event\Tool\Input\WriteInput;
use Gerard\ClaudeCodeHooks\Response\Tool\PreToolUseResponse;

if ($event instanceof PreToolUseEvent) {
    $response = match (true) {
        $event->toolInput instanceof BashInput
            && preg_match('#\bsudo\b#', $event->toolInput->command)
                => PreToolUseResponse::deny('sudo blocked by hook policy'),

        $event->toolInput instanceof WriteInput
            && str_ends_with($event->toolInput->filePath, '.env')
                => PreToolUseResponse::ask('Confirm before overwriting .env'),

        default => PreToolUseResponse::allow(),
    };
}

The four documented decisions — allow, deny, ask, defer — are static factories on PreToolUseResponse. Want to mutate the input before letting it through? ->withUpdatedInput($newInput).

📜 Streaming a transcript

TranscriptReader is a Generator — it never loads the file into memory.

use Gerard\ClaudeCodeHooks\Transcript\TranscriptReader;
use Gerard\ClaudeCodeHooks\Transcript\TranscriptCursor;
use Gerard\ClaudeCodeHooks\Transcript\TranscriptLine;
use Gerard\ClaudeCodeHooks\Transcript\BoundaryEvent;

$reader = new TranscriptReader();           // 8 MB per-line cap by default
$cursor = new TranscriptCursor($path, 0);

foreach ($reader->tail($cursor) as $entry) {
    if ($entry instanceof BoundaryEvent) {
        // The session was just compacted — relocate your cursor if needed.
        continue;
    }

    /** @var TranscriptLine $entry */
    if ($entry->truncated) {
        // Line exceeded the 8 MB cap — payload is empty, byte range is preserved.
        continue;
    }

    $entry->type;        // "user" | "assistant" | "system" | …
    $entry->isSidechain; // true when the line belongs to a subagent fork
    $entry->payload;     // the full decoded JSON line
}

// `return $reader->tail(...)` yields a TranscriptCursor advanced past the last
// complete line. Persist it to resume later without re-reading the whole file.

💰 Computing usage cost

use Gerard\ClaudeCodeHooks\Cost\CostCalculator;
use Gerard\ClaudeCodeHooks\Cost\Sonnet45PriceTable;
use Gerard\ClaudeCodeHooks\Transcript\Usage;

$usage = new Usage(
    inputTokens: 12_000,
    outputTokens: 800,
    cacheReadInputTokens: 9_500,
    cacheCreationInputTokens: 2_000,
    serviceTier: 'standard',
);

$cost = (new CostCalculator())->compute($usage, new Sonnet45PriceTable());

$cost->microDollars;  // int — exact, never a float
$cost->asDollars();   // float — for display only

Money is closed under addition; sum the cost of every assistant turn in a transcript by chaining ->add().

🪜 Resolving multi-source settings.json

use Gerard\ClaudeCodeHooks\Resolver\HookConfigResolver;
use Gerard\ClaudeCodeHooks\Resolver\FilesystemSettingsLoader;

$resolver = new HookConfigResolver(new FilesystemSettingsLoader([
    'policySettings'  => '/etc/claude-code/settings.json',
    'userSettings'    => $_SERVER['HOME'].'/.claude/settings.json',
    'projectSettings' => getcwd().'/.claude/settings.json',
    'localSettings'   => getcwd().'/.claude/settings.local.json',
]));

$registry = $resolver->resolve();

foreach ($registry->rules as $rule) {
    $rule->event;    // "PreToolUse"
    $rule->matcher;  // "Bash"
    $rule->handler;  // ['type' => 'http', 'url' => '…', …]
    $rule->source;   // HookSource::Project
    $rule->managed;  // bool — set when the policy enforces `allowManagedHooksOnly`
}

The resolver honours the full documented precedence chain and applies the allowManagedHooksOnly policy filter before merging.

🛠️ Building a settings.json fragment

use Gerard\ClaudeCodeHooks\Builder\HookConfigBuilder;

$config = HookConfigBuilder::create()
    ->event('PreToolUse')->matcher('Bash')
        ->httpHandler('http://127.0.0.1:42987/?event=PreToolUse')
            ->withHeader('Authorization', '${GERARD_TOKEN}')  // placeholder only
            ->withTimeout(5)
            ->async(true)
    ->build();

file_put_contents(
    '.claude/settings.json',
    json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);

Note: withHeader('Authorization', …) only accepts ${VAR} placeholders — this layer never holds real secrets. Resolution happens in the future Symfony bundle.

🔎 Linting a config

use Gerard\ClaudeCodeHooks\Linter\HookLinter;
use Gerard\ClaudeCodeHooks\Linter\HookConfig;
use Gerard\ClaudeCodeHooks\Linter\ConfigProfile;
use Gerard\ClaudeCodeHooks\Linter\Rule\Cch001BroadMatcherInPolicy;
use Gerard\ClaudeCodeHooks\Linter\Rule\Cch004HttpHandlerWithoutTimeout;
use Gerard\ClaudeCodeHooks\Linter\Rule\Cch005SecretInUrlLiteral;

$linter = new HookLinter([
    new Cch001BroadMatcherInPolicy(),
    new Cch004HttpHandlerWithoutTimeout(),
    new Cch005SecretInUrlLiteral(),
    // … or pass every rule under src/Linter/Rule/
]);

$findings = $linter->lint([$hookConfig], new ConfigProfile());

foreach ($findings as $finding) {
    $finding->severity;     // Severity::Error | Warning | Notice
    $finding->code;         // "CCH004"
    $finding->message;      // never embeds a secret value (SEC-08)
    $finding->jsonPointer;  // "/hooks/PreToolUse/0/hooks/0/timeout"
}

📤 Producing a structured response

Every response builder is immutable: with*() returns a clone, toArray() ships.

use Gerard\ClaudeCodeHooks\Response\Tool\PreToolUseResponse;

$response = PreToolUseResponse::allow()
    ->withSuppressOutput(false)
    ->withSystemMessage('Audited');

echo json_encode($response->toArray(), JSON_THROW_ON_ERROR);

🗂️ All supported events (29)

Family Events
Session SessionStart, SessionEnd, Setup
Turn UserPromptSubmit, UserPromptExpansion, Stop, StopFailure, SubagentStart, SubagentStop
Tool PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch
Perm PermissionRequest, PermissionDenied, Notification, Elicitation, ElicitationResult
Compact PreCompact, PostCompact
Ctx ConfigChange, CwdChanged, FileChanged, InstructionsLoaded, WorktreeCreate, WorktreeRemove
Team TaskCreated, TaskCompleted, TeammateIdle

Anything Anthropic ships after the pinned snapshot date surfaces as UnknownHookEvent — your code keeps running, the daily doc-drift job opens an issue, the next release adds the typed DTO.

🛠️ All supported tool inputs (12 + fallback)

DTO Wire tool_name Notable properties
BashInput Bash command, description, timeout, runInBackground
ReadInput Read filePath, offset, limit
WriteInput Write filePath, content
EditInput Edit filePath, oldString, newString, replaceAll
GlobInput Glob pattern, path
GrepInput Grep pattern, path, glob, outputMode, caseInsensitive, multiline
WebFetchInput WebFetch url, prompt
WebSearchInput WebSearch query, allowedDomains, blockedDomains
AgentInput Agent prompt, description, subagentType, model
AskUserQuestionInput AskUserQuestion questions, answers
TodoWriteInput TodoWrite todos[] (content, status, activeForm)
SkillInput Skill skill, args (#[\SensitiveParameter])
RawToolInput everything else name, payload — covers mcp__* + first-party tools without dedicated DTOs

🔌 Wire shapes — three quick references

The wire field names below match the actual shape Claude Code emits, verified against a real-corpus sample of 4 089 events.

PostToolUse
{
  "hook_event_name": "PostToolUse",
  "session_id": "",
  "transcript_path": "",
  "cwd": "",
  "permission_mode": "default",
  "tool_name": "Bash",
  "tool_input":  { "command": "ls", "description": "list" },
  "tool_response": { "stdout": "", "stderr": "", "interrupted": false },
  "tool_use_id": "toolu_01…",
  "duration_ms": 181
}
$event->toolName;     // "Bash"
$event->toolInput;    // BashInput { command: "ls", description: "list", … }
$event->toolResponse; // string|array<string,mixed> — the raw response shape
$event->durationMs;   // int|null — null when the wire omits duration_ms
$event->toolUseId;    // string|null
SessionEnd
{ "hook_event_name": "SessionEnd", "session_id": "", "transcript_path": "", "cwd": "", "reason": "other" }
$event->reason;         // "logout" | "clear" | "other" | …
$event->permissionMode; // PermissionMode::Default when the wire omits it
Stop
{
  "hook_event_name": "Stop",
  "session_id": "",
  "transcript_path": "",
  "cwd": "",
  "stop_hook_active": false,
  "last_assistant_message": "Goodbye!"
}
$event->stopHookActive;       // bool — required by the wire contract
$event->lastAssistantMessage; // string|null

💡 One JSON entry point. JsonDecoder::decode() is the only sanctioned JSON parser in the package. It enforces JSON_THROW_ON_ERROR and a depth cap of 64. Raw json_decode() calls in src/ are forbidden by CI.

📡 Staying in sync with the Anthropic spec

Two CLI binaries ship with the package and back the daily doc-drift.yml workflow:

Binary What it does Exit codes
bin/extract-anthropic-spec Fetches https://code.claude.com/docs/en/hooks (or, with --from-html-fixture <path>, parses a captured HTML fixture) and emits a canonicalised JSON spec on stdout or --output <path>. 0 ok • 2 parse failure (NOT drift — the workflow surfaces this as "extractor failure" instead of opening phantom "events removed" issues)
bin/check-doc-drift Diffs the live spec against tests/Fixtures/anthropic-spec/snapshot.json. 0 no drift • 1 drift detected • 2 parse error

Reblessing the snapshot when Anthropic ships a new event:

# 1. Capture a fresh page snapshot.
curl -sSL --max-time 15 \
  -H "User-Agent: gerard-doc-drift/1.0" \
  https://code.claude.com/docs/en/hooks \
  > tests/Fixtures/html/code-claude-com-hooks-YYYY-MM-DD.html

# 2. Sidecar SHA-256 (anti-MITM, anti-silent-edit).
( cd tests/Fixtures/html \
  && sha256sum code-claude-com-hooks-YYYY-MM-DD.html \
       > code-claude-com-hooks-YYYY-MM-DD.html.sha256 )

# 3. Bless the snapshot from the captured HTML.
bin/extract-anthropic-spec \
  --from-html-fixture tests/Fixtures/html/code-claude-com-hooks-YYYY-MM-DD.html \
  --output tests/Fixtures/anthropic-spec/snapshot.json

Then bump AnthropicSpecExtractor::SNAPSHOT_VERSION to the new date. AnthropicSpecExtractorLiveShapeTest asserts the SHA-256 sidecar still matches.

🧪 Quality bar

This is a security- and observability-critical package. The bar is set accordingly:

  • PHPStan level max + phpstan-strict-rules + phpstan-deprecation-rules + phpstan-phpunit + ergebnis/phpstan-ruleszero baseline.
  • Psalm errorLevel: 1 + findUnusedCode: true.
  • Deptrac — strict layered architecture: Event → Response → Resolver → Transcript → Scanner → Linter → Builder → Conformance → Support.
  • PHPUnit 11+ with failOnRisky, failOnWarning, strict coverage metadata.
  • 100 % class + method coverage enforced via bin/check-coverage.
  • Infection — MSI ≥ 80, Covered MSI ≥ 85.
  • Composer audit every CI run.

Run the full local gate:

composer install
composer cs
composer phpstan
composer psalm
composer deptrac
composer test:coverage
bin/check-coverage var/coverage/clover.xml
composer test:integration
composer test:conformance
composer test:mutation
composer audit

Or the curated bundles:

composer qa       # cs + phpstan + psalm + deptrac + test:unit + audit
composer qa:full  # qa + test:integration + test:conformance + test:mutation

🔐 Security highlights

ID Mitigation
SEC-01 All scanner paths pass through realpath(); symlink escapes from ~/.claude/plugins etc. are rejected.
SEC-02 TranscriptReader caps every line at 8 MB; oversize lines surface as TranscriptLine{truncated: true} instead of OOM-ing.
SEC-03 YAML decoded only via Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE. Object instantiation flags are forbidden (CI grep guard).
SEC-04 / 05 Decoder exceptions never embed the offending payload — only the source path and offset.
SEC-06 AnthropicSpecExtractor HTTP options are pinned: verify_peer/verify_host on, host allow-list, 10 MB body cap.
SEC-08 Linter Finding::$message is capped at 256 bytes and may never embed >8 contiguous chars from the offending input — invariant-tested.
SEC-09 HookConfig::__debugInfo() redacts the Authorization header.
SEC-15 Captured HTML fixtures are CI-scanned for Bearer , eyJ, gha_, glpat-, sk-, xoxb- patterns.

🤝 Contributing

Contributions are welcome — this is a community package and the API is intentionally narrow so it stays reviewable. Please read CONTRIBUTING.md for:

  • 🪜 the local quality-gate checklist;
  • 🆕 the 3-step "add an event" workflow;
  • 📸 the fixture HTML capture protocol (with the tests/Fixtures/html/*.sha256 sidecar);
  • 🔐 the #[\SensitiveParameter] convention enforced by the CI grep guard.

Found a bug or a missing event? Open an issue — a failing test fixture in tests/Fixtures/payloads/ is the fastest path to a fix.

See CHANGELOG.md for notable changes and the linter-rule roadmap, and docs/adr/ for architectural decision records.

📄 License

Released under the MIT License. Copyright © 2026 Sébastien Dieunidou and contributors.

🪝 Built for the PHP community that ships with Claude Code.

Report a bugRead the changelogContribute

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 1
  • 点击次数: 9
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 1
  • Watchers: 0
  • Forks: 0
  • 开发语言: HTML

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-05-13

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固