diff --git a/.omo/plans/comment-consistency-cleanup.md b/.omo/plans/comment-consistency-cleanup.md new file mode 100644 index 0000000..2d7a27f --- /dev/null +++ b/.omo/plans/comment-consistency-cleanup.md @@ -0,0 +1,556 @@ +# Comment Consistency Cleanup Plan + +## TL;DR +> **Summary**: Fix the inconsistent comment state left by the partial remove-ai-slops pass. Restore a uniform JSDoc/file-comment policy across the public TypeScript API repo: keep API-contract comments, remove temporal/AI-workflow/migration/marketing language and heavy banners everywhere. +> **Deliverables**: Consistent comment style in `src/` and `tests/`, restored concise file-level headers for public modules, removed slop phrases, green quality gates. +> **Effort**: Medium +> **Parallel**: YES — 4 waves +> **Critical Path**: Wave 1 (policy + banned-phrase sweep) → Wave 2 (source headers) ‖ Wave 3 (test headers) → Wave 4 (verification) + +## Context +### Original Request +The user observed that the previous `/remove-ai-slops` execution: +1. Ran as execution without planning. +2. Used 5 deep agents with large context windows to review a small set of files each. +3. Removed some JSDoc/file-level comments while leaving 90% of identical comments in place, creating inconsistency. +4. Left temporal-reference comments such as the `tests/eslint-disable-blocker.test.ts` header intact. +5. The user wants to plan fixing these inconsistencies and establish whether JSDoc comments are slop in a public API repo. + +### Interview Summary +- User intent: **inconsistency is the biggest slop**. The repo must have a uniform comment style. +- User opinion: JSDoc comments are **not slop** for a public API product. +- User wants a plan first, not immediate execution. + +### Metis Review (gaps addressed) +- Distinguish comment audience: public API contract, CLI operational contract, internal implementation note, test regression rationale. +- Avoid overzealous word bans that delete valid API behavior words (e.g., "automatic" as behavior vs. "automatically generated"). +- Decide which modules deserve file-level headers: package entrypoints, CLIs, public reference handlers, complex processing modules. +- In long files, replace heavy banners with short separators or better `describe` structure rather than removing all structure. +- Standardize public field comments in `src/types/index.ts` rather than only deleting prose. + +## Decision: Target Comment Style +**Recommended default**: **Library-grade explicit** for public API comments. + +This means: +1. **Keep** JSDoc on all exported types, interfaces, functions, classes, methods, and public module entry points. Public consumers and IDEs depend on these. +2. **Keep** concise file-level headers that describe what the module does and its I/O contract (especially CLIs and reference handlers). +3. **Remove** temporal/AI-workflow/migration/marketing language everywhere: + - Banned phrases: `Following ... pattern`, `incremental`, `one test at a time`, `Phase`, `Post-Phase`, `recent`, `recently`, `parent project`, `ported from`, `moved to`, `will`, `currently`, `now`, `new`, `modern`, `legacy`, `comprehensive`, `designed for`, `automatically` (when used as filler), `supports` (when redundant). +4. **Remove or simplify** heavy visual banners (`// =====`, `// ---------------------------------------------------------------------------`, `// ----`) to single blank-line separators. Keep only where a file is genuinely long and the separator aids navigation. +5. **Keep** regression-test rationale comments that explain **why** a test exists. +6. **Keep** internal comments that prevent likely bugs or explain non-obvious edge cases. + +> **Decision confirmed**: Use **library-grade explicit** comments for this repo. Public API JSDoc is preserved; temporal/AI-workflow/migration/marketing language is removed. + +### Banned phrase verification rule +The banned-phrase list targets **comments only**, not runtime code strings, variable names, or test data. Some terms (e.g., `new`, `will`, `supports`, `automatically`) can be legitimate API-behavior descriptions. The cleanup agent must: +- Review every grep match in a comment before changing or removing it. +- Preserve the comment if the word is describing actual runtime behavior (e.g., "automatically formats" for the format-code hook if that is the hook's documented behavior). +- Remove or rewrite the comment if the word is filler, temporal, or migration context (e.g., "Following parent project's pattern", "incremental", "Phase 2"). +- Final gate: **zero unreviewed banned-phrase matches remain in comments** in `src/` and `tests/`. + +## Work Objectives +### Core Objective +Establish a uniform, professional comment style across `src/` and `tests/` that is appropriate for a public TypeScript API library, eliminating the partial-cleanup inconsistency. + +### Deliverables +1. A documented comment policy (added to `AGENTS.md`). +2. Cleaned source files (`src/**/*.ts`) with consistent JSDoc and no unreviewed slop language in comments. +3. Cleaned test files (`tests/**/*.ts`) with consistent headers and no unreviewed slop language in comments. +4. Green quality gates: `pnpm run check`, `pnpm run test:run`, `pnpm run build`. + +### Definition of Done (verifiable conditions with commands) +- `pnpm run test:run` passes with **1201 tests**, 0 failures. +- `pnpm run check` (type-check + lint) passes with 0 errors. +- `pnpm run build` produces `dist/` without errors. +- A grep for banned temporal/AI phrases returns **zero unreviewed matches in comments** in `src/` and `tests/` (except documented opt-outs). +- All `src/` files have a consistent comment style (manual sample review passes). +- All `tests/` files have a consistent comment style (manual sample review passes). + +### Scope Boundaries +- **IN scope**: `src/**/*.ts`, `tests/**/*.ts`, and `AGENTS.md` (only to document the comment policy). +- **OUT of scope**: Markdown docs (`docs/**/*.md`, `README.md`, `CHANGELOG.md`, etc.), `package.json`, build scripts, upstream mirrors. These were already addressed in the previous cleanup pass. +- **Runtime behavior**: no changes allowed. + +### Must Have +- Uniform JSDoc policy across all public exports. +- Removal of temporal/AI-workflow/migration/marketing language from comments. +- Restoration of concise file-level headers where they were stripped from public modules. +- No functional code changes. + +### Must NOT Have (guardrails) +- Do not remove API-contract JSDoc from exported public types/functions. +- Do not change any runtime behavior, type signatures, or public API names. +- Do not introduce new abstractions or dependencies. +- Do not delete regression-rationale comments. +- Do not leave heavy visual banners in some files and not others. + +## Verification Strategy +> All routine verification is agent-executed. The mandatory Final Verification Wave presents consolidated results to the user and waits for explicit "okay" before the work is marked complete. +- Test decision: Existing tests already cover behavior; no new tests needed for comment-only changes. +- QA policy: Every task has agent-executed verification scenarios. +- Evidence: screenshots/diffs captured in PR review; automated grep reports. +- Banned-phrase grep: agents use grep to find candidates, then manually review each match to confirm it is inside a comment and is not a documented API-behavior opt-out before removing or rewriting it. + +## Execution Strategy +### Parallel Execution Waves +> Target: 5-8 tasks per wave. + +#### Wave 1: Document the policy and run banned-phrase sweep +- Add a short "Comment Style" section to `AGENTS.md` documenting the library-grade explicit policy. +- Run a banned-phrase grep across `src/` and `tests/` to produce the cleanup target list. + +#### Wave 2: Source file cleanup (split by domain) +- Group 1: Core types/schemas/validation/builder (`src/types/index.ts`, `src/validation/*.ts`, `src/utils/output-builder.ts`). +- Group 2: Lifecycle handlers (`src/lifecycle/*.ts`). +- Group 3: Pre-tool-use / post-tool-use handlers (`src/pre-tool-use/*.ts`, `src/post-tool-use/*.ts`). +- Group 4: Processing modules (`src/processing/*.ts`). +- Group 5: CLI entrypoints (`src/cli/*.ts`). + +#### Wave 3: Test file cleanup +- Group 1: Validation/output-builder/lifecycle tests. +- Group 2: Processing/tail/docs-round-trip tests. +- Group 3: Hooks/content-validators/eslint-disable-blocker tests. +- Group 4: Test utilities. + +#### Wave 4: Verification and consistency check +- Run banned-phrase grep again; must return zero unreviewed matches in comments. +- Run `pnpm run check`, `pnpm run test:run`, `pnpm run build`. +- Manual spot-check of representative files from each group. + +### Dependency Matrix +- Wave 2 groups are independent. +- Wave 3 groups are independent. +- Wave 4 depends on Waves 1-3. + +### Agent Dispatch Summary +- Wave 1: 1 agent (policy + grep). +- Wave 2: 5 parallel agents (source groups). +- Wave 3: 4 parallel agents (test groups). +- Wave 4: 1 agent (verification). + +## TODOs +- [x] 1. Document comment-style policy in `AGENTS.md` + + **What to do**: Add a `## Comment Style` section to `AGENTS.md` that records the library-grade explicit policy: keep API-contract JSDoc, strip temporal/AI-workflow/migration/marketing language, avoid heavy banners. + + **Must NOT do**: Do not make this section longer than necessary; do not add process instructions that belong in a runbook. + + **Recommended Agent Profile**: + - Category: `writing` + - Skills: none needed + - Omitted: `remove-ai-slops` — this is policy writing, not slop removal. + + **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: [2-10] | Blocked By: none + + **References**: + - Current `AGENTS.md` — append before `## Compatibility Notes`. + + **Acceptance Criteria**: + - [ ] `AGENTS.md` contains a concise `## Comment Style` section. + - [ ] Section explicitly bans temporal/AI-workflow/migration/marketing phrases. + - [ ] Section states JSDoc on public exports must be preserved. + + **QA Scenarios**: + ``` + Scenario: Policy is readable and complete + Tool: Read + Steps: Read `AGENTS.md` `## Comment Style` section. + Expected: Section exists, is concise, and covers API JSDoc, banned phrases, and banners. + Evidence: .omo/evidence/comment-policy.md + ``` + + **Commit**: YES | Message: `docs(agents): document library-grade explicit comment style` | Files: `AGENTS.md` + +- [x] 2. Run banned-phrase grep and produce target list + + **What to do**: Search `src/` and `tests/` for banned phrases and heavy banner patterns. Produce a structured report (file, line, comment text, suggested action). + + **Must NOT do**: Do not edit files in this task; do not flag valid API-behavior words used in non-comment contexts. + + **Recommended Agent Profile**: + - Category: `explore` + - Skills: none needed + + **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: [3-10] | Blocked By: [1] + + **References**: + - Banned phrase list from the plan decision section. + + **Acceptance Criteria**: + - [ ] Report lists every `src/` and `tests/` file containing candidate banned-phrase matches in comments or heavy comment banners. + - [ ] Report distinguishes API-contract JSDoc from slop language. + - [ ] Report is saved to `.omo/evidence/banned-phrase-report.md`. + + **QA Scenarios**: + ``` + Scenario: Report is complete and structured + Tool: Bash + Steps: ls .omo/evidence/banned-phrase-report.md && head -50 .omo/evidence/banned-phrase-report.md + Expected: File exists and contains file paths, line numbers, and suggested actions. + Evidence: .omo/evidence/banned-phrase-report.md + ``` + + **Commit**: NO + +- [x] 3. Clean core source comments: types, schemas, validators, builder + + **What to do**: Edit `src/types/index.ts`, `src/validation/schemas.ts`, `src/validation/validators.ts`, `src/validation/index.ts`, `src/utils/output-builder.ts` to: + - Restore concise file-level headers where they were stripped. + - Remove temporal/AI/marketing language from field/method JSDoc. + - Standardize on `/** Brief description. */` for fields; multi-line only when needed. + - Remove or simplify heavy section banners. + + **Must NOT do**: Do not delete public API contract information; do not change any runtime behavior or types. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + - Omitted: none + + **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: [11] | Blocked By: [2] + + **References**: + - `src/types/index.ts:1-50` — restore concise header. + - `src/validation/schemas.ts:1-50` — header was replaced; ensure new header is clean. + - `src/validation/validators.ts` — function JSDoc remains; strip temporal language. + + **Acceptance Criteria**: + - [ ] Each edited file has a concise file-level header. + - [ ] Zero unreviewed banned-phrase matches remain in comments in edited files; documented API-behavior opt-outs allowed. + - [ ] `pnpm run type-check` passes. + + **QA Scenarios**: + ``` + Scenario: Core source files are consistent + Tool: Bash + Steps: grep -RniE "(Following|incremental|Phase|recently|parent project|comprehensive|designed for)" src/types/index.ts src/validation/*.ts src/utils/output-builder.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/core-source-grep.txt + + Scenario: Type-check passes + Tool: Bash + Steps: pnpm run type-check + Expected: exit 0, no errors. + Evidence: .omo/evidence/core-source-typecheck.txt + ``` + + **Commit**: YES | Message: `style(src): standardize comments in types, validation, and builder` | Files: `src/types/index.ts`, `src/validation/*.ts`, `src/utils/output-builder.ts` + +- [x] 4. Clean lifecycle handler comments + + **What to do**: Edit `src/lifecycle/*.ts` to: + - Standardize file headers: keep concise "{Event} Hook Handler — {one-line purpose}" headers. + - Remove "reference handler" boilerplate where it is repetitive and uninformative. + - Remove "Use cases:" bullet lists that duplicate the handler's purpose. + - Keep meaningful one-line JSDoc for helper functions. + + **Must NOT do**: Do not delete I/O contract descriptions for CLI handlers; do not change handler behavior. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: [11] | Blocked By: [2] + + **References**: + - `src/lifecycle/index.ts` — header was removed; restore a concise one. + - `src/lifecycle/notification-handler.ts` — heavy function JSDoc removed previously; ensure remaining code is clean. + - `src/lifecycle/session-start.ts` — same as above. + + **Acceptance Criteria**: + - [ ] All `src/lifecycle/*.ts` files have consistent headers. + - [ ] Zero unreviewed banned-phrase matches remain in comments. + - [ ] `pnpm run type-check` passes. + + **QA Scenarios**: + ``` + Scenario: Lifecycle files are consistent + Tool: Bash + Steps: grep -RniE "(reference handler|Use cases:|Following|incremental|recently)" src/lifecycle/*.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/lifecycle-grep.txt + ``` + + **Commit**: YES | Message: `style(src): standardize lifecycle handler comments` | Files: `src/lifecycle/*.ts` + +- [x] 5. Clean pre-tool-use / post-tool-use handler comments + + **What to do**: Edit `src/pre-tool-use/*.ts` and `src/post-tool-use/*.ts` to: + - Keep concise "{Purpose} Hook" headers. + - Remove redundant bullet lists that repeat what the code does. + - Remove marketing language like "automatically", "supports", "comprehensive". + + **Must NOT do**: Do not delete CLI I/O contract notes; do not change tool behavior. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: [11] | Blocked By: [2] + + **References**: + - `src/pre-tool-use/bash-validator.ts`, `src/pre-tool-use/file-protector.ts`, `src/post-tool-use/format-code.ts`. + + **Acceptance Criteria**: + - [ ] Headers are concise and consistent. + - [ ] Zero unreviewed banned-phrase matches remain in comments; documented API-behavior opt-outs allowed. + - [ ] `pnpm run type-check` passes. + + **QA Scenarios**: + ``` + Scenario: Tool-use files are consistent + Tool: Bash + Steps: grep -RniE "(automatically|comprehensive|supports|Following|incremental)" src/pre-tool-use/*.ts src/post-tool-use/*.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/tool-use-grep.txt + ``` + + **Commit**: YES | Message: `style(src): standardize pre-tool-use and post-tool-use comments` | Files: `src/pre-tool-use/*.ts`, `src/post-tool-use/*.ts` + +- [x] 6. Clean processing module comments + + **What to do**: Edit `src/processing/*.ts` to: + - Restore concise file-level headers where stripped (e.g., `src/processing/index.ts`). + - Remove or simplify heavy section banners. + - Strip temporal language ("incremental", "new format", "now", "recently"). + + **Must NOT do**: Do not delete function-level JSDoc that describes public processing API contracts. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: [11] | Blocked By: [2] + + **References**: + - `src/processing/index.ts` — header was removed. + - `src/processing/denoiser.ts` — banners were removed. + - `src/processing/tail.ts` — contains "incremental" and "Designed for". + - `src/processing/parser.ts` — contains "new format". + + **Acceptance Criteria**: + - [ ] Concise file-level headers restored where appropriate. + - [ ] Zero unreviewed banned-phrase matches remain in comments. + - [ ] `pnpm run type-check` passes. + + **QA Scenarios**: + ``` + Scenario: Processing files are consistent + Tool: Bash + Steps: grep -RniE "(incremental|Designed for|new format|recently|now)" src/processing/*.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/processing-grep.txt + ``` + + **Commit**: YES | Message: `style(src): standardize processing module comments` | Files: `src/processing/*.ts` + +- [x] 7. Clean CLI entrypoint comments + + **What to do**: Edit `src/cli/*.ts` to: + - Keep concise headers describing CLI purpose and I/O contract. + - Remove temporal/marketing language. + - Simplify section banners. + + **Must NOT do**: Do not delete CLI usage/contract documentation. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: [11] | Blocked By: [2] + + **References**: + - `src/cli/export-sessions.ts`, `src/cli/tail-session.ts`. + + **Acceptance Criteria**: + - [ ] CLI files have consistent headers. + - [ ] Zero unreviewed banned-phrase matches remain in comments. + - [ ] `pnpm run type-check` passes. + + **QA Scenarios**: + ``` + Scenario: CLI files are consistent + Tool: Bash + Steps: grep -RniE "(Following|incremental|recently|designed for|automatically)" src/cli/*.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/cli-grep.txt + ``` + + **Commit**: YES | Message: `style(src): standardize CLI entrypoint comments` | Files: `src/cli/*.ts` + +- [x] 8. Clean validation/output-builder/lifecycle test comments + + **What to do**: Edit `tests/validation.test.ts`, `tests/output-builder.test.ts`, `tests/lifecycle.test.ts` to: + - Remove temporal/phase/test-order comments. + - Remove or simplify heavy section banners. + - Keep concise file headers. + - Preserve regression-rationale comments. + + **Must NOT do**: Do not delete test intent or regression rationale; do not change test assertions. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 3 | Blocks: [11] | Blocked By: [2] + + **References**: + - `tests/validation.test.ts` — phase banners removed previously; finish cleanup. + - `tests/output-builder.test.ts` — ensure header consistency. + - `tests/lifecycle.test.ts` — same. + + **Acceptance Criteria**: + - [ ] Zero unreviewed banned-phrase matches remain in comments. + - [ ] `pnpm run test:run tests/validation.test.ts tests/output-builder.test.ts tests/lifecycle.test.ts` passes. + + **QA Scenarios**: + ``` + Scenario: Test group 1 is consistent + Tool: Bash + Steps: grep -niE "(Phase|FIRST TEST|Following|incremental|recently)" tests/validation.test.ts tests/output-builder.test.ts tests/lifecycle.test.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/test-group-1-grep.txt + ``` + + **Commit**: YES | Message: `style(tests): standardize validation, builder, and lifecycle test comments` | Files: `tests/validation.test.ts`, `tests/output-builder.test.ts`, `tests/lifecycle.test.ts` + +- [x] 9. Clean processing/tail/docs-round-trip test comments + + **What to do**: Edit `tests/processing.test.ts`, `tests/tail.test.ts`, `tests/docs-round-trip.test.ts` to: + - Remove temporal/test-step comments ("Append a second message", "Round 1", etc.). + - Remove historical/migration comments ("ported from", "webui-derived"). + - Simplify or remove heavy banners. + - Preserve regression-rationale comments. + + **Must NOT do**: Do not change test behavior or assertions. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 3 | Blocks: [11] | Blocked By: [2] + + **References**: + - `tests/processing.test.ts` — some banners already removed. + - `tests/tail.test.ts` — inline step comments removed previously; finish cleanup. + - `tests/docs-round-trip.test.ts` — ensure header consistency. + + **Acceptance Criteria**: + - [ ] Zero unreviewed banned-phrase matches remain in comments. + - [ ] `pnpm run test:run tests/processing.test.ts tests/tail.test.ts tests/docs-round-trip.test.ts` passes. + + **QA Scenarios**: + ``` + Scenario: Test group 2 is consistent + Tool: Bash + Steps: grep -niE "(ported from|webui-derived|Round [0-9]|Append a|Initial:|Truncate)" tests/processing.test.ts tests/tail.test.ts tests/docs-round-trip.test.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/test-group-2-grep.txt + ``` + + **Commit**: YES | Message: `style(tests): standardize processing, tail, and docs-round-trip test comments` | Files: `tests/processing.test.ts`, `tests/tail.test.ts`, `tests/docs-round-trip.test.ts` + +- [x] 10. Clean remaining test comments + + **What to do**: Edit `tests/hooks.test.ts`, `tests/content-validators.test.ts`, `tests/eslint-disable-blocker.test.ts`, `tests/package-exports.test.ts`, `tests/processing-core-hardening.test.ts`, `tests/tool-result-redaction.test.ts`, `tests/test-utils.ts` to: + - Remove temporal/AI-workflow/migration language from headers. + - Keep concise file headers and regression rationale. + - Simplify heavy banners. + + **Must NOT do**: Do not delete regression-rationale comments; do not change test behavior. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: YES | Wave 3 | Blocks: [11] | Blocked By: [2] + + **References**: + - `tests/eslint-disable-blocker.test.ts` — example user cited; header contains "Following parent project's incremental testing pattern". + - `tests/content-validators.test.ts` — contains "Phase 2D.6". + - `tests/test-utils.ts` — heavy banners and helper JSDoc were already stripped; verify consistency. + + **Acceptance Criteria**: + - [ ] Zero unreviewed banned-phrase matches remain in comments in these files. + - [ ] `pnpm run test:run` passes for the affected test files. + + **QA Scenarios**: + ``` + Scenario: Remaining test files are consistent + Tool: Bash + Steps: grep -niE "(Following|incremental|Phase|parent project|moved to|ported from)" tests/hooks.test.ts tests/content-validators.test.ts tests/eslint-disable-blocker.test.ts tests/package-exports.test.ts tests/processing-core-hardening.test.ts tests/tool-result-redaction.test.ts tests/test-utils.ts + Expected: Zero unreviewed matches in comments; documented API-behavior opt-outs allowed. + Evidence: .omo/evidence/test-group-3-grep.txt + ``` + + **Commit**: YES | Message: `style(tests): standardize remaining test file comments` | Files: `tests/hooks.test.ts`, `tests/content-validators.test.ts`, `tests/eslint-disable-blocker.test.ts`, `tests/package-exports.test.ts`, `tests/processing-core-hardening.test.ts`, `tests/tool-result-redaction.test.ts`, `tests/test-utils.ts` + +- [x] 11. Final verification wave + + **What to do**: + - Run the banned-phrase grep across all `src/` and `tests/` files; review each match to confirm it is either outside a comment or a documented API-behavior opt-out. Zero unreviewed slop matches may remain in comments. + - Run `pnpm run check`. + - Run `pnpm run test:run`. + - Run `pnpm run build`. + - Spot-check representative files from each group for visual consistency. + - Delete `.omo/evidence/` and any planning artifacts before final commit (per Public Repository Hygiene guardrail). + + **Must NOT do**: Do not leave `.omo/` artifacts in the public repo. + + **Recommended Agent Profile**: + - Category: `deep` + - Skills: `remove-ai-slops` + + **Parallelization**: Can Parallel: NO | Wave 4 | Blocks: none | Blocked By: [3-10] + + **References**: + - `AGENTS.md` `## Public Repository Hygiene` section. + + **Acceptance Criteria**: + - [ ] Banned-phrase grep returns zero unreviewed matches in comments. + - [ ] `pnpm run check` passes. + - [ ] `pnpm run test:run` passes with 1201 tests. + - [ ] `pnpm run build` passes. + - [ ] Working tree contains no `.omo/` artifacts. + + **QA Scenarios**: + ``` + Scenario: All quality gates pass + Tool: Bash + Steps: pnpm run check && pnpm run test:run && pnpm run build + Expected: All exit 0, tests pass. + Evidence: .omo/evidence/final-gates.txt + + Scenario: Banned phrases eliminated + Tool: Bash + Steps: grep -RniE "(Following|incremental|Phase [0-9]|recently|parent project|ported from|moved to|comprehensive|designed for|Use cases:)" src/ tests/ + Expected: Zero unreviewed matches in comments (documented opt-outs only). + Evidence: .omo/evidence/final-banned-grep.txt + ``` + + **Commit**: YES | Message: `chore(repo): verify comment consistency and clean work artifacts` | Files: all changed files; delete `.omo/evidence/` + +## Final Verification Wave (MANDATORY) +> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing. +- [x] F1. Plan Compliance Audit — oracle: APPROVED +- [x] F2. Code Quality Review — unspecified-high: APPROVED +- [x] F3. Real Manual QA — unspecified-high: APPROVED +- [x] F4. Scope Fidelity Check — deep: APPROVED + +## Commit Strategy +- One commit per wave group (source groups, test groups) to keep diffs reviewable. +- Final verification commit combines gate evidence and deletes `.omo/evidence/`. +- Do not squash everything into one commit; comment-only changes should be bisectable. + +## Success Criteria +- Every `src/` and `tests/` file follows the same comment style. +- Banned temporal/AI-workflow/migration/marketing phrases are eliminated from comments (with documented API-behavior opt-outs). +- Public API JSDoc is preserved and consistent. +- Quality gates are green. +- No `.omo/` work artifacts remain in the public repo. + +## Remaining Risks / Deferred +- **Oversized modules**: splitting files >250 pure LOC is still deferred; this plan does not address module size. +- **Docs Markdown files**: this plan focuses on `src/` and `tests/` only. Markdown docs were already cleaned in the previous pass and are outside scope unless the user asks. +- **Subjectivity**: some phrases may be valid API behavior descriptions. The banned-phrase grep must be reviewed manually, not blindly applied. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1dc04a..a7ea5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,32 @@ Categories per release: **Added**, **Changed**, **Deprecated**, **Removed**, **F --- -## [0.1.0] - 2026-06-24 +## [Unreleased] + +### Changed + +- Published Claude Code API parity notes now cover `Setup` and `MessageDisplay`. +- Documented `SessionStart` input/output parity updates. +- Documented `Notification` and `StopFailure` enum expansions. +- Documented support for `CommandHookHandler.args`, `PostToolUseOutput.updatedToolOutput`, `PostToolUseInput.duration_ms`, `PostToolUseFailureInput.duration_ms`, `BaseHookInput.effort`, and `BaseHookOutput.terminalSequence`. +- Documented `PostToolUseOutput.updatedMCPToolOutput` widening to `unknown`. -First public release of `@libar-dev/agent-harness-kit`. +## [0.1.0] - 2026-06-24 -This is the first release under the new `agent-harness-kit` identity. +This is the first release candidate under the new `agent-harness-kit` identity. The package was renamed from `@libar-dev/claude-code-hooks` to `@libar-dev/agent-harness-kit`. +### Added + +- Added support for image content blocks in transcript processing. + ### Changed - Package name changed from `@libar-dev/claude-code-hooks` to `@libar-dev/agent-harness-kit`. +- Normalized npm bin paths in package metadata. +- Updated the repository URL to the public GitHub location. +- Added `publishConfig.access: public` for npm publishing. --- @@ -31,7 +46,7 @@ The package was renamed from `@libar-dev/claude-code-hooks` to > pre-publication development milestone under the old `@libar-dev/claude-code-hooks` > package name. -Pre-publication development milestone for `@libar-dev/claude-code-hooks` before the first public npm release. +Development milestone for `@libar-dev/claude-code-hooks` before the first public npm release. ### Added @@ -89,4 +104,3 @@ Pre-publication development milestone for `@libar-dev/claude-code-hooks` before `--unsafe-raw-unredacted` opt-in; without it, raw records are redacted. [0.1.0]: https://github.com/libar-dev/agent-harness-kit/releases/tag/v0.1.0 -[1.0.0]: https://github.com/libar-dev/claude-code-hooks/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index b5b4e22..aa6f9b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) when working in this repository. ## What This Is -A standalone TypeScript hooks library (`@libar-dev/agent-harness-kit`) for Claude Code. Hooks are command, HTTP, MCP tool, prompt, or agent handlers that execute at lifecycle points to provide deterministic control over Claude's behavior. The library covers all 28 hook events in the current official docs. +A standalone TypeScript hooks library (`@libar-dev/agent-harness-kit`) for Claude Code. Hooks are command, HTTP, MCP tool, prompt, or agent handlers that run at lifecycle points. The library covers all 30 hook events in the current official docs. Official docs (mirrored upstream): `docs/upstream/hooks-guide.md`, `docs/upstream/hooks-reference.md` @@ -30,7 +30,7 @@ pnpm run hook:test:session # Session start ## Absolute Rule: No `any` Types -`any` is forbidden everywhere. Use `unknown` with validation/type assertions instead. `noImplicitAny: true` is set in all tsconfig files. Do not weaken this. +`any` is forbidden. Use `unknown` with validation/type assertions instead. `noImplicitAny: true` is set in all tsconfig files. Do not weaken this. ```typescript // WRONG @@ -44,10 +44,10 @@ const bashInput = validateBashToolInput(input); // Returns typed BashToolInput **Hook I/O protocol**: JSON in via stdin, JSON out via stdout. Exit codes: 0 (success), 1 (non-blocking error), 2 (blocking error). `WorktreeCreate` treats any non-zero exit as a creation failure. -**28 hook events**: SessionStart, UserPromptSubmit, UserPromptExpansion, PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, Elicitation, ElicitationResult, SessionEnd. +**30 hook events**: Setup, SessionStart, UserPromptSubmit, UserPromptExpansion, PreToolUse, PermissionRequest, PermissionDenied, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, MessageDisplay, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, Stop, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, Elicitation, ElicitationResult, SessionEnd. **Key modules**: -- `src/types/index.ts` — All type definitions: hook I/O interfaces, tool input types, hook config types (`HookHandler`, `MatcherGroup`, `HooksConfig`), and `HookEnvironmentVars` +- `src/types/index.ts` — Type definitions: hook I/O interfaces, tool input types, hook config types (`HookHandler`, `MatcherGroup`, `HooksConfig`), and `HookEnvironmentVars` - `src/utils/index.ts` — Core I/O (`readStdinJson`, `outputJson`, `executeHook`), logging, config (`getConfig()` reads `CLAUDE_*` env vars) - `src/utils/output-builder.ts` — `HookOutputBuilder` with methods for all output patterns - `src/validation/` — Zod schemas (`schemas.ts`), validators (`validators.ts`), and re-exports (`index.ts`). Schema-first: define Zod schema -> infer types with `z.infer` -> validate at boundaries @@ -79,7 +79,7 @@ const bashInput = validateBashToolInput(input); // Returns typed BashToolInput ## Hook Handler Types -Settings validation supports all current official handler types: +Settings validation supports these handler types: ```json { @@ -181,7 +181,7 @@ import { validateHooksConfig } from '../validation/index.js'; const config = validateHooksConfig(parsed); // validates full settings hooks structure ``` -Config supports handler common fields `if`, `timeout`, `statusMessage`, and `once`. Command handlers additionally support `async`, `asyncRewake`, and `shell`. Settings-root restriction fields include `allowManagedHooksOnly`, `allowedHttpHookUrls`, and `httpHookAllowedEnvVars`. +Config supports common handler fields `if`, `timeout`, `statusMessage`, and `once`. Command handlers also support `async`, `asyncRewake`, and `shell`. Settings-root restriction fields include `allowManagedHooksOnly`, `allowedHttpHookUrls`, and `httpHookAllowedEnvVars`. ## Build System @@ -193,14 +193,14 @@ Config supports handler common fields `if`, `timeout`, `statusMessage`, and `onc ## Testing -- Tests live in `tests/` and run directly against `.ts` source files via Vitest. +- Tests live in `tests/` and run against `.ts` source files via Vitest. - Use test helpers from `tests/test-utils.ts` (`createPreToolUseInput`, `expectValidationError`, etc.). - All test inputs should go through Zod validation. - `tests/docs-round-trip.test.ts` validates parseable JSON hook examples from the mirrored official docs. It explicitly skips known pseudocode/commented JSON blocks and the generic official PreToolUse snippet that omits required `tool_use_id`. ## Config -All hook behavior is configurable through environment variables. The library reads: +Hook behavior is configurable through environment variables. The library reads: - Core/runtime: `CLAUDE_PROJECT_DIR`, `CLAUDE_ENV_FILE`, `CLAUDE_CODE_DEBUG_LOG_LEVEL`, `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS`, `CLAUDE_CODE_SYNC_PLUGIN_INSTALL`, `CLAUDE_HOOK_DEBUG`, `CLAUDE_HOOK_TIMEOUT` - Protection and command policy: `CLAUDE_HOOK_PROTECTED_FILES`, `CLAUDE_HOOK_DANGEROUS_COMMANDS`, `CLAUDE_HOOK_STRICT_PROTECTION`, `CLAUDE_HOOK_EXTRA_PROTECTED`, `CLAUDE_HOOK_READ_ONLY`, `CLAUDE_HOOK_AUTO_APPROVE_READS` @@ -217,6 +217,18 @@ docs separate. Set `CLAUDE_HOOK_DEBUG=true` or `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` for verbose logging. The default hook timeout is 60 seconds. `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` defaults to 1500 ms and is capped at 60000 ms. +## Public Repository Hygiene + +Planning and context files created for agent workflows are ephemeral and must not be committed to the public repo. Examples include `prometheus-implementation-context.md` and `.omo/notepads/*` scratch files. Delete them before merging. Persistent guidance belongs in user-facing docs or ADRs, not in agent-context scratchpads. + +## Comment Style + +- Preserve API-contract JSDoc on every exported type, interface, function, class, and method. Keep parameter, return, thrown-error, and behavior notes that public consumers rely on. +- Strip temporal, AI-workflow, migration, provenance, and marketing phrasing from comments. Avoid examples such as `Following ... pattern`, `incremental`, `Phase`, `recently`, `parent project`, `ported from`, `moved to`, `will`, `currently`, `now`, `new`, `modern`, `legacy`, `comprehensive`, and `designed for`. +- Treat filler wording as noise. Avoid `automatically` when it adds no technical detail, and avoid `supports` when the code, type, or API name already makes that clear. +- Keep comments that explain regression rationale, compatibility constraints, security-sensitive behavior, invariants, or non-obvious edge cases. +- Avoid heavy visual banners such as `// =====`, `// ----`, or long dashed separator lines. Prefer a single blank line between logical blocks. + ## Compatibility Notes `PermissionRequest` now uses nested `hookSpecificOutput.decision` with `behavior: "allow" | "deny"` and optional permission updates. The old top-level allow/deny style should not be used for new code. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7de5ab9..32191c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thank you for your interest in contributing to `@libar-dev/agent-harness-kit`. +Thanks for contributing to `@libar-dev/agent-harness-kit`. ## Getting Started @@ -21,7 +21,7 @@ pnpm run build # Compile src/ → dist/ (only needed before publishing) ## Code Rules -**No `any` types — ever.** This is an absolute rule enforced by `noImplicitAny: true`. Use `unknown` with a validator instead: +**No `any` types.** This is enforced by `noImplicitAny: true`. Use `unknown` with a validator instead: ```typescript // Wrong @@ -52,7 +52,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { ## Commit Style -Follow the existing convention: +Use the existing convention: ``` fix: short description of the bug fixed @@ -66,7 +66,7 @@ Keep the subject line under 72 characters. Reference issues in the body if appli ## Pull Request Checklist -Before opening a PR, confirm: +Before opening a PR: - [ ] `pnpm run check` passes with no errors or warnings - [ ] `pnpm run test:run` passes @@ -76,4 +76,4 @@ Before opening a PR, confirm: ## Architecture Notes -The high-level structure and the reasoning behind it lives in [docs/architecture/overview.md](docs/architecture/overview.md). The maintainer API-update checklist for tracking Claude Code API changes is in [docs/internal/api-update-checklist.md](docs/internal/api-update-checklist.md). +The high-level structure and the reasoning behind it lives in [docs/README.md](docs/README.md). The maintainer API-update checklist for tracking Claude Code API changes is in [docs/internal/api-update-checklist.md](docs/internal/api-update-checklist.md). diff --git a/README.md b/README.md index 04a6b55..42b34b9 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,22 @@ > **Today: a Claude Code toolkit. Future: harness-agnostic.** > -> This library currently targets [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) — all 28 events, strict types, Zod-validated inputs, and a fluent output builder. The long-term goal is to generalize the harness layer so the same validators, builders, and session tooling work across multiple agent platforms. Claude-specific names in the API (event names, CLI binaries) will remain stable; the package itself is being repositioned to reflect that broader ambition. +> This library currently targets [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). The long-term goal is to generalize the harness layer so the same validators, builders, and session tooling work across multiple agent platforms. Claude-specific API names (event names, CLI binaries) will remain stable. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![npm version](https://img.shields.io/badge/version-0.1.0-blue)](https://github.com/libar-dev/agent-harness-kit/releases) [![Node ≥22](https://img.shields.io/badge/node-%3E%3D22-green)](package.json) -TypeScript library for [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) with strict types, Zod-validated inputs, and a fluent output builder for all 28 hook events. +TypeScript library for [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) with strict types, Zod-validated inputs, and a fluent output builder for all 30 hook events. **Why use this instead of raw shell scripts?** -Writing hook output JSON by hand is error-prone — field names, nesting, and event-specific shapes vary across 28 event types. This library validates your inputs at the boundary and produces the right output shapes via `HookOutputBuilder`, so you write logic, not plumbing. +Writing hook output JSON by hand is error-prone: field names, nesting, and event-specific shapes vary across 30 event types. This library validates inputs at the boundary and produces the right output shapes via `HookOutputBuilder`, so you write logic, not plumbing. It also ships session-processing CLIs: export sessions to clean markdown/JSONL, and tail a live session JSONL as structured blocks for downstream ingestion. Retained tool-result bodies are secret-redacted by default (API keys, tokens, passwords, URL credentials). ## Install -Runtime support targets Node.js 22 and newer. The library is developed, type-checked, and CI-tested on a Node 24 baseline so maintainers and contributors see the current typings/tooling surface without overstating the consumer runtime floor. +Runtime support targets Node.js 22 and newer. Development, type-checking, and CI use a Node 24 baseline without raising the consumer runtime floor. ```bash pnpm add @libar-dev/agent-harness-kit @@ -74,12 +74,12 @@ echo '{"hook_event_name":"PreToolUse","session_id":"s1","transcript_path":"/tmp/ | Module | Contents | |--------|----------| -| `@libar-dev/agent-harness-kit/types` | TypeScript types for all 28 events + `HookOutputBuilder` | +| `@libar-dev/agent-harness-kit/types` | TypeScript types for all 30 events + `HookOutputBuilder` | | `@libar-dev/agent-harness-kit/utils` | `executeHook`, `outputJson`, `isProtectedFile`, `isDangerousCommand`, logging | | `@libar-dev/agent-harness-kit/validation` | Zod schemas, per-event validators, 15 tool-input validators, `validateHooksConfig` | | `@libar-dev/agent-harness-kit/pre-tool-use` | Reference handlers: bash validator, file protector, ESLint-disable blocker | | `@libar-dev/agent-harness-kit/post-tool-use` | Reference handlers: Prettier formatter, TypeScript checker | -| `@libar-dev/agent-harness-kit/lifecycle` | Reference handlers: session start/end, notifications, stop, subagents, elicitation | +| `@libar-dev/agent-harness-kit/lifecycle` | Reference handlers: setup, session start/end, notifications, message display, stop, subagents, elicitation | ## Documentation @@ -87,7 +87,7 @@ echo '{"hook_event_name":"PreToolUse","session_id":"s1","transcript_path":"/tmp/ - **[Writing Your First Hook](docs/guides/writing-your-first-hook.md)** — `executeHook` skeleton, validators, `permission()`, testing - **[Configuring settings.json](docs/guides/configuring-settings-json.md)** — 5 handler types, matcher syntax, `if`/`timeout`/`async` - **[Cookbook](docs/guides/cookbook.md)** — 10 copy-pasteable recipes -- **[Hook Events Reference](docs/reference/hook-events.md)** — all 28 events with input/output shapes +- **[Hook Events Reference](docs/reference/hook-events.md)** — all 30 events with input/output shapes - **[HookOutputBuilder Reference](docs/reference/output-builder.md)** — every method with examples - **[Validators Reference](docs/reference/validators.md)** — tool-input validators, type guards, config validators - **[Environment Variables](docs/reference/environment-variables.md)** — all `CLAUDE_HOOK_*` vars @@ -109,7 +109,7 @@ pnpm run build # compile src/ → dist/ (publish only) ## Status -Version `0.1.0` is not yet published to npm. The public API is stable but may change before the first published release. Pin to a commit hash if you depend on this from another project. +Version `0.1.0` is the initial npm release candidate. The public API is stable but may change before the first published release. Pin to a commit hash if you depend on this from another project. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index b621393..6872dcc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported Versions -This library is pre-1.0-stable in practice and published from the `main` branch. Security -fixes are applied to the latest released version. Older versions are not maintained. +This library is a pre-1.0 release candidate published from the `main` branch. Security +fixes apply to the latest released version. Older versions are not maintained. | Version | Supported | | ------- | ------------------ | @@ -27,14 +27,14 @@ public disclosure. ## Scope This is a TypeScript library that processes Claude Code hook I/O and session transcripts. -Particularly relevant areas: +Relevant areas: - **Untrusted transcript content** parsed by the `processing/` and tail subsystems (JSONL validation, secret redaction, regex safety). - **Hook input validation** in `validation/` (Zod schemas at trust boundaries). **Known limitation — secret redaction scope.** Redaction covers tool-**result** -bodies and error strings (Bash stdout, file contents, diffs, and the like). It +bodies and error strings (Bash stdout, file contents, diffs, etc.). It does **not** redact tool-**call inputs** — for example a Bash command such as `export API_KEY=...`, or a URL with embedded credentials passed as a tool argument — nor the one-line tool-call summaries derived from those inputs. @@ -42,4 +42,4 @@ Consumers should treat tool-call inputs as potentially containing secrets and handle them accordingly. The `--format raw-records` path is unredacted by design and is gated behind an explicit `--unsafe-raw-unredacted` opt-in. -When reporting, noting which subsystem is involved helps us triage quickly. +When reporting, include the subsystem involved if known. diff --git a/docs/README.md b/docs/README.md index a21a4de..046c7cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,6 @@ `@libar-dev/agent-harness-kit` developer documentation. ---- - ## Guides — Start Here | Guide | What it covers | @@ -14,20 +12,16 @@ | [Cookbook](guides/cookbook.md) | 10 copy-pasteable recipes for common hook patterns | | [Troubleshooting](guides/troubleshooting.md) | Hook not firing, exit codes, debug logging, Zod errors | ---- - ## API Reference | Reference | What it covers | |-----------|----------------| -| [Hook Events](reference/hook-events.md) | All 28 events — input fields, output shape, builder method | +| [Hook Events](reference/hook-events.md) | All 30 events — input fields, output shape, builder method | | [HookOutputBuilder](reference/output-builder.md) | Every method with signature and examples | | [Validators](reference/validators.md) | Tool-input validators, type guards, content validators, config validators | | [Types](reference/types.md) | Full type catalogue — inputs, outputs, tools, config | | [Environment Variables](reference/environment-variables.md) | Every `CLAUDE_HOOK_*` variable with type, default, and description | ---- - ## Architecture & Internal | Doc | Audience | @@ -36,11 +30,9 @@ | [Export Sessions Script](internal/export-sessions.md) | How the export-sessions utility works | | [Tail Sessions Script](internal/tail-session.md) | How the tail-session CLI works (modes, flags, marker offsets) | ---- - ## Upstream Reference (Mirrored) -Official Anthropic documentation mirrored for offline development use. Always check the [official docs](https://docs.anthropic.com/en/docs/claude-code/hooks) for the canonical, up-to-date version. +Official Claude Code documentation mirrored for offline development use. Always check the [official docs](https://code.claude.com/docs/en/overview) for the canonical, up-to-date version. | Doc | Source | |-----|--------| diff --git a/docs/guides/cookbook.md b/docs/guides/cookbook.md index 5310a0f..eb362c5 100644 --- a/docs/guides/cookbook.md +++ b/docs/guides/cookbook.md @@ -2,8 +2,6 @@ Ten copy-pasteable recipes. Each shows the hook script, the `settings.json` registration, and relevant `HookOutputBuilder` methods. All examples assume `tsx` for running TypeScript hooks directly. ---- - ## 1. Deny dangerous Bash commands **Event:** `PreToolUse` | **Handler type:** `command` @@ -41,8 +39,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`permission('deny', reason)`](../reference/output-builder.md#permission) ---- - ## 2. Protect sensitive files from Write/Edit **Event:** `PreToolUse` | **Handler type:** `command` @@ -82,8 +78,6 @@ Default protected patterns: `.env`, `.env.local`, `.env.production`, `.git/**`, **Builder method:** [`permission('deny', reason)`](../reference/output-builder.md#permission) ---- - ## 3. Inject project context at SessionStart **Event:** `SessionStart` | **Handler type:** `command` @@ -119,8 +113,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`sessionStartContext(context)`](../reference/output-builder.md#sessionstartcontext) ---- - ## 4. Auto-format edited files on PostToolUse **Event:** `PostToolUse` | **Handler type:** `command` @@ -165,8 +157,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`feedback(reason, additionalContext?)`](../reference/output-builder.md#feedback) ---- - ## 5. TypeScript-check edited files on PostToolUse **Event:** `PostToolUse` | **Handler type:** `command` @@ -215,8 +205,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`feedback(reason, additionalContext?)`](../reference/output-builder.md#feedback) ---- - ## 6. Block prompts containing secrets **Event:** `UserPromptSubmit` | **Handler type:** `command` @@ -254,8 +242,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`blockPrompt(reason)`](../reference/output-builder.md#blockprompt) ---- - ## 7. Add context to every user prompt **Event:** `UserPromptSubmit` | **Handler type:** `command` @@ -289,8 +275,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`addContext(context)`](../reference/output-builder.md#addcontext) ---- - ## 8. Custom worktree path for WorktreeCreate **Event:** `WorktreeCreate` | **Handler type:** `command` @@ -307,7 +291,6 @@ import * as path from 'path'; import * as os from 'os'; async function hook(input: WorktreeCreateInput): Promise { - // Place worktrees in ~/worktrees/ const worktreePath = path.join(os.homedir(), 'worktrees', input.name); outputJson(HookOutputBuilder.worktreePath(worktreePath)); } @@ -329,8 +312,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`worktreePath(absolutePath)`](../reference/output-builder.md#worktreepath) ---- - ## 9. Respond to MCP Elicitation programmatically **Event:** `Elicitation` | **Handler type:** `command` @@ -340,7 +321,6 @@ Auto-accepts form-mode elicitation requests from a known MCP server instead of p ```typescript // .claude/hooks/elicitation-auto.ts #!/usr/bin/env tsx -// Based on examples/elicitation-responder.ts import { executeHook, outputJson } from '@libar-dev/agent-harness-kit/utils'; import { HookOutputBuilder, type ElicitationInput } from '@libar-dev/agent-harness-kit/types'; @@ -349,7 +329,6 @@ import { validateElicitationInput } from '@libar-dev/agent-harness-kit/validatio async function hook(input: ElicitationInput): Promise { const elicitation = validateElicitationInput(input); - // Only auto-respond to a specific MCP server's form-mode requests if (elicitation.mcp_server_name !== 'my_trusted_server') return; if (elicitation.mode !== 'form') return; @@ -371,8 +350,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { **Builder method:** [`elicitation(action, content?, hookEventName?)`](../reference/output-builder.md#elicitation) ---- - ## 10. Send a desktop notification when Claude stops **Event:** `Stop` | **Handler type:** `command` @@ -414,11 +391,9 @@ if (import.meta.url === `file://${process.argv[1]}`) { Using `"async": true` so Claude doesn't wait for the notification before continuing. ---- - ## See Also - [HookOutputBuilder Reference](../reference/output-builder.md) — full method signatures -- [Hook Events Reference](../reference/hook-events.md) — all 28 events with input shapes +- [Hook Events Reference](../reference/hook-events.md) — all 30 events with input shapes - [Validators Reference](../reference/validators.md) — all tool-input validators - [Environment Variables](../reference/environment-variables.md) — configure defaults without code changes diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index ee5ea6f..b85ca91 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -2,7 +2,7 @@ `@libar-dev/agent-harness-kit` is a TypeScript library for writing [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). It provides: -- **Type-safe input** for all 28 hook events via Zod-validated interfaces. +- **Type-safe input** for all 30 hook events via Zod-validated interfaces. - **`HookOutputBuilder`** — a fluent builder that produces the correct JSON output shape for every hook event without you needing to remember the nested structure. - **Reference implementations** — production-ready handlers for bash validation, file protection, auto-formatting, session management, and more. - **`executeHook()`** — a runner that handles stdin/stdout, Zod validation, and exit codes for you. @@ -131,5 +131,5 @@ The package exposes several sub-paths so you only import what you need: - **[Writing Your First Hook](writing-your-first-hook.md)** — end-to-end walkthrough building a real Bash command validator. - **[Configuring settings.json](configuring-settings-json.md)** — all five handler types, matcher syntax, and timing fields. - **[Cookbook](cookbook.md)** — ten copy-pasteable recipes for common hook patterns. -- **[Hook Events Reference](../reference/hook-events.md)** — all 28 events with input/output shapes. +- **[Hook Events Reference](../reference/hook-events.md)** — all 30 events with input/output shapes. - **[HookOutputBuilder Reference](../reference/output-builder.md)** — every method with signatures and examples. diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index abb83d7..02865e7 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -2,7 +2,7 @@ ## Hook didn't fire -**1. Check the event name.** Verify the key in `settings.json` is spelled exactly as one of the 28 event names (case-sensitive: `PreToolUse`, not `pre_tool_use`). +**1. Check the event name.** Verify the key in `settings.json` is spelled exactly as one of the 30 event names (case-sensitive: `PreToolUse`, not `pre_tool_use`). **2. Check the matcher.** The `matcher` field is a regex applied to `tool_name` (for tool events) or equivalent primary identifier. An empty matcher or omitting it matches everything. Test your regex: @@ -25,8 +25,6 @@ Hooks in a project settings file only fire when Claude Code is run from that pro node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json', 'utf8'))" && echo "Valid" ``` ---- - ## Hook fired but produced no effect **1. Check the exit code.** Exit code 0 means success. Exit codes 1 and 2 signal errors (see below). If your script exits non-zero, Claude Code may ignore its output. @@ -39,8 +37,6 @@ node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json', 'utf8')) echo '' | tsx .claude/hooks/my-hook.ts | jq . ``` ---- - ## Exit code semantics | Exit code | Meaning | Effect | @@ -55,8 +51,6 @@ echo '' | tsx .claude/hooks/my-hook.ts | jq . - Errors with `BLOCK` or `DENY` in the message → exit code 2 - All other errors → exit code 1 ---- - ## Enabling verbose logging Set one of these before running Claude Code: @@ -78,8 +72,6 @@ You can also capture your hook's stderr directly: echo '' | tsx .claude/hooks/my-hook.ts 2>&1 ``` ---- - ## Zod validation errors When a hook input fails schema validation, `executeHook` throws with a message like: @@ -98,8 +90,6 @@ The `path` field tells you which field is wrong. Common causes: Use `safeValidateHookInput` if you want validation failures to return `null` instead of throwing. ---- - ## "No `any` types" TypeScript build failure The library enforces `noImplicitAny: true`. If you see errors like: @@ -121,8 +111,6 @@ const command = bash.command; // string See [Validators Reference](../reference/validators.md) for all available validators. ---- - ## Hook times out Default timeout is **60 seconds** for command and HTTP hooks. If your hook does slow work (network calls, large TypeScript checks), increase it: @@ -135,8 +123,6 @@ Or override the default globally with `CLAUDE_HOOK_TIMEOUT=120`. For `SessionEnd`, the total budget for all hooks combined defaults to **1500 ms** (hard cap: 60000 ms). Increase with `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=30000`. ---- - ## Circular continuation (Stop hook loops) A `Stop` hook that returns `decision: 'block'` causes Claude to continue. If Claude then stops again and the hook fires again, you can get infinite loops. Guard against this with `stop_hook_active`: diff --git a/docs/guides/writing-your-first-hook.md b/docs/guides/writing-your-first-hook.md index 825d2e7..903d7dd 100644 --- a/docs/guides/writing-your-first-hook.md +++ b/docs/guides/writing-your-first-hook.md @@ -223,4 +223,4 @@ throw createBlockingError('This must not proceed'); - **[Cookbook](cookbook.md)** — recipes for PostToolUse, SessionStart, UserPromptSubmit, and other events. - **[HookOutputBuilder Reference](../reference/output-builder.md)** — every method, every event. -- **[Hook Events Reference](../reference/hook-events.md)** — all 28 events with their input shapes. +- **[Hook Events Reference](../reference/hook-events.md)** — all 30 events with their input shapes. diff --git a/docs/reference/hook-events.md b/docs/reference/hook-events.md index da69cf6..a407b15 100644 --- a/docs/reference/hook-events.md +++ b/docs/reference/hook-events.md @@ -1,11 +1,9 @@ # Hook Events Reference -All 28 Claude Code hook events. For each event: when it fires, its input fields, the output it accepts, and the `HookOutputBuilder` method to use. +All 30 Claude Code hook events. For each event: when it fires, its input fields, the output it accepts, and the `HookOutputBuilder` method to use. **Source of truth for types:** [`src/types/index.ts`](../../src/types/index.ts) ---- - ## Table of Contents **Tool Lifecycle** @@ -15,13 +13,13 @@ All 28 Claude Code hook events. For each event: when it fires, its input fields, - [PermissionRequest](#permissionrequest) · [PermissionDenied](#permissiondenied) **User Interaction** -- [UserPromptSubmit](#userpromptsubmit) · [UserPromptExpansion](#userpromptexpansion) · [Notification](#notification) · [Elicitation](#elicitation) · [ElicitationResult](#elicitationresult) +- [UserPromptSubmit](#userpromptsubmit) · [UserPromptExpansion](#userpromptexpansion) · [Notification](#notification) · [MessageDisplay](#messagedisplay) · [Elicitation](#elicitation) · [ElicitationResult](#elicitationresult) **Subagents & Teams** - [SubagentStart](#subagentstart) · [SubagentStop](#subagentstart) · [TeammateIdle](#teammateidle) · [TaskCreated](#taskcreated) · [TaskCompleted](#taskcompleted) **Session Lifecycle** -- [SessionStart](#sessionstart) · [Stop](#stop) · [StopFailure](#stopfailure) · [SessionEnd](#sessionend) +- [Setup](#setup) · [SessionStart](#sessionstart) · [Stop](#stop) · [StopFailure](#stopfailure) · [SessionEnd](#sessionend) **Instructions & Config** - [InstructionsLoaded](#instructionsloaded) · [ConfigChange](#configchange) @@ -32,8 +30,6 @@ All 28 Claude Code hook events. For each event: when it fires, its input fields, **Compaction** - [PreCompact](#precompact) · [PostCompact](#postcompact) ---- - ## Base Fields Every event's input includes these fields (from `BaseHookInput`): @@ -57,8 +53,6 @@ Base output fields (from `BaseHookOutput`, applicable to all events): | `suppressOutput` | `boolean` | `false` | Hide stdout from transcript mode | | `systemMessage` | `string` | — | Optional warning shown to the user | ---- - ## Tool Lifecycle ### PreToolUse @@ -109,8 +103,6 @@ outputJson(HookOutputBuilder.permission('allow', 'Redirected to safe path', { })); ``` ---- - ### PostToolUse **When it fires:** After a tool call succeeds. Used to run formatters, type-checkers, or feed observations back to Claude. @@ -147,8 +139,6 @@ outputJson(HookOutputBuilder.feedback('Formatted file with Prettier')); outputJson(HookOutputBuilder.feedback('TypeScript errors found', tscOutput)); ``` ---- - ### PostToolUseFailure **When it fires:** After a tool call fails (error returned). Provides additional context about the failure to Claude. @@ -169,8 +159,6 @@ outputJson(HookOutputBuilder.feedback('TypeScript errors found', tscOutput)); **Builder method:** `HookOutputBuilder.feedback(reason, additionalContext?)` ---- - ### PostToolBatch **When it fires:** After a batch of parallel tool calls completes (one notification per batch, not per tool). @@ -198,8 +186,6 @@ outputJson(HookOutputBuilder.feedback('TypeScript errors found', tscOutput)); **Builder method:** `HookOutputBuilder.batchBlock(reason)` ---- - ## Permissions ### PermissionRequest @@ -253,8 +239,6 @@ outputJson(HookOutputBuilder.denyPermission({ message: 'Not allowed in this proj outputJson(HookOutputBuilder.permissionRequestSetMode('auto', 'session')); ``` ---- - ### PermissionDenied **When it fires:** When auto mode denies a tool call. The hook can tell Claude whether to retry. @@ -283,8 +267,6 @@ outputJson(HookOutputBuilder.permissionRequestSetMode('auto', 'session')); **Builder method:** `HookOutputBuilder.permissionDeniedRetry(retry)` ---- - ## User Interaction ### UserPromptSubmit @@ -319,8 +301,6 @@ outputJson(HookOutputBuilder.addContext('Current date: 2026-04-24')); outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); ``` ---- - ### UserPromptExpansion **When it fires:** Before a slash command or MCP prompt expands. Can add context or block expansion. @@ -337,8 +317,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** Same shape as `UserPromptSubmitOutput` but `hookEventName: 'UserPromptExpansion'`. ---- - ### Notification **When it fires:** When Claude Code sends a notification to the user (permission prompt, idle, auth, elicitation dialog). @@ -355,7 +333,36 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** `NotificationOutput` — can add `additionalContext`. No decision control. ---- +### MessageDisplay + +**When it fires:** While assistant text is streaming. The hook can override the currently rendered chunk content. + +**Input** (`MessageDisplayInput`): + +| Field | Type | Description | +|-------|------|-------------| +| `turn_id` | `string` | Unique identifier for the current turn | +| `message_id` | `string` | Unique identifier for the message being displayed | +| `index` | `number` | Zero-based chunk index for this display delta | +| `final` | `boolean` | Whether this is the final chunk | +| `delta` | `string` | Delta text being displayed | + +**Output** (`MessageDisplayOutput`): + +```typescript +{ + hookSpecificOutput: { + hookEventName: 'MessageDisplay', + displayContent?: string, + } +} +``` + +**Builder method:** `HookOutputBuilder.messageDisplayContent(content)` + +```typescript +outputJson(HookOutputBuilder.messageDisplayContent(validatedInput.delta)); +``` ### Elicitation @@ -386,8 +393,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.elicitation(action, content?, hookEventName?)` ---- - ### ElicitationResult **When it fires:** After the user responds to an elicitation request. Allows the hook to observe or override the result. @@ -404,8 +409,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** Same as Elicitation. Pass `hookEventName: 'ElicitationResult'` to the builder. ---- - ## Subagents & Teams ### SubagentStart @@ -425,8 +428,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.subagentContext(context)` ---- - ### SubagentStop **When it fires:** When a subagent completes (or is stopped). @@ -437,8 +438,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.subagentStopContext(reason)` ---- - ### TeammateIdle **When it fires:** When a teammate in a multi-agent team is about to go idle. @@ -454,8 +453,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.teammateStop(reason)` (sets `continue: false`) ---- - ### TaskCreated **When it fires:** When a task is being created. @@ -474,8 +471,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.taskBlock(reason, 'TaskCreated')` ---- - ### TaskCompleted **When it fires:** When a task is being marked as completed. Exit code only (no JSON decision control). @@ -484,10 +479,35 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.taskBlock(reason, 'TaskCompleted')` ---- - ## Session Lifecycle +### Setup + +**When it fires:** During init-only or maintenance mode before the main session lifecycle begins. + +**Input** (`SetupInput`): + +| Field | Type | Description | +|-------|------|-------------| +| `trigger` | `'init' \| 'maintenance'` | How setup was triggered | + +**Output** (`SetupOutput`): + +```typescript +{ + hookSpecificOutput: { + hookEventName: 'Setup', + additionalContext?: string, + } +} +``` + +**Builder method:** `HookOutputBuilder.setupContext(context)` + +```typescript +outputJson(HookOutputBuilder.setupContext('Repository bootstrap complete')); +``` + ### SessionStart **When it fires:** At the beginning of every session (startup, resume, clear, compact). @@ -506,8 +526,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.sessionStartContext(context)` ---- - ### Stop **When it fires:** When Claude finishes responding (end of turn). @@ -523,8 +541,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.subagentStopContext(reason)` (sets `decision: 'block'`) ---- - ### StopFailure **When it fires:** When a turn ends due to an API error. @@ -539,8 +555,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.stopFailureLog(systemMessage?)` ---- - ### SessionEnd **When it fires:** When a session ends. @@ -555,8 +569,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Total timeout:** `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` (default 1500 ms, max 60000 ms). ---- - ## Instructions & Config ### InstructionsLoaded @@ -576,8 +588,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** None (observability only). ---- - ### ConfigChange **When it fires:** When Claude Code settings change at runtime. @@ -591,8 +601,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output** (`ConfigChangeOutput`): `decision: 'block'` + `reason` to reject the change. ---- - ## File System ### CwdChanged @@ -610,8 +618,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.watchPaths(paths)` ---- - ### FileChanged **When it fires:** When a watched file changes (add, change, or unlink). @@ -627,8 +633,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** Same as `CwdChanged` — can update the watch path list. ---- - ### WorktreeCreate **When it fires:** When a git worktree is being created. @@ -645,8 +649,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Builder method:** `HookOutputBuilder.worktreePath(absolutePath)` ---- - ### WorktreeRemove **When it fires:** When a git worktree is being removed. @@ -659,8 +661,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output:** None (observability only). ---- - ## Compaction ### PreCompact @@ -676,8 +676,6 @@ outputJson(HookOutputBuilder.sessionTitle('Feature: auth refactor')); **Output** (`PreCompactOutput`): `decision: 'block'` to prevent compaction, or `additionalContext` to inject into the compaction. ---- - ### PostCompact **When it fires:** After compaction completes. diff --git a/docs/reference/types.md b/docs/reference/types.md index 28d3dbb..7a35bcb 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -4,23 +4,19 @@ Public TypeScript types exported by `@libar-dev/agent-harness-kit/types`. **Source:** [`src/types/index.ts`](../../src/types/index.ts) ---- - ## Base Types | Type | Description | |------|-------------| | `BaseHookInput` | Common fields in every hook input (`session_id`, `transcript_path`, `cwd`, `hook_event_name`, `permission_mode`, `agent_id`, `agent_type`) | | `BaseHookOutput` | Common output fields (`continue`, `stopReason`, `suppressOutput`, `systemMessage`) | -| `HookInput` | Union of all 28 per-event input types | +| `HookInput` | Union of all 30 per-event input types | | `HookOutput` | Union of all per-event output types | | `PermissionMode` | `'default' \| 'plan' \| 'acceptEdits' \| 'auto' \| 'dontAsk' \| 'bypassPermissions'` | | `PermissionUpdateEntry` | `{ type: string; [key: string]: unknown }` — used in permission update arrays | | `ElicitationAction` | `'accept' \| 'decline' \| 'cancel'` | | `ElicitationMode` | `'form' \| 'url'` | ---- - ## Per-Event Input Types All extend `BaseHookInput`. @@ -50,6 +46,7 @@ All extend `BaseHookInput`. | `UserPromptSubmitInput` | `prompt` | | `UserPromptExpansionInput` | `expansion_type`, `command_name`, `command_args`, `command_source`, `prompt` | | `NotificationInput` | `message`, `title?`, `notification_type` | +| `MessageDisplayInput` | `turn_id`, `message_id`, `index`, `final`, `delta` | | `ElicitationInput` | `mcp_server_name`, `message`, `mode?`, `requested_schema?`, `url?`, `elicitation_id?` | | `ElicitationResultInput` | `mcp_server_name`, `action`, `content?`, `mode?`, `elicitation_id?` | @@ -67,6 +64,7 @@ All extend `BaseHookInput`. | Type | Key additional fields | |------|----------------------| +| `SetupInput` | `trigger` | | `SessionStartInput` | `source`, `model`, `agent_type?` | | `SessionEndInput` | `reason` | | `StopInput` | `stop_hook_active`, `last_assistant_message?` | @@ -95,8 +93,6 @@ All extend `BaseHookInput`. | `PreCompactInput` | `trigger`, `custom_instructions` | | `PostCompactInput` | `trigger`, `compact_summary` | ---- - ## Per-Event Output Types All extend `BaseHookOutput`. @@ -112,7 +108,9 @@ All extend `BaseHookOutput`. | `UserPromptSubmitOutput` | Block prompt or add context/title | | `UserPromptExpansionOutput` | Block expansion or add context | | `NotificationOutput` | Add `additionalContext` | +| `MessageDisplayOutput` | Override the currently rendered message chunk | | `SubagentStartOutput` | Add `additionalContext` to subagent's system prompt | +| `SetupOutput` | Add `additionalContext` during setup | | `SessionStartOutput` | Add `additionalContext` to session | | `StopOutput` | Block stopping via `decision: 'block'` + `reason` | | `PreCompactOutput` | Block compaction or inject `additionalContext` | @@ -121,8 +119,6 @@ All extend `BaseHookOutput`. | `WorktreeCreateOutput` | Return custom `worktreePath` via `hookSpecificOutput` | | `ElicitationOutput` | Programmatic response via `hookSpecificOutput.action` | ---- - ## Tool Input Types | Type | Fields | @@ -143,8 +139,6 @@ All extend `BaseHookOutput`. | `MCPToolInput` | `Record` | | `TaskToolInput` | `prompt: string`, `description?`, `subagent_type?`, `model?` | ---- - ## Hook Handler Types (settings.json) | Type | Key fields | @@ -157,12 +151,10 @@ All extend `BaseHookOutput`. | `HookHandler` | Union of the five handler types (discriminated on `type`) | | `MatcherGroup` | `{ matcher?: string; hooks: HookHandler[] }` | | `HooksConfig` | `{ hooks?: Partial>; allowManagedHooksOnly?; allowedHttpHookUrls?; httpHookAllowedEnvVars? }` | -| `HookEventName` | Union of all 28 event name strings | +| `HookEventName` | Union of all 30 event name strings | All handler types share base fields: `timeout?`, `statusMessage?`, `once?`, `if?`. ---- - ## Environment and Config Types | Type | Description | @@ -172,8 +164,6 @@ All handler types share base fields: `timeout?`, `statusMessage?`, `once?`, `if? `HookEnvironmentVars` key fields: `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_REMOTE?`, `CLAUDE_ENV_FILE?` (SessionStart only), `CLAUDE_PLUGIN_ROOT?`, `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS?`, `CLAUDE_CODE_DEBUG_LOG_LEVEL?`. ---- - ## Utility ### `isHookType(input, eventName)` diff --git a/docs/upstream/README.md b/docs/upstream/README.md index 55022cb..fb3ec06 100644 --- a/docs/upstream/README.md +++ b/docs/upstream/README.md @@ -1,20 +1,26 @@ # Upstream Anthropic Documentation -These files are mirrored from the [Claude Code official documentation](https://docs.anthropic.com/en/docs/claude-code/hooks) for offline reference during development of this library. +These files are mirrored from the [Claude Code official documentation](https://code.claude.com/docs/en/overview) for offline reference during development of this library. **They are not maintained by this project.** For the canonical, up-to-date versions always refer to the official Anthropic documentation. +Refresh them from the raw markdown endpoints with: + +```bash +pnpm run docs:sync-upstream +``` + | File | Source | |------|--------| -| `hooks-reference.md` | Claude Code Hooks Reference | -| `hooks-guide.md` | Claude Code Hooks Guide | -| `settings.md` | Claude Code Settings Reference | -| `cli-reference.md` | Claude Code CLI Reference | -| `headless.md` | Claude Code Headless Mode | +| `hooks-reference.md` | `https://code.claude.com/docs/en/hooks.md` | +| `hooks-guide.md` | `https://code.claude.com/docs/en/hooks-guide.md` | +| `settings.md` | `https://code.claude.com/docs/en/settings.md` | +| `cli-reference.md` | `https://code.claude.com/docs/en/cli-reference.md` | +| `headless.md` | `https://code.claude.com/docs/en/headless.md` | diff --git a/docs/upstream/cli-reference.md b/docs/upstream/cli-reference.md index f82623e..c62f5f1 100644 --- a/docs/upstream/cli-reference.md +++ b/docs/upstream/cli-reference.md @@ -1,10 +1,3 @@ - - > ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further. @@ -17,26 +10,37 @@ You can start sessions, pipe content, resume conversations, and manage updates with these commands: -| Command | Description | Example | -| :------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------- | -| `claude` | Start interactive session | `claude` | -| `claude "query"` | Start interactive session with initial prompt | `claude "explain this project"` | -| `claude -p "query"` | Query via SDK, then exit | `claude -p "explain this function"` | -| `cat file \| claude -p "query"` | Process piped content | `cat logs.txt \| claude -p "explain"` | -| `claude -c` | Continue most recent conversation in current directory | `claude -c` | -| `claude -c -p "query"` | Continue via SDK | `claude -c -p "Check for type errors"` | -| `claude -r "" "query"` | Resume session by ID or name | `claude -r "auth-refactor" "Finish this PR"` | -| `claude update` | Update to latest version | `claude update` | -| `claude install [version]` | Install or reinstall the native binary. Accepts a version like `2.1.118`, or `stable` or `latest`. See [Install a specific version](/en/setup#install-a-specific-version) | `claude install stable` | -| `claude auth login` | Sign in to your Anthropic account. Use `--email` to pre-fill your email address, `--sso` to force SSO authentication, and `--console` to sign in with Anthropic Console for API usage billing instead of a Claude subscription | `claude auth login --console` | -| `claude auth logout` | Log out from your Anthropic account | `claude auth logout` | -| `claude auth status` | Show authentication status as JSON. Use `--text` for human-readable output. Exits with code 0 if logged in, 1 if not | `claude auth status` | -| `claude agents` | List all configured [subagents](/en/sub-agents), grouped by source | `claude agents` | -| `claude auto-mode defaults` | Print the built-in [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier rules as JSON. Use `claude auto-mode config` to see your effective config with settings applied | `claude auto-mode defaults > rules.json` | -| `claude mcp` | Configure Model Context Protocol (MCP) servers | See the [Claude Code MCP documentation](/en/mcp). | -| `claude plugin` | Manage Claude Code [plugins](/en/plugins). Alias: `claude plugins`. See [plugin reference](/en/plugins-reference#cli-commands-reference) for subcommands | `claude plugin install code-review@claude-plugins-official` | -| `claude remote-control` | Start a [Remote Control](/en/remote-control) server to control Claude Code from Claude.ai or the Claude app. Runs in server mode (no local interactive session). See [Server mode flags](/en/remote-control#start-a-remote-control-session) | `claude remote-control --name "My Project"` | -| `claude setup-token` | Generate a long-lived OAuth token for CI and scripts. Prints the token to the terminal without saving it. Requires a Claude subscription. See [Generate a long-lived token](/en/authentication#generate-a-long-lived-token) | `claude setup-token` | +| Command | Description | Example | +| :------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------- | +| `claude` | Start interactive session | `claude` | +| `claude "query"` | Start interactive session with initial prompt | `claude "explain this project"` | +| `claude -p "query"` | Query via SDK, then exit | `claude -p "explain this function"` | +| `cat file \| claude -p "query"` | Process piped content | `cat logs.txt \| claude -p "explain"` | +| `claude -c` | Continue most recent conversation in current directory | `claude -c` | +| `claude -c -p "query"` | Continue via SDK | `claude -c -p "Check for type errors"` | +| `claude -r "" "query"` | Resume session by ID or name | `claude -r "auth-refactor" "Finish this PR"` | +| `claude update` | Update to latest version | `claude update` | +| `claude install [version]` | Install or reinstall the native binary. Accepts a version like `2.1.118`, or `stable` or `latest`. See [Install a specific version](/en/setup#install-a-specific-version) | `claude install stable` | +| `claude auth login` | Sign in to your Anthropic account. Use `--email` to pre-fill your email address, `--sso` to force SSO authentication, and `--console` to sign in with Anthropic Console for API usage billing instead of a Claude subscription | `claude auth login --console` | +| `claude auth logout` | Log out from your Anthropic account | `claude auth logout` | +| `claude auth status` | Show authentication status as JSON. Use `--text` for human-readable output. Exits with code 0 if logged in, 1 if not | `claude auth status` | +| `claude agents` | Open [agent view](/en/agent-view) to monitor and dispatch parallel background sessions. Use `--cwd ` to show only sessions started under that directory, or `--json` to print active sessions as a JSON array for scripting (`--json --all` also includes completed background sessions). Pass `--permission-mode`, `--model`, `--effort`, or `--agent` to set [defaults for dispatched sessions](/en/agent-view#permission-mode-model-and-effort). Accepts `--settings`, `--add-dir`, `--plugin-dir`, and `--mcp-config` like the top-level `claude` command. Opening agent view requires an interactive terminal | `claude agents --json` | +| `claude attach ` | Attach to a [background session](/en/agent-view#manage-sessions-from-the-shell) in this terminal | `claude attach 7c5dcf5d` | +| `claude auto-mode defaults` | Print the built-in [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier rules as JSON. Use `claude auto-mode config` to see your effective config with settings applied | `claude auto-mode defaults > rules.json` | +| `claude daemon status` | Print the background-session [supervisor's](/en/agent-view#the-supervisor-process) state, version, socket directory, and worker count for diagnostics. Exits 1 if the supervisor isn't running | `claude daemon status` | +| `claude daemon stop --any` | Stop the background-session [supervisor](/en/agent-view#the-supervisor-process) and the sessions it hosts. Pass `--keep-workers` to leave background sessions running so the next supervisor reconnects to them. `--any` confirms stopping an on-demand supervisor, which is the default. Use this to recover from an [unresponsive supervisor](/en/agent-view#agent-view-says-the-background-service-did-not-respond) | `claude daemon stop --any --keep-workers` | +| `claude logs ` | Print recent output from a [background session](/en/agent-view#manage-sessions-from-the-shell) | `claude logs 7c5dcf5d` | +| `claude mcp` | Configure Model Context Protocol (MCP) servers | See the [Claude Code MCP documentation](/en/mcp). | +| `claude mcp login ` | {/* min-version: 2.1.186 */}Run a configured MCP server's OAuth flow without opening the interactive `/mcp` panel. Works for HTTP, SSE, and claude.ai connector servers. Add `--no-browser` over SSH to print the authorization URL instead of opening a browser, then paste the redirect URL back at the prompt. Requires Claude Code v2.1.186 or later. See [Authenticate from the command line](/en/mcp#authenticate-from-the-command-line) | `claude mcp login sentry` | +| `claude mcp logout ` | {/* min-version: 2.1.186 */}Clear stored OAuth credentials for an MCP server. Requires Claude Code v2.1.186 or later | `claude mcp logout sentry` | +| `claude plugin` | Manage Claude Code [plugins](/en/plugins). Alias: `claude plugins`. See [plugin reference](/en/plugins-reference#cli-commands-reference) for subcommands | `claude plugin install code-review@claude-plugins-official` | +| `claude project purge [path]` | Delete all local Claude Code state for a project: transcripts, task lists, debug logs, file-edit history, prompt history lines, and the project's entry in `~/.claude.json`. Omit `[path]` to pick from an interactive list. Flags: `--dry-run` to preview, `-y`/`--yes` to skip confirmation, `-i`/`--interactive` to confirm each item, `--all` for every project. See [Clear local data](/en/claude-directory#clear-local-data) | `claude project purge ~/work/repo --dry-run` | +| `claude remote-control` | Start a [Remote Control](/en/remote-control) server to control Claude Code from Claude.ai or the Claude app. Runs in server mode (no local interactive session). See [Server mode flags](/en/remote-control#start-a-remote-control-session) | `claude remote-control --name "My Project"` | +| `claude respawn ` | Restart a [background session](/en/agent-view#manage-sessions-from-the-shell), running or stopped, with its conversation intact. Use `--all` to restart every running session, e.g. to pick up an updated Claude Code binary | `claude respawn 7c5dcf5d` | +| `claude rm ` | Remove a [background session](/en/agent-view#manage-sessions-from-the-shell) from the list. The conversation transcript stays on your local machine, available through `claude --resume` | `claude rm 7c5dcf5d` | +| `claude setup-token` | Generate a long-lived OAuth token for CI and scripts. Prints the token to the terminal without saving it. Requires a Claude subscription. See [Generate a long-lived token](/en/authentication#generate-a-long-lived-token) | `claude setup-token` | +| `claude stop ` | Stop a [background session](/en/agent-view#manage-sessions-from-the-shell). Also accepts `claude kill` | `claude stop 7c5dcf5d` | +| `claude ultrareview [target]` | Run [ultrareview](/en/ultrareview#run-ultrareview-non-interactively) non-interactively. Prints findings to stdout and exits 0 on success or 1 on failure. Use `--json` for the raw payload and `--timeout ` to override the 30-minute default | `claude ultrareview 1234 --json` | If you mistype a subcommand, Claude Code suggests the closest match and exits without starting a session. For example, `claude udpate` prints `Did you mean claude update?`. @@ -44,70 +48,77 @@ If you mistype a subcommand, Claude Code suggests the closest match and exits wi Customize Claude Code's behavior with these command-line flags. `claude --help` does not list every flag, so a flag's absence from `--help` does not mean it is unavailable. -| Flag | Description | Example | -| :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------- | -| `--add-dir` | Add additional working directories for Claude to read and edit files. Grants file access; most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories. Validates each path exists as a directory | `claude --add-dir ../apps ../lib` | -| `--agent` | Specify an agent for the current session (overrides the `agent` setting) | `claude --agent my-custom-agent` | -| `--agents` | Define custom subagents dynamically via JSON. Uses the same field names as subagent [frontmatter](/en/sub-agents#supported-frontmatter-fields), plus a `prompt` field for the agent's instructions | `claude --agents '{"reviewer":{"description":"Reviews code","prompt":"You are a code reviewer"}}'` | -| `--allow-dangerously-skip-permissions` | Add `bypassPermissions` to the `Shift+Tab` mode cycle without starting in it. Lets you begin in a different mode like `plan` and switch to `bypassPermissions` later. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) | `claude --permission-mode plan --allow-dangerously-skip-permissions` | -| `--allowedTools` | Tools that execute without prompting for permission. See [permission rule syntax](/en/settings#permission-rule-syntax) for pattern matching. To restrict which tools are available, use `--tools` instead | `"Bash(git log *)" "Bash(git diff *)" "Read"` | -| `--append-system-prompt` | Append custom text to the end of the default system prompt | `claude --append-system-prompt "Always use TypeScript"` | -| `--append-system-prompt-file` | Load additional system prompt text from a file and append to the default prompt | `claude --append-system-prompt-file ./extra-rules.txt` | -| `--bare` | Minimal mode: skip auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md so scripted calls start faster. Claude has access to Bash, file read, and file edit tools. Sets [`CLAUDE_CODE_SIMPLE`](/en/env-vars). See [bare mode](/en/headless#start-faster-with-bare-mode) | `claude --bare -p "query"` | -| `--betas` | Beta headers to include in API requests (API key users only) | `claude --betas interleaved-thinking` | -| `--channels` | (Research preview) MCP servers whose [channel](/en/channels) notifications Claude should listen for in this session. Space-separated list of `plugin:@` entries. Requires Claude.ai authentication | `claude --channels plugin:my-notifier@my-marketplace` | -| `--chrome` | Enable [Chrome browser integration](/en/chrome) for web automation and testing | `claude --chrome` | -| `--continue`, `-c` | Load the most recent conversation in the current directory. Includes sessions that added this directory with `/add-dir` | `claude --continue` | -| `--dangerously-load-development-channels` | Enable [channels](/en/channels-reference#test-during-the-research-preview) that are not on the approved allowlist, for local development. Accepts `plugin:@` and `server:` entries. Prompts for confirmation | `claude --dangerously-load-development-channels server:webhook` | -| `--dangerously-skip-permissions` | Skip permission prompts. Equivalent to `--permission-mode bypassPermissions`. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) for what this does and does not skip | `claude --dangerously-skip-permissions` | -| `--debug` | Enable debug mode with optional category filtering (for example, `"api,hooks"` or `"!statsig,!file"`) | `claude --debug "api,mcp"` | -| `--debug-file ` | Write debug logs to a specific file path. Implicitly enables debug mode. Takes precedence over `CLAUDE_CODE_DEBUG_LOGS_DIR` | `claude --debug-file /tmp/claude-debug.log` | -| `--disable-slash-commands` | Disable all skills and commands for this session | `claude --disable-slash-commands` | -| `--disallowedTools` | Tools that are removed from the model's context and cannot be used | `"Bash(git log *)" "Bash(git diff *)" "Edit"` | -| `--effort` | Set the [effort level](/en/model-config#adjust-effort-level) for the current session. Options: `low`, `medium`, `high`, `xhigh`, `max`; available levels depend on the model. Session-scoped and does not persist to settings | `claude --effort high` | -| `--enable-auto-mode` | {/* max-version: 2.1.110 */}Removed in v2.1.111. Auto mode is now in the `Shift+Tab` cycle by default; use `--permission-mode auto` to start in it | `claude --permission-mode auto` | -| `--exclude-dynamic-system-prompt-sections` | Move per-machine sections from the system prompt (working directory, environment info, memory paths, git status) into the first user message. Improves prompt-cache reuse across different users and machines running the same task. Only applies with the default system prompt; ignored when `--system-prompt` or `--system-prompt-file` is set. Use with `-p` for scripted, multi-user workloads | `claude -p --exclude-dynamic-system-prompt-sections "query"` | -| `--fallback-model` | Enable automatic fallback to specified model when default model is overloaded (print mode only) | `claude -p --fallback-model sonnet "query"` | -| `--fork-session` | When resuming, create a new session ID instead of reusing the original (use with `--resume` or `--continue`) | `claude --resume abc123 --fork-session` | -| `--from-pr` | Resume sessions linked to a specific GitHub PR. Accepts a PR number or URL. Sessions are automatically linked when created via `gh pr create` | `claude --from-pr 123` | -| `--ide` | Automatically connect to IDE on startup if exactly one valid IDE is available | `claude --ide` | -| `--init` | Run initialization hooks and start interactive mode | `claude --init` | -| `--init-only` | Run initialization hooks and exit (no interactive session) | `claude --init-only` | -| `--include-hook-events` | Include all hook lifecycle events in the output stream. Requires `--output-format stream-json` | `claude -p --output-format stream-json --include-hook-events "query"` | -| `--include-partial-messages` | Include partial streaming events in output. Requires `--print` and `--output-format stream-json` | `claude -p --output-format stream-json --include-partial-messages "query"` | -| `--input-format` | Specify input format for print mode (options: `text`, `stream-json`) | `claude -p --output-format json --input-format stream-json` | -| `--json-schema` | Get validated JSON output matching a JSON Schema after agent completes its workflow (print mode only, see [structured outputs](/en/agent-sdk/structured-outputs)) | `claude -p --json-schema '{"type":"object","properties":{...}}' "query"` | -| `--maintenance` | Run maintenance hooks and start interactive mode | `claude --maintenance` | -| `--max-budget-usd` | Maximum dollar amount to spend on API calls before stopping (print mode only) | `claude -p --max-budget-usd 5.00 "query"` | -| `--max-turns` | Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default | `claude -p --max-turns 3 "query"` | -| `--mcp-config` | Load MCP servers from JSON files or strings (space-separated) | `claude --mcp-config ./mcp.json` | -| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet` or `opus`) or a model's full name | `claude --model claude-sonnet-4-6` | -| `--name`, `-n` | Set a display name for the session, shown in `/resume` and the terminal title. You can resume a named session with `claude --resume `.

[`/rename`](/en/commands) changes the name mid-session and also shows it on the prompt bar | `claude -n "my-feature-work"` | -| `--no-chrome` | Disable [Chrome browser integration](/en/chrome) for this session | `claude --no-chrome` | -| `--no-session-persistence` | Disable session persistence so sessions are not saved to disk and cannot be resumed (print mode only) | `claude -p --no-session-persistence "query"` | -| `--output-format` | Specify output format for print mode (options: `text`, `json`, `stream-json`) | `claude -p "query" --output-format json` | -| `--permission-mode` | Begin in a specified [permission mode](/en/permission-modes). Accepts `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, or `bypassPermissions`. Overrides `defaultMode` from settings files | `claude --permission-mode plan` | -| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode | `claude -p --permission-prompt-tool mcp_auth_tool "query"` | -| `--plugin-dir` | Load plugins from a directory for this session only. Each flag takes one path. Repeat the flag for multiple directories: `--plugin-dir A --plugin-dir B` | `claude --plugin-dir ./my-plugins` | -| `--print`, `-p` | Print response without interactive mode (see [Agent SDK documentation](/en/agent-sdk/overview) for programmatic usage details) | `claude -p "query"` | -| `--remote` | Create a new [web session](/en/claude-code-on-the-web) on claude.ai with the provided task description | `claude --remote "Fix the login bug"` | -| `--remote-control`, `--rc` | Start an interactive session with [Remote Control](/en/remote-control#start-a-remote-control-session) enabled so you can also control it from claude.ai or the Claude app. Optionally pass a name for the session | `claude --remote-control "My Project"` | -| `--remote-control-session-name-prefix ` | Prefix for auto-generated [Remote Control](/en/remote-control) session names when no explicit name is set. Defaults to your machine's hostname, producing names like `myhost-graceful-unicorn`. Set `CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX` for the same effect | `claude remote-control --remote-control-session-name-prefix dev-box` | -| `--replay-user-messages` | Re-emit user messages from stdin back on stdout for acknowledgment. Requires `--input-format stream-json` and `--output-format stream-json` | `claude -p --input-format stream-json --output-format stream-json --replay-user-messages` | -| `--resume`, `-r` | Resume a specific session by ID or name, or show an interactive picker to choose a session. Includes sessions that added this directory with `/add-dir` | `claude --resume auth-refactor` | -| `--session-id` | Use a specific session ID for the conversation (must be a valid UUID) | `claude --session-id "550e8400-e29b-41d4-a716-446655440000"` | -| `--setting-sources` | Comma-separated list of setting sources to load (`user`, `project`, `local`) | `claude --setting-sources user,project` | -| `--settings` | Path to a settings JSON file or a JSON string to load additional settings from | `claude --settings ./settings.json` | -| `--strict-mcp-config` | Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations | `claude --strict-mcp-config --mcp-config ./mcp.json` | -| `--system-prompt` | Replace the entire system prompt with custom text | `claude --system-prompt "You are a Python expert"` | -| `--system-prompt-file` | Load system prompt from a file, replacing the default prompt | `claude --system-prompt-file ./custom-prompt.txt` | -| `--teleport` | Resume a [web session](/en/claude-code-on-the-web) in your local terminal | `claude --teleport` | -| `--teammate-mode` | Set how [agent team](/en/agent-teams) teammates display: `auto` (default), `in-process`, or `tmux`. See [Choose a display mode](/en/agent-teams#choose-a-display-mode) | `claude --teammate-mode in-process` | -| `--tmux` | Create a tmux session for the worktree. Requires `--worktree`. Uses iTerm2 native panes when available; pass `--tmux=classic` for traditional tmux | `claude -w feature-auth --tmux` | -| `--tools` | Restrict which built-in tools Claude can use. Use `""` to disable all, `"default"` for all, or tool names like `"Bash,Edit,Read"` | `claude --tools "Bash,Edit,Read"` | -| `--verbose` | Enable verbose logging, shows full turn-by-turn output | `claude --verbose` | -| `--version`, `-v` | Output the version number | `claude -v` | -| `--worktree`, `-w` | Start Claude in an isolated [git worktree](/en/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees) at `/.claude/worktrees/`. If no name is given, one is auto-generated | `claude -w feature-auth` | +| Flag | Description | Example | +| :---------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | +| `--add-dir` | Add additional working directories for Claude to read and edit files. Grants file access; most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories. Validates each path exists as a directory. To persist these directories across sessions, set [`permissions.additionalDirectories`](/en/settings#permission-settings) in settings | `claude --add-dir ../apps ../lib` | +| `--advisor ` | {/* min-version: 2.1.98 */}Enable the server-side [advisor tool](/en/advisor) for this session with a model alias: `opus`, `sonnet`, or `fable` ({/* min-version: 2.1.170 */}v2.1.170+), or a full model ID. Takes precedence over the `advisorModel` setting for the session. Requires Claude Code v2.1.98 or later | `claude --advisor opus` | +| `--agent` | Specify an agent for the current session (overrides the `agent` setting) | `claude --agent my-custom-agent` | +| `--agents` | Define custom subagents dynamically via JSON. Uses the same field names as subagent [frontmatter](/en/sub-agents#supported-frontmatter-fields), plus a `prompt` field for the agent's instructions | `claude --agents '{"reviewer":{"description":"Reviews code","prompt":"You are a code reviewer"}}'` | +| `--allow-dangerously-skip-permissions` | Add `bypassPermissions` to the `Shift+Tab` mode cycle without starting in it. Lets you begin in a different mode like `plan` and switch to `bypassPermissions` later. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) | `claude --permission-mode plan --allow-dangerously-skip-permissions` | +| `--allowedTools`, `--allowed-tools` | Tools that execute without prompting for permission. See [permission rule syntax](/en/settings#permission-rule-syntax) for pattern matching. To restrict which tools are available, use `--tools` instead | `"Bash(git log *)" "Bash(git diff *)" "Read"` | +| `--append-system-prompt` | Append custom text to the end of the default system prompt | `claude --append-system-prompt "Always use TypeScript"` | +| `--append-system-prompt-file` | Load additional system prompt text from a file and append to the default prompt | `claude --append-system-prompt-file ./extra-rules.txt` | +| `--ax-screen-reader` | {/* min-version: 2.1.181 */}Render screen-reader friendly output: flat text without decorative borders or animations. Forces the classic renderer, so the [`tui`](/en/settings#available-settings) setting has no effect for the session. Takes precedence over [`CLAUDE_AX_SCREEN_READER`](/en/env-vars) and the [`axScreenReader`](/en/settings#available-settings) setting. Requires Claude Code v2.1.181 or later | `claude --ax-screen-reader` | +| `--bare` | Minimal mode: skip auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md so scripted calls start faster. Claude has access to Bash, file read, and file edit tools. Sets [`CLAUDE_CODE_SIMPLE`](/en/env-vars). See [bare mode](/en/headless#start-faster-with-bare-mode) | `claude --bare -p "query"` | +| `--betas` | Beta headers to include in API requests (API key users only) | `claude --betas interleaved-thinking` | +| `--bg` | Start the session as a [background agent](/en/agent-view) and return immediately. Prints the session ID and management commands. Combine with `--exec` to run a shell command as a background job instead of a Claude session, or with `--agent` to run a specific subagent | `claude --bg "investigate the flaky test"` | +| `--channels` | (Research preview) MCP servers whose [channel](/en/channels) notifications Claude should listen for in this session. Space-separated list of `plugin:@` entries. Requires Claude.ai authentication | `claude --channels plugin:my-notifier@my-marketplace` | +| `--chrome` | Enable [Chrome browser integration](/en/chrome) for web automation and testing | `claude --chrome` | +| `--continue`, `-c` | Load the most recent conversation in the current directory. Includes sessions that added this directory with `/add-dir` | `claude --continue` | +| `--dangerously-load-development-channels` | Enable [channels](/en/channels-reference#test-during-the-research-preview) that are not on the approved allowlist, for local development. Accepts `plugin:@` and `server:` entries. Prompts for confirmation | `claude --dangerously-load-development-channels server:webhook` | +| `--dangerously-skip-permissions` | Skip permission prompts. Equivalent to `--permission-mode bypassPermissions`. See [permission modes](/en/permission-modes#skip-all-checks-with-bypasspermissions-mode) for what this does and does not skip | `claude --dangerously-skip-permissions` | +| `--debug` | Enable debug mode with optional category filtering (for example, `"api,hooks"` or `"!statsig,!file"`) | `claude --debug "api,mcp"` | +| `--debug-file ` | Write debug logs to a specific file path. Implicitly enables debug mode. Takes precedence over `CLAUDE_CODE_DEBUG_LOGS_DIR` | `claude --debug-file /tmp/claude-debug.log` | +| `--disable-slash-commands` | Disable all skills and commands for this session | `claude --disable-slash-commands` | +| `--disallowedTools`, `--disallowed-tools` | Deny rules. A bare tool name removes the matching tools from the model's context: `"Edit"` removes Edit, `"*"` removes every tool, and `"mcp__*"` removes every MCP tool. A scoped rule such as `Bash(rm *)` leaves the tool available and denies only matching calls | `"Bash(git log *)" "Bash(git diff *)" "Edit"` | +| `--effort` | Set the [effort level](/en/model-config#adjust-effort-level) for the current session. Options: `low`, `medium`, `high`, `xhigh`, `max`; available levels depend on the model. Overrides the [`effortLevel`](/en/settings#available-settings) setting for this session and does not persist | `claude --effort high` | +| `--enable-auto-mode` | {/* max-version: 2.1.110 */}Removed in v2.1.111. Auto mode is now in the `Shift+Tab` cycle by default; use `--permission-mode auto` to start in it | `claude --permission-mode auto` | +| `--exclude-dynamic-system-prompt-sections` | Move per-machine sections from the system prompt (working directory, environment info, memory paths, git-repo flag) into the first user message. Improves prompt-cache reuse across different users and machines running the same task. Only applies with the default system prompt; ignored when `--system-prompt` or `--system-prompt-file` is set. Use with `-p` for scripted, multi-user workloads | `claude -p --exclude-dynamic-system-prompt-sections "query"` | +| `--exec` | Run a shell command as a PTY-backed background job instead of starting a Claude session. Use with `--bg` to launch from the shell | `claude --bg --exec 'pytest -x'` | +| `--fallback-model` | Enable automatic fallback to the specified model(s) when the primary model is overloaded or not available, for example a retired model. Accepts a comma-separated list tried in order. See [Fallback model chains](/en/model-config#fallback-model-chains). To persist a chain across sessions, use the [`fallbackModel` setting](/en/settings#available-settings), which this flag overrides | `claude --fallback-model sonnet,haiku` | +| `--fork-session` | When resuming, create a new session ID instead of reusing the original (use with `--resume` or `--continue`) | `claude --resume abc123 --fork-session` | +| `--from-pr` | Resume sessions linked to a specific pull request. Accepts a PR number, a GitHub or GitHub Enterprise PR URL, a GitLab merge request URL, or a Bitbucket pull request URL. Sessions are linked automatically when Claude creates the pull request | `claude --from-pr 123` | +| `--ide` | Automatically connect to IDE on startup if exactly one valid IDE is available | `claude --ide` | +| `--init` | Run [Setup hooks](/en/hooks#setup) with the `init` matcher before the session (print mode only) | `claude -p --init "query"` | +| `--init-only` | Run [Setup](/en/hooks#setup) and `SessionStart` hooks, then exit without starting a conversation | `claude --init-only` | +| `--include-hook-events` | Include all hook lifecycle events in the output stream. Requires `--output-format stream-json` | `claude -p --output-format stream-json --verbose --include-hook-events "query"` | +| `--include-partial-messages` | Include partial streaming events in output. Requires `--print` and `--output-format stream-json` | `claude -p --output-format stream-json --verbose --include-partial-messages "query"` | +| `--input-format` | Specify input format for print mode (options: `text`, `stream-json`) | `claude -p --output-format json --input-format stream-json` | +| `--json-schema` | Get validated JSON output matching a JSON Schema after agent completes its workflow (print mode only, see [structured outputs](/en/agent-sdk/structured-outputs)) | `claude -p --json-schema '{"type":"object","properties":{...}}' "query"` | +| `--maintenance` | Run [Setup hooks](/en/hooks#setup) with the `maintenance` matcher before the session (print mode only) | `claude -p --maintenance "query"` | +| `--max-budget-usd` | Maximum dollar amount to spend on API calls before stopping (print mode only) | `claude -p --max-budget-usd 5.00 "query"` | +| `--max-turns` | Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default | `claude -p --max-turns 3 "query"` | +| `--mcp-config` | Load MCP servers from JSON files or strings (space-separated) | `claude --mcp-config ./mcp.json` | +| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet`, `opus`, `haiku`, or `fable`) or a model's full name. Overrides the [`model`](/en/settings#available-settings) setting and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) | `claude --model claude-sonnet-4-6` | +| `--name`, `-n` | Set a display name for the session, shown in `/resume` and the terminal title. You can resume a named session with `claude --resume `.

[`/rename`](/en/commands) changes the name mid-session and also shows it on the prompt bar | `claude -n "my-feature-work"` | +| `--no-chrome` | Disable [Chrome browser integration](/en/chrome) for this session | `claude --no-chrome` | +| `--no-session-persistence` | Disable session persistence so sessions are not saved to disk and cannot be resumed. Print mode only. The [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable does the same in any mode | `claude -p --no-session-persistence "query"` | +| `--output-format` | Specify output format for print mode (options: `text`, `json`, `stream-json`) | `claude -p "query" --output-format json` | +| `--permission-mode` | Begin in a specified [permission mode](/en/permission-modes). Accepts `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, or `bypassPermissions`. Overrides `defaultMode` from settings files | `claude --permission-mode plan` | +| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode | `claude -p --permission-prompt-tool mcp_auth_tool "query"` | +| `--plugin-dir` | Load a plugin from a directory or `.zip` archive for this session only. Each flag takes one path. Repeat the flag for multiple plugins: `--plugin-dir A --plugin-dir B.zip` | `claude --plugin-dir ./my-plugin` | +| `--plugin-url` | Fetch a plugin `.zip` archive from a URL for this session only. Repeat the flag for multiple plugins, or pass space-separated URLs in a single quoted value | `claude --plugin-url https://example.com/plugin.zip` | +| `--print`, `-p` | Print response without interactive mode (see [Agent SDK documentation](/en/agent-sdk/overview) for programmatic usage details) | `claude -p "query"` | +| `--prompt-suggestions` | Emit a `prompt_suggestion` message after each turn with a predicted next user prompt. Requires `--print`, `--output-format stream-json`, and `--verbose`. See [Prompt suggestions](/en/interactive-mode#prompt-suggestions) | `claude -p --prompt-suggestions --output-format stream-json --verbose "query"` | +| `--remote` | Create a new [web session](/en/claude-code-on-the-web) on claude.ai with the provided task description | `claude --remote "Fix the login bug"` | +| `--remote-control`, `--rc` | Start an interactive session with [Remote Control](/en/remote-control#start-a-remote-control-session) enabled so you can also control it from claude.ai or the Claude app. Optionally pass a name for the session | `claude --remote-control "My Project"` | +| `--remote-control-session-name-prefix ` | Prefix for auto-generated [Remote Control](/en/remote-control) session names when no explicit name is set. Defaults to your machine's hostname, producing names like `myhost-graceful-unicorn`. Set `CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX` for the same effect | `claude remote-control --remote-control-session-name-prefix dev-box` | +| `--replay-user-messages` | Re-emit user messages from stdin back on stdout for acknowledgment. Requires `--input-format stream-json` and `--output-format stream-json` | `claude -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages` | +| `--resume`, `-r` | Resume a specific session by ID or name, or show an interactive picker to choose a session. The picker and name search include sessions that added this directory with `/add-dir`; passing a session ID searches only the current project directory and its git worktrees. As of v2.1.144, [background sessions](/en/agent-view) appear in the picker marked with `bg` | `claude --resume auth-refactor` | +| `--safe-mode` | {/* min-version: 2.1.169 */}Start with all customizations disabled to troubleshoot a broken configuration: CLAUDE.md, skills, plugins, hooks, MCP servers, custom commands and agents, output styles, workflows, custom themes, custom keybindings, status line and file-suggestion commands, LSP servers, and auto-memory do not load. Authentication, model selection, built-in tools, and permissions work normally, which differs from [`--bare`](/en/headless#start-faster-with-bare-mode). Managed settings policy still applies, including policy-configured hooks, status line, and file-suggestion commands; managed plugins, managed skills, managed CLAUDE.md, and policy-configured MCP servers do not. Useful for checking whether a customization is what triggers [automatic fallback from Fable 5](/en/model-config#automatic-model-fallback). Sets [`CLAUDE_CODE_SAFE_MODE`](/en/env-vars) | `claude --safe-mode` | +| `--session-id` | Use a specific session ID for the conversation (must be a valid UUID) | `claude --session-id "550e8400-e29b-41d4-a716-446655440000"` | +| `--setting-sources` | Comma-separated list of setting sources to load (`user`, `project`, `local`) | `claude --setting-sources user,project` | +| `--settings` | Path to a settings JSON file or an inline JSON string. Values you set here override the same keys in your `settings.json` files for this session. Keys you omit keep their file-based values. See [settings precedence](/en/settings#settings-precedence) | `claude --settings ./settings.json` | +| `--strict-mcp-config` | Only use MCP servers from `--mcp-config`, ignoring all other MCP configurations | `claude --strict-mcp-config --mcp-config ./mcp.json` | +| `--system-prompt` | Replace the entire system prompt with custom text | `claude --system-prompt "You are a Python expert"` | +| `--system-prompt-file` | Load system prompt from a file, replacing the default prompt | `claude --system-prompt-file ./custom-prompt.txt` | +| `--teleport` | Resume a [web session](/en/claude-code-on-the-web) in your local terminal | `claude --teleport` | +| `--teammate-mode` | Set how [agent team](/en/agent-teams) teammates display: `in-process` (default), `auto`, `tmux`, or {/* min-version: 2.1.186 */}`iterm2` (added in v2.1.186). The default changed from `auto` in v2.1.179. Overrides the [`teammateMode`](/en/settings#available-settings) setting for this session. See [Choose a display mode](/en/agent-teams#choose-a-display-mode) | `claude --teammate-mode auto` | +| `--tmux` | Create a tmux session for the worktree. Requires `--worktree`. Uses iTerm2 native panes when available; pass `--tmux=classic` for traditional tmux | `claude -w feature-auth --tmux` | +| `--tools` | Restrict which built-in tools Claude can use. Use `""` to disable all, `"default"` for all, or tool names like `"Bash,Edit,Read"`. MCP tools are not affected; to deny those too, use `--disallowedTools "mcp__*"`, or pass `--strict-mcp-config` without `--mcp-config` so no MCP servers load | `claude --tools "Bash,Edit,Read"` | +| `--verbose` | Enable verbose logging, shows full turn-by-turn output. Overrides the [`viewMode`](/en/settings#available-settings) setting for this session | `claude --verbose` | +| `--version`, `-v` | Output the version number | `claude -v` | +| `--worktree`, `-w` | Start Claude in an isolated [git worktree](/en/worktrees) at `/.claude/worktrees/`. If no name is given, one is auto-generated. Pass `#` or a GitHub pull request URL to fetch that PR from `origin` and branch the worktree from it | `claude -w feature-auth` | ### System prompt flags @@ -122,7 +133,9 @@ Claude Code provides four flags for customizing the system prompt. All four work `--system-prompt` and `--system-prompt-file` are mutually exclusive. The append flags can be combined with either replacement flag. -For most use cases, use an append flag. Appending preserves Claude Code's built-in capabilities while adding your requirements. Use a replacement flag only when you need complete control over the system prompt. +Choose based on whether Claude Code's default identity still fits your task. Use an append flag when Claude should remain a coding assistant that also follows your extra rules: per-invocation instructions, output formatting, or domain context for a `-p` script. Appending preserves the default tool guidance, safety instructions, and coding conventions, so you only supply what differs. Use a replacement flag when the surface, identity, or permission model differs from Claude Code's, like a non-coding agent in a pipeline that no human watches. Replacing drops all of the default prompt, including tool guidance and safety instructions, so you take responsibility for whatever your task still needs. + +These flags apply only to the current invocation. For persistent personas you can switch between and share across a project, use [output styles](/en/output-styles). For project conventions Claude should always follow, use [CLAUDE.md](/en/memory). The [Agent SDK guide on system prompts](/en/agent-sdk/modifying-system-prompts#decide-on-a-starting-point) covers the same decision in more depth. ## See also diff --git a/docs/upstream/headless.md b/docs/upstream/headless.md index e198b8f..a020614 100644 --- a/docs/upstream/headless.md +++ b/docs/upstream/headless.md @@ -1,10 +1,3 @@ - - > ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further. @@ -15,11 +8,7 @@ The [Agent SDK](/en/agent-sdk/overview) gives you the same tools, agent loop, and context management that power Claude Code. It's available as a CLI for scripts and CI/CD, or as [Python](/en/agent-sdk/python) and [TypeScript](/en/agent-sdk/typescript) packages for full programmatic control. - - The CLI was previously called "headless mode." The `-p` flag and all CLI options work the same way. - - -To run Claude Code programmatically from the CLI, pass `-p` with your prompt and any [CLI options](/en/cli-reference): +To run Claude Code in non-interactive mode, pass `-p` with your prompt and any [CLI options](/en/cli-reference): ```bash theme={null} claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash" @@ -61,7 +50,7 @@ In bare mode Claude has access to the Bash, file read, and file edit tools. Pass | Settings | `--settings ` | | MCP servers | `--mcp-config ` | | Custom agents | `--agents ` | -| A plugin directory | `--plugin-dir ` | +| A plugin | `--plugin-dir `, `--plugin-url ` | Bare mode skips OAuth and keychain reads. Anthropic authentication must come from `ANTHROPIC_API_KEY` or an `apiKeyHelper` in the JSON passed to `--settings`. Bedrock, Vertex, and Foundry use their usual provider credentials. @@ -69,10 +58,46 @@ Bare mode skips OAuth and keychain reads. Anthropic authentication must come fro `--bare` is the recommended mode for scripted and SDK calls, and will become the default for `-p` in a future release. +### Background tasks at exit + +If Claude starts a [background Bash task](/en/tools-reference#bash-tool-behavior) during a `claude -p` run, for example a dev server or a watch build, that shell is terminated about five seconds after Claude has returned its final result and stdin has closed. The grace period lets a task that finishes right after the result still deliver its output. Before v2.1.163, a never-exiting background process would hold the `claude -p` invocation open indefinitely. + +Background [subagents](/en/sub-agents) and workflows are exempt from the five-second grace because their result is part of the final output, so `claude -p` waits for them to complete. From v2.1.182, that wait is capped at ten minutes by default so a stuck background agent cannot hold the process open indefinitely. Adjust the cap with [`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`](/en/env-vars), or set it to `0` to wait without a limit. + ## Examples These examples highlight common CLI patterns. For CI and other scripted calls, add [`--bare`](#start-faster-with-bare-mode) so they don't pick up whatever happens to be configured locally. +### Pipe data through Claude + +Non-interactive mode reads stdin, so you can pipe data in and redirect the response out like any other command-line tool. + +This example pipes a build log into Claude and writes the explanation to a file: + +```bash theme={null} +cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt +``` + +With `--output-format json`, the response payload includes `total_cost_usd` and a per-model cost breakdown, so scripted callers can track spend per invocation without consulting the [usage dashboard](/en/costs). + + + As of Claude Code v2.1.128, piped stdin is capped at 10MB. If you exceed the cap, Claude Code exits with a clear error and a non-zero status. To work with larger inputs, write the content to a file and reference the file path in your prompt instead of piping it. + + +### Add Claude to a build script + +You can wrap a non-interactive call in a script to use Claude as a project-specific linter or reviewer. + +This `package.json` script pipes the diff against `main` into Claude and asks it to report typos. Piping the diff means Claude doesn't need Bash permission to read it, and the escaped double quotes keep the script portable to Windows: + +```json theme={null} +{ + "scripts": { + "lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\"" + } +} +``` + ### Get structured output Use `--output-format` to control how responses are returned: @@ -129,24 +154,24 @@ claude -p "Write a poem" --output-format stream-json --verbose --include-partial When an API request fails with a retryable error, Claude Code emits a `system/api_retry` event before retrying. You can use this to surface retry progress or implement custom backoff logic. -| Field | Type | Description | -| ---------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | `"system"` | message type | -| `subtype` | `"api_retry"` | identifies this as a retry event | -| `attempt` | integer | current attempt number, starting at 1 | -| `max_retries` | integer | total retries permitted | -| `retry_delay_ms` | integer | milliseconds until the next attempt | -| `error_status` | integer or null | HTTP status code, or `null` for connection errors with no HTTP response | -| `error` | string | error category: `authentication_failed`, `billing_error`, `rate_limit`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown` | -| `uuid` | string | unique event identifier | -| `session_id` | string | session the event belongs to | +| Field | Type | Description | +| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `type` | `"system"` | message type | +| `subtype` | `"api_retry"` | identifies this as a retry event | +| `attempt` | integer | current attempt number, starting at 1 | +| `max_retries` | integer | total retries permitted | +| `retry_delay_ms` | integer | milliseconds until the next attempt | +| `error_status` | integer or null | HTTP status code, or `null` for connection errors with no HTTP response | +| `error` | string | error category: `authentication_failed`, `oauth_org_not_allowed`, `billing_error`, `rate_limit`, `overloaded`, `invalid_request`, `model_not_found`, `server_error`, `max_output_tokens`, or `unknown` | +| `uuid` | string | unique event identifier | +| `session_id` | string | session the event belongs to | The `system/init` event reports session metadata including the model, tools, MCP servers, and loaded plugins. It is the first event in the stream unless [`CLAUDE_CODE_SYNC_PLUGIN_INSTALL`](/en/env-vars) is set, in which case `plugin_install` events precede it. Use the plugin fields to fail CI when a plugin did not load: -| Field | Type | Description | -| --------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `plugins` | array | plugins that loaded successfully, each with `name` and `path` | -| `plugin_errors` | array | plugin load-time errors such as an unsatisfied dependency version, each with `plugin`, `type`, and `message`. Affected plugins are demoted and absent from `plugins`. The key is omitted when there are no errors | +| Field | Type | Description | +| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plugins` | array | plugins that loaded successfully, each with `name` and `path` | +| `plugin_errors` | array | plugin load-time errors, each with `plugin`, `type`, and `message`. Includes unsatisfied dependency versions and `--plugin-dir` load failures such as a missing path or invalid archive. Affected plugins are demoted and absent from `plugins`. The key is omitted when there are no errors | When [`CLAUDE_CODE_SYNC_PLUGIN_INSTALL`](/en/env-vars) is set, Claude Code emits `system/plugin_install` events while marketplace plugins install before the first turn. Use these to surface install progress in your own UI. @@ -189,7 +214,7 @@ claude -p "Look at my staged changes and create an appropriate commit" \ The `--allowedTools` flag uses [permission rule syntax](/en/settings#permission-rule-syntax). The trailing ` *` enables prefix matching, so `Bash(git diff *)` allows any command starting with `git diff`. The space before `*` is important: without it, `Bash(git diff*)` would also match `git diff-index`. - User-invoked [skills](/en/skills) like `/commit` and [built-in commands](/en/commands) are only available in interactive mode. In `-p` mode, describe the task you want to accomplish instead. + User-invoked [skills](/en/skills) and custom commands work in `-p` mode: include `/skill-name` in the prompt string and Claude Code expands it before running. Built-in commands that open an interactive dialog, such as `/login`, are not available in `-p` mode. {/* min-version: 2.1.181 */}To change a setting from a `-p` invocation, pass `key=value` to `/config`, for example `/config thinking=false`. ### Customize the system prompt @@ -224,6 +249,8 @@ session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_i claude -p "Continue that review" --resume "$session_id" ``` +Run both commands from the same directory: session ID lookup is scoped to the current project directory and its git worktrees. See [Resume a session](/en/sessions#resume-a-session) for the full scope rules. + ## Next steps * [Agent SDK quickstart](/en/agent-sdk/quickstart): build your first agent with Python or TypeScript diff --git a/docs/upstream/hooks-guide.md b/docs/upstream/hooks-guide.md index 9cd9599..a9c087a 100644 --- a/docs/upstream/hooks-guide.md +++ b/docs/upstream/hooks-guide.md @@ -1,15 +1,8 @@ - - > ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further. -# Automate workflows with hooks +# Automate actions with hooks > Run shell commands automatically when Claude Code edits files, finishes tasks, or needs input. Format code, send notifications, validate commands, and enforce project rules. @@ -100,6 +93,8 @@ Each example includes a ready-to-use configuration block that you add to a [sett * [Reload environment when directory or files change](#reload-environment-when-directory-or-files-change) * [Auto-approve specific permission prompts](#auto-approve-specific-permission-prompts) +For a production example of hooks that run a separate model review and feed findings back into the session, see [how the `security-guidance` plugin integrates with Claude Code](/en/security-guidance#how-the-plugin-integrates-with-claude-code). + ### Get notified when Claude needs input Get a desktop notification whenever Claude finishes working and needs your input, so you can switch to other tasks without checking the terminal. @@ -178,6 +173,19 @@ This hook uses the `Notification` event, which fires when Claude is waiting for +The empty `matcher` fires on all notification types. To fire only on specific events, set it to one of these values: + +| Matcher | Fires when | +| :--------------------- | :----------------------------------------------------- | +| `permission_prompt` | Claude needs you to approve a tool use | +| `idle_prompt` | Claude is done and waiting for your next prompt | +| `auth_success` | Authentication completes | +| `elicitation_dialog` | An MCP server opens an elicitation form | +| `elicitation_complete` | An MCP elicitation form is submitted or dismissed | +| `elicitation_response` | An MCP elicitation response is sent back to the server | + +Type `/hooks` and select `Notification` to confirm the hook is registered. For the full event schema, see the [Notification reference](/en/hooks#notification). + ### Auto-format code after edits Automatically run [Prettier](https://prettier.io/) on every file Claude edits, so formatting stays consistent without manual intervention. @@ -435,6 +443,7 @@ Hook events fire at specific lifecycle points in Claude Code. When an event fire | Event | When it fires | | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | | `SessionStart` | When a session begins or resumes | +| `Setup` | When you start Claude Code with `--init-only`, or with `--init` or `--maintenance` in `-p` mode. For one-time preparation in CI or scripts | | `UserPromptSubmit` | When you submit a prompt, before Claude processes it | | `UserPromptExpansion` | When a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion | | `PreToolUse` | Before a tool call executes. Can block it | @@ -444,6 +453,7 @@ Hook events fire at specific lifecycle points in Claude Code. When an event fire | `PostToolUseFailure` | After a tool call fails | | `PostToolBatch` | After a full batch of parallel tool calls resolves, before the next model call | | `Notification` | When Claude Code sends a notification | +| `MessageDisplay` | While assistant message text is displayed | | `SubagentStart` | When a subagent is spawned | | `SubagentStop` | When a subagent finishes | | `TaskCreated` | When a task is being created via `TaskCreate` | @@ -463,8 +473,6 @@ Hook events fire at specific lifecycle points in Claude Code. When an event fire | `ElicitationResult` | After a user responds to an MCP elicitation, before the response is sent back to the server | | `SessionEnd` | When a session terminates | -When multiple hooks match, each one returns its own result. For decisions, Claude Code picks the most restrictive answer. A `PreToolUse` hook returning `deny` cancels the tool call no matter what the others return. One hook returning `ask` forces the permission prompt even if the rest return `allow`. Text from `additionalContext` is kept from every hook and passed to Claude together. - Each hook has a `type` that determines how it runs. Most hooks use `"type": "command"`, which runs a shell command. Four other types are available: * `"type": "http"`: POST event data to a URL. See [HTTP hooks](#http-hooks). @@ -472,6 +480,38 @@ Each hook has a `type` that determines how it runs. Most hooks use `"type": "com * `"type": "prompt"`: single-turn LLM evaluation. See [Prompt-based hooks](#prompt-based-hooks). * `"type": "agent"`: multi-turn verification with tool access. Agent hooks are experimental and may change. See [Agent-based hooks](#agent-based-hooks). +### Combine results from multiple hooks + +When multiple hooks match the same event, every hook's command runs to completion before Claude Code merges the results. One hook returning `deny` does not stop sibling hooks from executing. Don't rely on one hook's `deny` to suppress side effects in another hook. + +After all matching hooks finish, Claude Code combines their outputs. For `PreToolUse` permission decisions, the most restrictive answer wins, in the order `deny`, `defer`, `ask`, `allow`. Text from `additionalContext` is kept from every hook and passed to Claude together. + +The example below registers two `PreToolUse` hooks on `Bash`. The first appends every command to a log file and exits 0. The second runs a script that exits 2 to deny when the command contains `rm -rf`: + +```json theme={null} +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "jq -r .tool_input.command >> ~/.claude/bash.log" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh" + } + ] + } + ] + } +} +``` + +When Claude tries to run `rm -rf /tmp/build`, both hooks execute in parallel. The logging hook writes the command to `~/.claude/bash.log` and exits 0, which reports no decision. The guardrail hook exits 2, which denies the tool call. The deny wins, so Claude Code blocks the command and shows Claude the guardrail's stderr. The log entry is still written because the logging hook already ran. + ### Read input and return output Hooks communicate with Claude Code through stdin, stdout, stderr, and exit codes. When an event fires, Claude Code passes event-specific data as JSON to your script's stdin. Your script reads that data, does its work, and tells Claude Code what to do next via the exit code. @@ -508,18 +548,18 @@ if echo "$COMMAND" | grep -q "drop table"; then exit 2 # exit 2 = block the action fi -exit 0 # exit 0 = let it proceed +exit 0 # exit 0 = no decision; the normal permission flow applies ``` The exit code determines what happens next: -* **Exit 0**: the action proceeds. For `UserPromptSubmit`, `UserPromptExpansion`, and `SessionStart` hooks, anything you write to stdout is added to Claude's context. -* **Exit 2**: the action is blocked. Write a reason to stderr, and Claude receives it as feedback so it can adjust. +* **Exit 0**: the hook reports no objection and the action proceeds normally. For a `PreToolUse` hook this doesn't approve the tool call: the normal [permission flow](/en/permissions) still applies. For `UserPromptSubmit`, `UserPromptExpansion`, and `SessionStart` hooks, anything you write to stdout is added to Claude's context. +* **Exit 2**: the action is blocked. Write a reason to stderr, and Claude receives it as feedback so it can adjust. Some events cannot be blocked: for `SessionStart`, `Setup`, `Notification`, and others, exit 2 shows stderr to the user and execution continues. See [exit code 2 behavior per event](/en/hooks#exit-code-2-behavior-per-event) for the full list. * **Any other exit code**: the action proceeds. The transcript shows a ` hook error` notice followed by the first line of stderr; the full stderr goes to the [debug log](/en/hooks#debug-hooks). #### Structured JSON output -Exit codes give you two options: allow or block. For more control, exit 0 and print a JSON object to stdout instead. +Exit codes only let you block or stay silent. For more control, exit 0 and print a JSON object to stdout instead. Use exit 2 to block with a stderr message, or exit 0 with JSON for structured control. Don't mix them: Claude Code ignores JSON when you exit 2. @@ -572,25 +612,30 @@ Without a matcher, a hook fires on every occurrence of its event. Matchers let y The `"Edit|Write"` matcher fires only when Claude uses the `Edit` or `Write` tool, not when it uses `Bash`, `Read`, or any other tool. See [Matcher patterns](/en/hooks#matcher-patterns) for how plain names and regular expressions are evaluated. + + Claude can also create or modify files by running shell commands through the `Bash` tool. If your hook must see every file change, such as for compliance scanning or audit logging, add a [`Stop`](/en/hooks#stop) hook that scans the working tree once per turn. For per-call coverage instead, also match `Bash` and have your script list modified and untracked files with `git status --porcelain`. + + Each event type matches on a specific field: -| Event | What the matcher filters | Example matcher values | -| :-------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ | -| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | tool name | `Bash`, `Edit\|Write`, `mcp__.*` | -| `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | -| `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | -| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` | -| `SubagentStart` | agent type | `Bash`, `Explore`, `Plan`, or custom agent names | -| `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | -| `SubagentStop` | agent type | same values as `SubagentStart` | -| `ConfigChange` | configuration source | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` | -| `StopFailure` | error type | `rate_limit`, `authentication_failed`, `billing_error`, `invalid_request`, `server_error`, `max_output_tokens`, `unknown` | -| `InstructionsLoaded` | load reason | `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact` | -| `Elicitation` | MCP server name | your configured MCP server names | -| `ElicitationResult` | MCP server name | same values as `Elicitation` | -| `FileChanged` | literal filenames to watch (see [FileChanged](/en/hooks#filechanged)) | `.envrc\|.env` | -| `UserPromptExpansion` | command name | your skill or command names | -| `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `CwdChanged` | no matcher support | always fires on every occurrence | +| Event | What the matcher filters | Example matcher values | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | tool name | `Bash`, `Edit\|Write`, `mcp__.*` | +| `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | +| `Setup` | which CLI flag triggered setup | `init`, `maintenance` | +| `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | +| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response` | +| `SubagentStart` | agent type | `general-purpose`, `Explore`, `Plan`, or custom agent names | +| `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | +| `SubagentStop` | agent type | same values as `SubagentStart` | +| `ConfigChange` | configuration source | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` | +| `StopFailure` | error type | `rate_limit`, `overloaded`, `authentication_failed`, `oauth_org_not_allowed`, `billing_error`, `invalid_request`, `model_not_found`, `server_error`, `max_output_tokens`, `unknown` | +| `InstructionsLoaded` | load reason | `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact` | +| `Elicitation` | MCP server name | your configured MCP server names | +| `ElicitationResult` | MCP server name | same values as `Elicitation` | +| `FileChanged` | literal filenames to watch (see [FileChanged](/en/hooks#filechanged)) | `.envrc\|.env` | +| `UserPromptExpansion` | command name | your skill or command names | +| `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `CwdChanged`, `MessageDisplay` | no matcher support | always fires on every occurrence | A few more examples showing matchers on different event types: @@ -672,7 +717,7 @@ For full matcher syntax, see the [Hooks reference](/en/hooks#configuration). The `if` field requires Claude Code v2.1.85 or later. Earlier versions ignore it and run the hook on every matched call. -The `if` field uses [permission rule syntax](/en/permissions) to filter hooks by tool name and arguments together, so the hook process only spawns when the tool call matches, or when a Bash command is too complex to parse. This goes beyond `matcher`, which filters at the group level by tool name only. +The `if` field uses [permission rule syntax](/en/permissions) to filter hooks by tool name and arguments together, so the hook process only spawns when the tool call matches. This goes beyond `matcher`, which filters at the group level by tool name only. For example, to run a hook only when Claude uses `git` commands rather than all Bash commands: @@ -695,7 +740,19 @@ For example, to run a hook only when Claude uses `git` commands rather than all } ``` -The hook process only spawns when a subcommand of the Bash command matches `git *`, or when the command is too complex to parse into subcommands. For compound commands like `npm test && git push`, Claude Code evaluates each subcommand and fires the hook because `git push` matches. The `if` field accepts the same patterns as permission rules: `"Bash(git *)"`, `"Edit(*.ts)"`, and so on. To match multiple tool names, use separate handlers each with its own `if` value, or match at the `matcher` level where pipe alternation is supported. +Whether your hook command runs depends on the shape of your `if` pattern and the Bash command Claude is invoking: + +| `if` pattern | Bash command | Hook runs? | Why | +| :----------------- | :--------------------- | :--------- | :-------------------------------------------------------------------------------------------------- | +| `Bash(git *)` | `git push` | yes | command name matches | +| `Bash(git *)` | `npm test && git push` | yes | each subcommand is checked; `git push` matches | +| `Bash(git *)` | `echo $(git log)` | yes | commands inside `$()` and backticks are checked; `git log` matches | +| `Bash(git *)` | `echo $(date)` | no | no subcommand matches `git *` | +| `Bash(git push *)` | `echo $(date)` | yes | patterns that specify more than the command name run the hook anyway on `$()`, backticks, or `$VAR` | + +The filter also fails open, running your hook regardless of pattern, when the Bash command cannot be parsed. Because the filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. + +The `if` field accepts the same patterns as permission rules: `"Bash(git *)"`, `"Edit(*.ts)"`, and so on. To match multiple tool names, use separate handlers each with its own `if` value, or match at the `matcher` level where pipe alternation is supported. `if` only works on tool events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, and `PermissionDenied`. Adding it to any other event prevents the hook from running. @@ -703,16 +760,16 @@ The hook process only spawns when a subcommand of the Bash command matches `git Where you add a hook determines its scope: -| Location | Scope | Shareable | -| :--------------------------------------------------------- | :--------------------------------- | :--------------------------------- | -| `~/.claude/settings.json` | All your projects | No, local to your machine | -| `.claude/settings.json` | Single project | Yes, can be committed to the repo | -| `.claude/settings.local.json` | Single project | No, gitignored | -| Managed policy settings | Organization-wide | Yes, admin-controlled | -| [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | -| [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the skill or agent is active | Yes, defined in the component file | +| Location | Scope | Shareable | +| :--------------------------------------------------------- | :--------------------------------- | :----------------------------------------- | +| `~/.claude/settings.json` | All your projects | No, local to your machine | +| `.claude/settings.json` | Single project | Yes, can be committed to the repo | +| `.claude/settings.local.json` | Single project | No, gitignored when Claude Code creates it | +| Managed policy settings | Organization-wide | Yes, admin-controlled | +| [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | +| [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the skill or agent is active | Yes, defined in the component file | -Run [`/hooks`](/en/hooks#the-hooks-menu) in Claude Code to browse all configured hooks grouped by event. To disable all hooks at once, set `"disableAllHooks": true` in your settings file. +Run [`/hooks`](/en/hooks#the-%2Fhooks-menu) in Claude Code to browse all configured hooks grouped by event. To disable hooks, set `"disableAllHooks": true` in your settings file. Hooks configured in managed settings still run unless `disableAllHooks` is also set there. If you edit settings files directly while Claude Code is running, the file watcher normally picks up hook changes automatically. @@ -723,7 +780,10 @@ For decisions that require judgment rather than deterministic rules, use `type: The model's only job is to return a yes/no decision as JSON: * `"ok": true`: the action proceeds -* `"ok": false`: the action is blocked. The model's `"reason"` is fed back to Claude so it can adjust. +* `"ok": false`: what happens depends on the event: + * `Stop` and `SubagentStop`: the `reason` is fed back to Claude so it keeps working + * `PreToolUse`: the tool call is denied and the `reason` is returned to Claude as the tool error, so it can adjust and continue + * `PostToolUse`, `PostToolBatch`, `UserPromptSubmit`, and `UserPromptExpansion`: the turn ends and the `reason` appears in the chat as a warning line This example uses a `Stop` hook to ask the model whether all requested tasks are complete. If the model returns `"ok": false`, Claude keeps working and uses the `reason` as its next instruction: @@ -820,7 +880,10 @@ For full configuration options and response handling, see [HTTP hooks](/en/hooks ### Limitations * Command hooks communicate through stdout, stderr, and exit codes only. They cannot trigger `/` commands or tool calls. Text returned via `additionalContext` is injected as a system reminder that Claude reads as plain text. HTTP hooks communicate through the response body instead. -* Hook timeout is 10 minutes by default, configurable per hook with the `timeout` field (in seconds). +* Hook timeouts vary by type. Override per hook with the `timeout` field in seconds. + * `command`, `http`, `mcp_tool`: 10 minutes. `UserPromptSubmit` lowers these to 30 seconds, and `MessageDisplay` lowers them to 10 seconds. + * `prompt`: 30 seconds. + * `agent`: 60 seconds. * `PostToolUse` hooks cannot undo actions since the tool has already executed. * `PermissionRequest` hooks do not fire in [non-interactive mode](/en/headless) (`-p`). Use `PreToolUse` hooks for automated permission decisions. * `Stop` hooks fire whenever Claude finishes responding, not only at task completion. They do not fire on user interrupts. API errors fire [StopFailure](/en/hooks#stopfailure) instead. @@ -850,7 +913,7 @@ You see a message like "PreToolUse hook error: ..." in the transcript. echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh echo $? # Check the exit code ``` -* If you see "command not found", use absolute paths or `$CLAUDE_PROJECT_DIR` to reference scripts +* If you see "command not found", use absolute paths or `${CLAUDE_PROJECT_DIR}` to reference scripts. To avoid shell quoting entirely, add `"args": []` to switch to [exec form](/en/hooks#exec-form-and-shell-form), which spawns the script directly without a shell * If you see "jq: command not found", install `jq` or use Python/Node.js for JSON parsing * If the script isn't running at all, make it executable: `chmod +x ./my-hook.sh` @@ -862,11 +925,11 @@ You edited a settings file but the hooks don't appear in the menu. * Verify your JSON is valid (trailing commas and comments are not allowed) * Confirm the settings file is in the correct location: `.claude/settings.json` for project hooks, `~/.claude/settings.json` for global hooks -### Stop hook runs forever +### Stop hook hits the block cap -Claude keeps working in an infinite loop instead of stopping. +Claude keeps working instead of stopping, then ends the turn with a warning that the Stop hook blocked too many consecutive times. -Your Stop hook script needs to check whether it already triggered a continuation. Parse the `stop_hook_active` field from the JSON input and exit early if it's `true`: +Claude Code overrides a Stop hook after it blocks 8 times in a row without progress. Your hook script needs to check whether it already triggered a continuation. Parse the `stop_hook_active` field from the JSON input and exit early if it's `true`: ```bash theme={null} #!/bin/bash @@ -877,11 +940,13 @@ fi # ... rest of your hook logic ``` +If your hook legitimately needs more than eight iterations to converge, raise the cap with [`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`](/en/env-vars). + ### JSON validation failed Claude Code shows a JSON parsing error even though your hook script outputs valid JSON. -When Claude Code runs a hook, it spawns a shell that sources your profile (`~/.zshrc` or `~/.bashrc`). If your profile contains unconditional `echo` statements, that output gets prepended to your hook's JSON: +When Claude Code runs a shell-form command hook (one without `args`), it spawns `sh -c` on macOS and Linux or Git Bash on Windows by default. This shell is non-interactive, but Git Bash and some configurations (such as `BASH_ENV` pointing at `~/.bashrc`) still source your profile. If that profile contains unconditional `echo` statements, the output gets prepended to your hook's JSON: ```text theme={null} Shell ready on arm64 diff --git a/docs/upstream/hooks-reference.md b/docs/upstream/hooks-reference.md index 80cef9d..1fae798 100644 --- a/docs/upstream/hooks-reference.md +++ b/docs/upstream/hooks-reference.md @@ -1,10 +1,3 @@ - - > ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further. @@ -14,7 +7,7 @@ > Reference for Claude Code hook events, configuration schema, JSON input/output formats, exit codes, async hooks, HTTP hooks, prompt hooks, and MCP tool hooks. - For a quickstart guide with examples, see [Automate workflows with hooks](/en/hooks-guide). + For a quickstart guide with examples, see [Automate actions with hooks](/en/hooks-guide). Hooks are user-defined shell commands, HTTP endpoints, or LLM prompts that execute automatically at specific points in Claude Code's lifecycle. Use this reference to look up event schemas, configuration options, JSON input/output formats, and advanced features like async hooks, HTTP hooks, and MCP tool hooks. If you're setting up hooks for the first time, start with the [guide](/en/hooks-guide) instead. @@ -25,7 +18,7 @@ Hooks fire at specific points during a Claude Code session. When an event fires
- Hook lifecycle diagram showing SessionStart, then a per-turn loop containing UserPromptSubmit, UserPromptExpansion for slash commands, the nested agentic loop (PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, PostToolBatch, SubagentStart/Stop, TaskCreated, TaskCompleted), and Stop or StopFailure, followed by TeammateIdle, PreCompact, PostCompact, and SessionEnd, with Elicitation and ElicitationResult nested inside MCP tool execution, PermissionDenied as a side branch from PermissionRequest for auto-mode denials, and WorktreeCreate, WorktreeRemove, Notification, ConfigChange, InstructionsLoaded, CwdChanged, and FileChanged as standalone async events + Hook lifecycle diagram showing optional Setup feeding into SessionStart, then a per-turn loop containing UserPromptSubmit, UserPromptExpansion for slash commands, the nested agentic loop (PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure, PostToolBatch, SubagentStart/Stop, TaskCreated, TaskCompleted), and Stop or StopFailure, followed by TeammateIdle, PreCompact, PostCompact, and SessionEnd, with Elicitation and ElicitationResult nested inside MCP tool execution, PermissionDenied as a side branch from PermissionRequest for auto-mode denials, WorktreeCreate, WorktreeRemove, Notification, ConfigChange, InstructionsLoaded, CwdChanged, and FileChanged as standalone async events, and MessageDisplay as a display-only event that runs while assistant message text streams
@@ -34,6 +27,7 @@ The table below summarizes when each event fires. The [Hook events](#hook-events | Event | When it fires | | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | | `SessionStart` | When a session begins or resumes | +| `Setup` | When you start Claude Code with `--init-only`, or with `--init` or `--maintenance` in `-p` mode. For one-time preparation in CI or scripts | | `UserPromptSubmit` | When you submit a prompt, before Claude processes it | | `UserPromptExpansion` | When a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion | | `PreToolUse` | Before a tool call executes. Can block it | @@ -43,6 +37,7 @@ The table below summarizes when each event fires. The [Hook events](#hook-events | `PostToolUseFailure` | After a tool call fails | | `PostToolBatch` | After a full batch of parallel tool calls resolves, before the next model call | | `Notification` | When Claude Code sends a notification | +| `MessageDisplay` | While assistant message text is displayed | | `SubagentStart` | When a subagent is spawned | | `SubagentStop` | When a subagent finishes | | `TaskCreated` | When a task is being created via `TaskCreate` | @@ -76,7 +71,8 @@ To see how these pieces fit together, consider this `PreToolUse` hook that block { "type": "command", "if": "Bash(rm *)", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh" + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh", + "args": [] } ] } @@ -101,14 +97,14 @@ if echo "$COMMAND" | grep -q 'rm -rf'; then } }' else - exit 0 # allow the command + exit 0 # no decision; normal permission flow applies fi ``` Now suppose Claude Code decides to run `Bash "rm -rf /tmp/build"`. Here's what happens: - Hook resolution flow: PreToolUse event fires, matcher checks for Bash match, if condition checks for Bash(rm *) match, hook handler runs, result returns to Claude Code + Diagram of hook resolution: PreToolUse fires, the matcher checks for a Bash match, then the if condition checks for a Bash(rm *) match. If both match, the hook command runs and returns permissionDecision deny, so the tool call is blocked and Claude Code continues. If either check fails to match, the hook is skipped and the tool call is allowed to proceed. @@ -141,7 +137,7 @@ Now suppose Claude Code decides to run `Bash "rm -rf /tmp/build"`. Here's what h } ``` - If the command had been a safer `rm` variant like `rm file.txt`, the script would hit `exit 0` instead, which tells Claude Code to allow the tool call with no further action. + If the command had been a safer `rm` variant like `rm file.txt`, the script would hit `exit 0` instead. Exit code 0 with no output means the hook has no decision to report, so the tool call continues through the normal [permission flow](/en/permissions). The hook can deny the call, but staying silent doesn't approve it. @@ -169,14 +165,14 @@ See [How a hook resolves](#how-a-hook-resolves) above for a complete walkthrough Where you define a hook determines its scope: -| Location | Scope | Shareable | -| :--------------------------------------------------------- | :---------------------------- | :--------------------------------- | -| `~/.claude/settings.json` | All your projects | No, local to your machine | -| `.claude/settings.json` | Single project | Yes, can be committed to the repo | -| `.claude/settings.local.json` | Single project | No, gitignored | -| Managed policy settings | Organization-wide | Yes, admin-controlled | -| [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | -| [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the component is active | Yes, defined in the component file | +| Location | Scope | Shareable | +| :--------------------------------------------------------- | :---------------------------- | :----------------------------------------- | +| `~/.claude/settings.json` | All your projects | No, local to your machine | +| `.claude/settings.json` | Single project | Yes, can be committed to the repo | +| `.claude/settings.local.json` | Single project | No, gitignored when Claude Code creates it | +| Managed policy settings | Organization-wide | Yes, admin-controlled | +| [Plugin](/en/plugins) `hooks/hooks.json` | When plugin is enabled | Yes, bundled with the plugin | +| [Skill](/en/skills) or [agent](/en/sub-agents) frontmatter | While the component is active | Yes, defined in the component file | For details on settings file resolution, see [settings](/en/settings). Enterprise administrators can use `allowManagedHooksOnly` to block user, project, and plugin hooks. Hooks from plugins force-enabled in managed settings `enabledPlugins` are exempt, so administrators can distribute vetted hooks through an organization marketplace. See [Hook configuration](/en/settings#hook-configuration). @@ -184,34 +180,37 @@ For details on settings file resolution, see [settings](/en/settings). Enterpris The `matcher` field filters when hooks fire. How a matcher is evaluated depends on the characters it contains: -| Matcher value | Evaluated as | Example | -| :---------------------------------- | :---------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | -| `"*"`, `""`, or omitted | Match all | fires on every occurrence of the event | -| Only letters, digits, `_`, and `\|` | Exact string, or `\|`-separated list of exact strings | `Bash` matches only the Bash tool; `Edit\|Write` matches either tool exactly | -| Contains any other character | JavaScript regular expression | `^Notebook` matches any tool starting with Notebook; `mcp__memory__.*` matches every tool from the `memory` server | +| Matcher value | Evaluated as | Example | +| :----------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------- | +| `"*"`, `""`, or omitted | Match all | fires on every occurrence of the event | +| Only letters, digits, `_`, spaces, `,`, and `\|` | Exact string, or list of exact strings separated by `\|` or `,` with optional surrounding whitespace | `Bash` matches only the Bash tool; `Edit\|Write` and `Edit, Write` each match either tool exactly | +| Contains any other character | JavaScript regular expression | `^Notebook` matches any tool starting with Notebook; `mcp__memory__.*` matches every tool from the `memory` server | + +Comma separators and the surrounding whitespace tolerance require Claude Code v2.1.191 or later. The `FileChanged` and `StopFailure` events accept only `|` as the list separator and treat `,` as a literal character; all other events listed in the table that follows accept `|` or `,`. The `FileChanged` event does not follow these rules when building its watch list. See [FileChanged](#filechanged). Each event type matches on a different field: -| Event | What the matcher filters | Example matcher values | -| :------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ | -| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | tool name | `Bash`, `Edit\|Write`, `mcp__.*` | -| `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | -| `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | -| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog` | -| `SubagentStart` | agent type | `Bash`, `Explore`, `Plan`, or custom agent names | -| `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | -| `SubagentStop` | agent type | same values as `SubagentStart` | -| `ConfigChange` | configuration source | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` | -| `CwdChanged` | no matcher support | always fires on every directory change | -| `FileChanged` | literal filenames to watch (see [FileChanged](#filechanged)) | `.envrc\|.env` | -| `StopFailure` | error type | `rate_limit`, `authentication_failed`, `billing_error`, `invalid_request`, `server_error`, `max_output_tokens`, `unknown` | -| `InstructionsLoaded` | load reason | `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact` | -| `UserPromptExpansion` | command name | your skill or command names | -| `Elicitation` | MCP server name | your configured MCP server names | -| `ElicitationResult` | MCP server name | same values as `Elicitation` | -| `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove` | no matcher support | always fires on every occurrence | +| Event | What the matcher filters | Example matcher values | +| :------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied` | tool name | `Bash`, `Edit\|Write`, `mcp__.*` | +| `SessionStart` | how the session started | `startup`, `resume`, `clear`, `compact` | +| `Setup` | which CLI flag triggered setup | `init`, `maintenance` | +| `SessionEnd` | why the session ended | `clear`, `resume`, `logout`, `prompt_input_exit`, `bypass_permissions_disabled`, `other` | +| `Notification` | notification type | `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response` | +| `SubagentStart` | agent type | `general-purpose`, `Explore`, `Plan`, or custom agent names | +| `PreCompact`, `PostCompact` | what triggered compaction | `manual`, `auto` | +| `SubagentStop` | agent type | same values as `SubagentStart` | +| `ConfigChange` | configuration source | `user_settings`, `project_settings`, `local_settings`, `policy_settings`, `skills` | +| `CwdChanged` | no matcher support | always fires on every directory change | +| `FileChanged` | literal filenames to watch (see [FileChanged](#filechanged)) | `.envrc\|.env` | +| `StopFailure` | error type | `rate_limit`, `overloaded`, `authentication_failed`, `oauth_org_not_allowed`, `billing_error`, `invalid_request`, `model_not_found`, `server_error`, `max_output_tokens`, `unknown` | +| `InstructionsLoaded` | load reason | `session_start`, `nested_traversal`, `path_glob_match`, `include`, `compact` | +| `UserPromptExpansion` | command name | your skill or command names | +| `Elicitation` | MCP server name | your configured MCP server names | +| `ElicitationResult` | MCP server name | same values as `Elicitation` | +| `UserPromptSubmit`, `PostToolBatch`, `Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, `WorktreeRemove`, `MessageDisplay` | no matcher support | always fires on every occurrence | The matcher runs against a field from the [JSON input](#hook-input-and-output) that Claude Code sends to your hook on stdin. For tool events, that field is `tool_name`. Each [hook event](#hook-events) section lists the full set of matcher values and the input schema for that event. @@ -297,26 +296,78 @@ Each object in the inner `hooks` array is a hook handler: the shell command, HTT These fields apply to all hook types: -| Field | Required | Description | -| :-------------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | yes | `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` | -| `if` | no | Permission rule syntax to filter when this hook runs, such as `"Bash(git *)"` or `"Edit(*.ts)"`. The hook only spawns if the tool call matches the pattern, or if a Bash command is too complex to parse. Only evaluated on tool events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, and `PermissionDenied`. On other events, a hook with `if` set never runs. Uses the same syntax as [permission rules](/en/permissions) | -| `timeout` | no | Seconds before canceling. Defaults: 600 for command, 30 for prompt, 60 for agent | -| `statusMessage` | no | Custom spinner message displayed while the hook runs | -| `once` | no | If `true`, runs once per session then is removed. Only honored for hooks declared in [skill frontmatter](#hooks-in-skills-and-agents); ignored in settings files and agent frontmatter | +| Field | Required | Description | +| :-------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | yes | `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` | +| `if` | no | Permission rule syntax to filter when this hook runs, such as `"Bash(git *)"` or `"Edit(*.ts)"`. The hook command only runs if the tool call matches the pattern. See the [Bash matching table](#bash-if-matching) below for how Bash patterns evaluate against subcommands, `$()`, and backticks. Only evaluated on tool events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, and `PermissionDenied`. On other events, a hook with `if` set never runs. Uses the same syntax as [permission rules](/en/permissions) | +| `timeout` | no | Seconds before canceling. Defaults: 600 for `command`, `http`, and `mcp_tool`; 30 for `prompt`; 60 for `agent`. [`UserPromptSubmit`](#userpromptsubmit) lowers the `command`, `http`, and `mcp_tool` default to 30, and [`MessageDisplay`](#messagedisplay) lowers it to 10 | +| `statusMessage` | no | Custom spinner message displayed while the hook runs | +| `once` | no | If `true`, runs once per session then is removed. Only honored for hooks declared in [skill frontmatter](#hooks-in-skills-and-agents); ignored in settings files and agent frontmatter | + +The `if` field holds exactly one permission rule. There is no `&&`, `||`, or list syntax for combining rules; to apply multiple conditions, define a separate hook handler for each. + +For Bash patterns, whether your hook command runs depends on the shape of the pattern and the Bash command Claude is invoking. Leading `VAR=value` assignments are stripped before matching. -The `if` field holds exactly one permission rule. There is no `&&`, `||`, or list syntax for combining rules; to apply multiple conditions, define a separate hook handler for each. For Bash, the rule is matched against each subcommand of the tool input after leading `VAR=value` assignments are stripped, so `if: "Bash(git push *)"` matches both `FOO=bar git push` and `npm test && git push`. The hook runs if any subcommand matches, and always runs when the command is too complex to parse. +| `if` pattern | Bash command | Hook runs? | Why | +| :----------------- | :--------------------- | :--------- | :-------------------------------------------------------------------------------------------------- | +| `Bash(git *)` | `FOO=bar git push` | yes | leading assignments are stripped; `git push` matches | +| `Bash(git *)` | `npm test && git push` | yes | each subcommand is checked; `git push` matches | +| `Bash(rm *)` | `echo $(rm -rf /)` | yes | commands inside `$()` and backticks are checked; `rm -rf /` matches | +| `Bash(rm *)` | `echo $(date)` | no | no subcommand matches `rm *` | +| `Bash(git push *)` | `echo $(date)` | yes | patterns that specify more than the command name run the hook anyway on `$()`, backticks, or `$VAR` | + +The filter also fails open, running your hook regardless of pattern, when the Bash command cannot be parsed. Because the `if` filter is best-effort, use the [permission system](/en/permissions) rather than a hook to enforce a hard allow or deny. #### Command hook fields In addition to the [common fields](#common-fields), command hooks accept these fields: -| Field | Required | Description | -| :------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `command` | yes | Shell command to execute | -| `async` | no | If `true`, runs in the background without blocking. See [Run hooks in the background](#run-hooks-in-the-background) | -| `asyncRewake` | no | If `true`, runs in the background and wakes Claude on exit code 2. Implies `async`. The hook's stderr, or stdout if stderr is empty, is shown to Claude as a system reminder so it can react to a long-running background failure | -| `shell` | no | Shell to use for this hook. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` runs the command via PowerShell on Windows. Does not require `CLAUDE_CODE_USE_POWERSHELL_TOOL` since hooks spawn PowerShell directly | +| Field | Required | Description | +| :------------ | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | yes | Shell command to execute. With `args`, the executable to spawn directly. See [Exec form and shell form](#exec-form-and-shell-form) | +| `args` | no | Argument list. When present, `command` is resolved as an executable and spawned directly with `args` as the argument vector, with no shell involved. See [Exec form and shell form](#exec-form-and-shell-form) | +| `async` | no | If `true`, runs in the background without blocking. See [Run hooks in the background](#run-hooks-in-the-background) | +| `asyncRewake` | no | If `true`, runs in the background and wakes Claude on exit code 2. Implies `async`. The hook's stderr, or stdout if stderr is empty, is shown to Claude as a system reminder so it can react to a long-running background failure | +| `shell` | no | Shell to use for this hook. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` runs the command via PowerShell on Windows. Does not require `CLAUDE_CODE_USE_POWERSHELL_TOOL` since hooks spawn PowerShell directly. Ignored when `args` is set | + + + +##### Exec form and shell form + +A command hook runs as exec form when `args` is set, and shell form when `args` is omitted. Set `args` whenever the hook references a [path placeholder](#reference-scripts-by-path), since each element is passed as one argument with no quoting. Omit `args` when you need shell features like pipes or `&&`, or when neither concern applies. + +**Exec form** runs when `args` is present. Claude Code resolves `command` as an executable on `PATH` and spawns it directly with `args` as the argument vector. There is no shell, so each `args` element is one argument exactly as written, and path placeholders like `${CLAUDE_PLUGIN_ROOT}` are substituted into `command` and into each `args` element as plain strings. Special characters such as apostrophes, `$`, and backticks pass through verbatim because there is no shell to interpret them. No shell tokenization happens on any platform. + +**Shell form** runs when `args` is absent. The `command` string is passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed. Set the `shell` field to choose explicitly. The shell tokenizes the string, expands variables, and interprets pipes, `&&`, redirects, and globs. + + + On Windows, exec form requires `command` to resolve to a real executable such as a `.exe`. The `.cmd` and `.bat` shims that npm, npx, eslint, and other tools install in `node_modules/.bin` are not executables and cannot be spawned without a shell. To run them in exec form, invoke the underlying script with `node` directly, for example `"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/node_modules/eslint/bin/eslint.js"]`. The `node` plus script-path pattern works on every platform because `node.exe` is a real binary. To run a `.cmd` or `.bat` shim by name, use shell form. + + +This example runs a Node script bundled with a plugin. Exec form passes the resolved script path as one argument with no quoting: + +```json theme={null} +{ + "type": "command", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/format.js", "--fix"] +} +``` + +The equivalent shell form needs quoting to handle paths with spaces or special characters: + +```json theme={null} +{ + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}\"/scripts/format.js --fix" +} +``` + +Both forms support the same [path placeholders](#reference-scripts-by-path), and both export them as the environment variables `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, and `CLAUDE_PLUGIN_DATA` on the spawned process, so a script can read `process.env.CLAUDE_PLUGIN_ROOT` regardless of how it was launched. Plugin hooks additionally substitute `${user_config.*}` values; see [User configuration](/en/plugins-reference#user-configuration). + + + In exec form, `command` is the executable name or path only. If `command` is a bare name with no path separator and contains whitespace alongside `args`, Claude Code logs a warning because the spawn will fail: there is no executable named `node script.js`. Move the extra tokens into `args`. Absolute paths with spaces, such as `C:\Program Files\nodejs\node.exe`, are a single valid executable and do not trigger the warning. + #### HTTP hook fields @@ -397,24 +448,26 @@ This example calls the `security_scan` tool on the `my_server` MCP server after In addition to the [common fields](#common-fields), prompt and agent hooks accept these fields: -| Field | Required | Description | -| :------- | :------- | :------------------------------------------------------------------------------------------ | -| `prompt` | yes | Prompt text to send to the model. Use `$ARGUMENTS` as a placeholder for the hook input JSON | -| `model` | no | Model to use for evaluation. Defaults to a fast model | +| Field | Required | Description | +| :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `prompt` | yes | Prompt text to send to the model. Use `$ARGUMENTS` as a placeholder for the hook input JSON. Escape with a backslash to include literal text: `\$1.00` renders as `$1.00` | +| `model` | no | Model to use for evaluation. Defaults to a fast model | -All matching hooks run in parallel, and identical handlers are deduplicated automatically. Command hooks are deduplicated by command string, and HTTP hooks are deduplicated by URL. Handlers run in the current directory with Claude Code's environment. The `$CLAUDE_CODE_REMOTE` environment variable is set to `"true"` in remote web environments and not set in the local CLI. +All matching hooks run in parallel, and identical handlers are deduplicated automatically. Command hooks are deduplicated by command string and `args`, and HTTP hooks are deduplicated by URL. Handlers run in the current directory with Claude Code's environment. The `$CLAUDE_CODE_REMOTE` environment variable is set to `"true"` in remote web environments and not set in the local CLI. ### Reference scripts by path -Use environment variables to reference hook scripts relative to the project or plugin root, regardless of the working directory when the hook runs: +Use these placeholders to reference hook scripts relative to the project or plugin root, regardless of the working directory when the hook runs: -* `$CLAUDE_PROJECT_DIR`: the project root. Wrap in quotes to handle paths with spaces. +* `${CLAUDE_PROJECT_DIR}`: the project root. Claude Code also sets this variable in the environment of [stdio MCP servers](/en/mcp#option-3-add-a-local-stdio-server) and plugin LSP servers. * `${CLAUDE_PLUGIN_ROOT}`: the plugin's installation directory, for scripts bundled with a [plugin](/en/plugins). Changes on each plugin update. * `${CLAUDE_PLUGIN_DATA}`: the plugin's [persistent data directory](/en/plugins-reference#persistent-data-directory), for dependencies and state that should survive plugin updates. +Prefer [exec form](#exec-form-and-shell-form) for any hook that references a path placeholder. Exec form passes each `args` element as one argument with no shell tokenization, so paths with spaces or special characters need no quoting. In shell form, wrap each placeholder in double quotes. + - This example uses `$CLAUDE_PROJECT_DIR` to run a style checker from the project's `.claude/hooks/` directory after any `Write` or `Edit` tool call: + This example uses `${CLAUDE_PROJECT_DIR}` to run a style checker from the project's `.claude/hooks/` directory after any `Write` or `Edit` tool call: ```json theme={null} { @@ -425,7 +478,8 @@ Use environment variables to reference hook scripts relative to the project or p "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-style.sh" + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-style.sh", + "args": [] } ] } @@ -451,6 +505,7 @@ Use environment variables to reference hook scripts relative to the project or p { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/format.sh", + "args": [], "timeout": 30 } ] @@ -518,24 +573,29 @@ Direct edits to hooks in settings files are normally picked up automatically by Command hooks receive JSON data via stdin and communicate results through exit codes, stdout, and stderr. HTTP hooks receive the same JSON as the POST request body and communicate results through the HTTP response body. This section covers fields and behavior common to all events. Each event's section under [Hook events](#hook-events) includes its specific input schema and decision control options. +On macOS and Linux, command hooks run in their own session without a controlling terminal as of v2.1.139. The hook process and any child processes cannot open `/dev/tty` or send escape sequences directly to the Claude Code interface. Windows has no `/dev/tty`. To surface a message to the user on any platform, return [`systemMessage`](#json-output) in JSON output. To trigger a desktop notification, set a window title, or ring the bell, return [`terminalSequence`](#emit-terminal-notifications) instead. + ### Common input fields Hook events receive these fields as JSON, in addition to event-specific fields documented in each [hook event](#hook-events) section. For command hooks, this JSON arrives via stdin. For HTTP hooks, it arrives as the POST request body. -| Field | Description | -| :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `session_id` | Current session identifier | -| `transcript_path` | Path to conversation JSON | -| `cwd` | Current working directory when the hook is invoked | -| `permission_mode` | Current [permission mode](/en/permissions#permission-modes): `"default"`, `"plan"`, `"acceptEdits"`, `"auto"`, `"dontAsk"`, or `"bypassPermissions"`. Not all events receive this field: see each event's JSON example below to check | -| `hook_event_name` | Name of the event that fired | +| Field | Description | +| :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session_id` | Current session identifier | +| `transcript_path` | Path to conversation JSON | +| `cwd` | Current working directory when the hook is invoked | +| `permission_mode` | Current [permission mode](/en/permissions#permission-modes): `"default"`, `"plan"`, `"acceptEdits"`, `"auto"`, `"dontAsk"`, or `"bypassPermissions"`. Not all events receive this field: see each event's JSON example below to check | +| `effort` | Object with a `level` field holding the active [effort level](/en/model-config#adjust-effort-level) for the turn: `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. If the requested model effort exceeds what the current model supports, this is the downgraded level the model actually used. Ultracode is not a distinct level and reports as `"xhigh"`. The object matches the [status line](/en/statusline#available-data) `effort` field. Present for events that fire within a tool-use context, such as `PreToolUse`, `PostToolUse`, `Stop`, and `SubagentStop`, when the current model supports the effort parameter. The level is also available to hook commands and the Bash tool as the `$CLAUDE_EFFORT` environment variable. | +| `hook_event_name` | Name of the event that fired | When running with `--agent` or inside a subagent, two additional fields are included: -| Field | Description | -| :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent_id` | Unique identifier for the subagent. Present only when the hook fires inside a subagent call. Use this to distinguish subagent hook calls from main-thread calls. | -| `agent_type` | Agent name (for example, `"Explore"` or `"security-reviewer"`). Present when the session uses `--agent` or the hook fires inside a subagent. For subagents, the subagent's type takes precedence over the session's `--agent` value. | +| Field | Description | +| :----------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent_id` | Unique identifier for the subagent. Present only when the hook fires inside a subagent call. Use this to distinguish subagent hook calls from main-thread calls. | +| `agent_type` | Agent name (for example, `"Explore"` or `"security-reviewer"`). Present when the session uses `--agent` or the hook fires inside a subagent. For subagents, the subagent's type takes precedence over the session's `--agent` value. For [custom subagents](/en/sub-agents), this is the `name` field from the agent's frontmatter, not the filename. | + +Only [`SessionStart`](#sessionstart) hooks can receive a `model` field, and it is not guaranteed to be present. There is no `$CLAUDE_MODEL` environment variable. A hook process inherits the parent environment, so it can read `$ANTHROPIC_MODEL` if you set it in your shell, but that value does not change when you switch models with `/model` during a session. For example, a `PreToolUse` hook for a Bash command receives this on stdin: @@ -577,7 +637,7 @@ if [[ "$command" == rm* ]]; then exit 2 # Blocking error: tool call is prevented fi -exit 0 # Success: tool call proceeds +exit 0 # No decision: the normal permission flow applies ``` @@ -608,6 +668,7 @@ Exit code 2 is the way a hook signals "stop, don't do this." The effect depends | `Notification` | No | Shows stderr to user only | | `SubagentStart` | No | Shows stderr to user only | | `SessionStart` | No | Shows stderr to user only | +| `Setup` | No | Shows stderr to user only | | `SessionEnd` | No | Shows stderr to user only | | `CwdChanged` | No | Shows stderr to user only | | `FileChanged` | No | Shows stderr to user only | @@ -618,6 +679,7 @@ Exit code 2 is the way a hook signals "stop, don't do this." The effect depends | `WorktreeCreate` | Yes | Any non-zero exit code causes worktree creation to fail | | `WorktreeRemove` | No | Failures are logged in debug mode only | | `InstructionsLoaded` | No | Exit code is ignored | +| `MessageDisplay` | No | The original text is displayed | ### HTTP response handling @@ -633,7 +695,7 @@ Unlike command hooks, HTTP hooks cannot signal a blocking error through status c ### JSON output -Exit codes let you allow or block, but JSON output gives you finer-grained control. Instead of exiting with code 2 to block, exit 0 and print a JSON object to stdout. Claude Code reads specific fields from that JSON to control behavior, including [decision control](#decision-control) for blocking, allowing, or escalating to the user. +Exit codes only let you block or stay silent, but JSON output gives you finer-grained control. Instead of exiting with code 2 to block, exit 0 and print a JSON object to stdout. Claude Code reads specific fields from that JSON to control behavior, including [decision control](#decision-control) for blocking, allowing, or escalating to the user. You must choose one approach per hook, not both: either use exit codes alone for signaling, or exit 0 and print JSON for structured control. Claude Code only processes JSON on exit 0. If you exit 2, any JSON is ignored. @@ -641,7 +703,7 @@ Exit codes let you allow or block, but JSON output gives you finer-grained contr Your hook's stdout must contain only the JSON object. If your shell profile prints text on startup, it can interfere with JSON parsing. See [JSON validation failed](/en/hooks-guide#json-validation-failed) in the troubleshooting guide. -Hook output injected into context (`additionalContext`, `systemMessage`, or plain stdout) is capped at 10,000 characters. Output that exceeds this limit is saved to a file and replaced with a preview and file path, the same way large tool results are handled. +Hook output strings, including `additionalContext`, `systemMessage`, and plain stdout, are capped at 10,000 characters. Output that exceeds this limit is saved to a file and replaced with a preview and file path, the same way large tool results are handled. The JSON object supports three kinds of fields: @@ -649,12 +711,13 @@ The JSON object supports three kinds of fields: * **Top-level `decision` and `reason`** are used by some events to block or provide feedback. * **`hookSpecificOutput`** is a nested object for events that need richer control. It requires a `hookEventName` field set to the event name. -| Field | Default | Description | -| :--------------- | :------ | :------------------------------------------------------------------------------------------------------------------------- | -| `continue` | `true` | If `false`, Claude stops processing entirely after the hook runs. Takes precedence over any event-specific decision fields | -| `stopReason` | none | Message shown to the user when `continue` is `false`. Not shown to Claude | -| `suppressOutput` | `false` | If `true`, omits stdout from the debug log | -| `systemMessage` | none | Warning message shown to the user | +| Field | Default | Description | +| :----------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `continue` | `true` | If `false`, Claude stops processing entirely after the hook runs. Takes precedence over any event-specific decision fields | +| `stopReason` | none | Message shown to the user when `continue` is `false`. Not shown to Claude | +| `suppressOutput` | `false` | If `true`, hides the hook's stdout from the transcript. Stdout still appears in the debug log | +| `systemMessage` | none | Warning message shown to the user | +| `terminalSequence` | none | A terminal escape sequence for Claude Code to emit on your behalf, such as a desktop notification, window title, or bell. Restricted to OSC `0`/`1`/`2`/`9`/`99`/`777` and BEL. If the value contains anything outside the allowlist, the field is ignored. Use this instead of writing to `/dev/tty`, which is unavailable to hooks | To stop Claude entirely regardless of event type: @@ -662,21 +725,102 @@ To stop Claude entirely regardless of event type: { "continue": false, "stopReason": "Build failed, fix errors before continuing" } ``` +#### Emit terminal notifications + +The `terminalSequence` field requires Claude Code v2.1.141 or later. + +Hooks run without a controlling terminal, so writing escape sequences directly to `/dev/tty` fails. Instead, return the escape sequence in the `terminalSequence` field and Claude Code emits it for you through its own terminal write path. This is race-free, works inside tmux and GNU screen, and works on Windows where there is no `/dev/tty`. + +The field accepts a string of one or more allowlisted escape sequences: + +* OSC `0`, `1`, `2`: window and icon titles +* OSC `9`: iTerm2, ConEmu, Windows Terminal, and WezTerm notifications, including `9;4` taskbar progress +* OSC `99`: Kitty notifications +* OSC `777`: urxvt, Ghostty, and Warp notifications +* Bare BEL + +Sequences may be terminated with BEL or with ST. Anything outside the allowlist, including CSI cursor and color sequences, OSC palette sequences, OSC 8 hyperlinks, OSC 52 clipboard writes, and OSC 1337, is rejected and the field is ignored. + +The example below fires a desktop notification from a `Notification` hook. The escape sequence is built with `printf` octal escapes so the control bytes never appear on the shell command line, and `jq -n --arg` builds the JSON output so quotes, backslashes, and newlines in the notification message are escaped correctly: + +```bash theme={null} +#!/bin/bash +# Notification hook: ping the desktop when Claude Code needs attention. +input=$(cat) +title="Claude Code" +body=$(jq -r '.message // "Needs your attention"' <<<"$input") +seq=$(printf '\033]777;notify;%s;%s\007' "$title" "$body") +jq -nc --arg seq "$seq" '{terminalSequence: $seq}' +``` + +The `{ "terminalSequence": "..." }` shape is the same from any shell or language. On Windows, build the escape string in PowerShell or a script and emit the same JSON object. + + + `terminalSequence` is the supported replacement for hooks that previously wrote escape sequences directly to `/dev/tty`. The allowlist is restricted to sequences that cannot move the cursor or alter colors, so a hook can never corrupt an on-screen prompt. + + +#### Add context for Claude + +The `additionalContext` field passes a string from your hook into Claude's context window. Claude Code wraps the string in a system reminder and inserts it into the conversation at the point where the hook fired. Claude reads the reminder on the next model request, but it does not appear as a chat message in the interface. + +Return `additionalContext` inside `hookSpecificOutput` alongside the event name: + +```json theme={null} +{ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "This file is generated. Edit src/schema.ts and run `bun generate` instead." + } +} +``` + +Where the reminder appears depends on the event: + +* [SessionStart](#sessionstart), [Setup](#setup), and [SubagentStart](#subagentstart): at the start of the conversation, before the first prompt +* [UserPromptSubmit](#userpromptsubmit) and [UserPromptExpansion](#userpromptexpansion): alongside the submitted prompt +* [PreToolUse](#pretooluse), [PostToolUse](#posttooluse), [PostToolUseFailure](#posttoolusefailure), and [PostToolBatch](#posttoolbatch): next to the tool result +* [Stop](#stop) and [SubagentStop](#subagentstop): at the end of the turn. The conversation continues so Claude can act on the feedback. See [Stop decision control](#stop-decision-control) + +When several hooks return `additionalContext` for the same event, Claude receives all of the values. If a value exceeds 10,000 characters, Claude Code writes the full text to a file in the session directory and passes Claude the file path with a short preview instead. + +Use `additionalContext` for information Claude should know about the current state of your environment or the operation that just ran: + +* **Environment state**: the current branch, deployment target, or active feature flags +* **Conditional project rules**: which test command applies to the file just edited, which directories are read-only in this worktree +* **External data**: open issues assigned to you, recent CI results, content fetched from an internal service + +For instructions that never change, prefer [CLAUDE.md](/en/memory). It loads without running a script and is the standard place for static project conventions. + +Write the text as factual statements rather than imperative system instructions. Phrasing such as "The deployment target is production" or "This repo uses `bun test`" reads as project information. Text framed as out-of-band system commands can trigger Claude's prompt-injection defenses, which causes Claude to surface the text to you instead of treating it as context. + +Once injected, the text is saved in the session transcript. For mid-session events like `PostToolUse` or `UserPromptSubmit`, resuming with `--continue` or `--resume` replays the saved text rather than re-running the hook for past turns, so values like timestamps or commit SHAs become stale on resume. `SessionStart` hooks run again on resume with `source` set to `"resume"`, so they can refresh their context. + #### Decision control Not every event supports blocking or controlling behavior through JSON. The events that do each use a different set of fields to express that decision. Use this table as a quick reference before writing a hook: -| Events | Decision pattern | Key fields | -| :---------------------------------------------------------------------------------------------------------------------------------- | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact | Top-level `decision` | `decision: "block"`, `reason` | -| TeammateIdle, TaskCreated, TaskCompleted | Exit code or `continue: false` | Exit code 2 blocks the action with stderr feedback. JSON `{"continue": false, "stopReason": "..."}` also stops the teammate entirely, matching `Stop` hook behavior | -| PreToolUse | `hookSpecificOutput` | `permissionDecision` (allow/deny/ask/defer), `permissionDecisionReason` | -| PermissionRequest | `hookSpecificOutput` | `decision.behavior` (allow/deny) | -| PermissionDenied | `hookSpecificOutput` | `retry: true` tells the model it may retry the denied tool call | -| WorktreeCreate | path return | Command hook prints path on stdout; HTTP hook returns `hookSpecificOutput.worktreePath`. Hook failure or missing path fails creation | -| Elicitation | `hookSpecificOutput` | `action` (accept/decline/cancel), `content` (form field values for accept) | -| ElicitationResult | `hookSpecificOutput` | `action` (accept/decline/cancel), `content` (form field values override) | -| WorktreeRemove, Notification, SessionEnd, PostCompact, InstructionsLoaded, StopFailure, CwdChanged, FileChanged | None | No decision control. Used for side effects like logging or cleanup | +| Events | Decision pattern | Key fields | +| :---------------------------------------------------------------------------------------------------------------------------------- | :----------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact | Top-level `decision` | `decision: "block"`, `reason`. Stop and SubagentStop also accept `hookSpecificOutput.additionalContext` for [non-error feedback that continues the conversation](#stop-decision-control) | +| TeammateIdle, TaskCreated, TaskCompleted | Exit code or `continue: false` | Exit code 2 blocks the action with stderr feedback. JSON `{"continue": false, "stopReason": "..."}` also stops the teammate entirely, matching `Stop` hook behavior | +| PreToolUse | `hookSpecificOutput` | `permissionDecision` (allow/deny/ask/defer), `permissionDecisionReason` | +| PermissionRequest | `hookSpecificOutput` | `decision.behavior` (allow/deny) | +| PermissionDenied | `hookSpecificOutput` | `retry: true` tells the model it may retry the denied tool call | +| WorktreeCreate | path return | Command hook prints path on stdout; HTTP hook returns `hookSpecificOutput.worktreePath`. Hook failure or missing path fails creation | +| Elicitation | `hookSpecificOutput` | `action` (accept/decline/cancel), `content` (form field values for accept) | +| ElicitationResult | `hookSpecificOutput` | `action` (accept/decline/cancel), `content` (form field values override) | +| MessageDisplay | `hookSpecificOutput` | `displayContent` replaces the displayed text on screen. Display-only: the transcript and what Claude sees keep the original | +| SessionStart, Setup, SubagentStart | Context only | `hookSpecificOutput.additionalContext` adds context for Claude. SessionStart also accepts [`initialUserMessage`, `watchPaths`, `sessionTitle`, and `reloadSkills`](#sessionstart-decision-control). No blocking or decision control | +| WorktreeRemove, Notification, SessionEnd, PostCompact, InstructionsLoaded, StopFailure, CwdChanged, FileChanged | None | No decision control. Used for side effects like logging or cleanup | + +A few events can also rewrite content rather than only allow or block it: + +* `PreToolUse` — `updatedInput` directly under `hookSpecificOutput` replaces a tool's arguments before it runs ([details](#pretooluse-decision-control)) +* `PermissionRequest` — `updatedInput` inside the `decision` object ([details](#permissionrequest-decision-control)) +* `PostToolUse` — `updatedToolOutput` replaces the tool's result ([details](#posttooluse-decision-control)) +* `UserPromptSubmit` — cannot replace the prompt; only injects `additionalContext` alongside it + +For redaction or transformation use cases, intercept at `PreToolUse` for outbound tool inputs and `PostToolUse` for inbound tool results. Here are examples of each pattern in action: @@ -748,7 +892,7 @@ The matcher value corresponds to how the session was initiated: #### SessionStart input -In addition to the [common input fields](#common-input-fields), SessionStart hooks receive `source`, `model`, and optionally `agent_type`. The `source` field indicates how the session started: `"startup"` for new sessions, `"resume"` for resumed sessions, `"clear"` after `/clear`, or `"compact"` after compaction. The `model` field contains the model identifier. If you start Claude Code with `claude --agent `, an `agent_type` field contains the agent name. +In addition to the [common input fields](#common-input-fields), SessionStart hooks receive `source` and optionally `model`, `agent_type`, and `session_title`. The `source` field indicates how the session started: `"startup"` for new sessions, `"resume"` for resumed sessions, `"clear"` after `/clear`, or `"compact"` after compaction. The `model` field contains the active model identifier. It can be omitted, for example after `/clear` or when a session is restored through conversation recovery, so check for the field before reading it. If you start Claude Code with `claude --agent `, an `agent_type` field contains the agent name. The `session_title` field carries the current session title if one is already set, for example via `--name` or `/rename`. A hook that emits `sessionTitle` can check `session_title` first to avoid overwriting a title the user set explicitly. ```json theme={null} { @@ -765,19 +909,37 @@ In addition to the [common input fields](#common-input-fields), SessionStart hoo Any text your hook script prints to stdout is added as context for Claude. In addition to the [JSON output fields](#json-output) available to all hooks, you can return these event-specific fields: -| Field | Description | -| :------------------ | :------------------------------------------------------------------------ | -| `additionalContext` | String added to Claude's context. Multiple hooks' values are concatenated | +| Field | Description | +| :------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `additionalContext` | String added to Claude's context at the start of the conversation, before the first prompt. See [Add context for Claude](#add-context-for-claude) for how the text is delivered and what to put in it | +| `initialUserMessage` | String used as the first user message of the session. Applies in [non-interactive mode](/en/headless) (`-p`), where it becomes the first turn even if no prompt is provided. If a prompt is provided, it follows as the next turn. Unlike `additionalContext`, which attaches to an existing turn, this creates the turn | +| `sessionTitle` | Sets the session title, with the same effect as `/rename`. Use to name sessions automatically from the launch folder, git branch, or worktree name. Applies only when `source` is `"startup"` or `"resume"`; ignored on `"clear"` and `"compact"` | +| `watchPaths` | Array of absolute paths to watch for [FileChanged](#filechanged) events during this session | +| `reloadSkills` | Boolean. When `true`, Claude Code re-scans the [skill](/en/skills) and command directories after the SessionStart hooks complete, so skills the hook installed are available in the same session, starting with the first prompt | ```json theme={null} { "hookSpecificOutput": { "hookEventName": "SessionStart", - "additionalContext": "My additional context here" + "additionalContext": "Current branch: feat/auth-refactor\nUncommitted changes: src/auth.ts, src/login.tsx\nActive issue: #4211 Migrate to OAuth2", + "sessionTitle": "auth-refactor" } } ``` +Since plain stdout already reaches Claude for this event, a hook that only loads context can print to stdout directly without building JSON. Use the JSON form when you need to combine context with other fields such as `suppressOutput` or `sessionTitle`. + +Use `reloadSkills` when a SessionStart hook installs or updates skills. Skill discovery normally runs before SessionStart hooks finish, so files the hook writes into `~/.claude/skills/` or `.claude/skills/` would otherwise only appear in the next session. This example syncs a shared skills repository and requests the re-scan: + +```bash theme={null} +#!/bin/bash + +git -C ~/.claude/skills/team-skills pull --quiet 2>/dev/null || \ + git clone --quiet https://git.example.com/your-org/team-skills.git ~/.claude/skills/team-skills + +echo '{"hookSpecificOutput": {"hookEventName": "SessionStart", "reloadSkills": true}}' +``` + #### Persist environment variables SessionStart hooks have access to the `CLAUDE_ENV_FILE` environment variable, which provides a file path where you can persist environment variables for subsequent Bash commands. @@ -818,9 +980,57 @@ exit 0 Any variables written to this file will be available in all subsequent Bash commands that Claude Code executes during the session. - `CLAUDE_ENV_FILE` is available for SessionStart, [CwdChanged](#cwdchanged), and [FileChanged](#filechanged) hooks. Other hook types do not have access to this variable. + `CLAUDE_ENV_FILE` is available for SessionStart, [Setup](#setup), [CwdChanged](#cwdchanged), and [FileChanged](#filechanged) hooks. Other hook types do not have access to this variable. +### Setup + +Fires only when you launch Claude Code with `--init-only`, or with `--init` or `--maintenance` in print mode (`-p`). It does not fire on normal startup. Use it for one-time dependency installation or scheduled cleanup that you trigger explicitly from CI or scripts, separate from normal session startup. For per-session initialization, use [SessionStart](#sessionstart) instead. + +The matcher value corresponds to the CLI flag that triggered the hook: + +| Matcher | When it fires | +| :------------ | :----------------------------------------- | +| `init` | `claude --init-only` or `claude -p --init` | +| `maintenance` | `claude -p --maintenance` | + +`--init-only` runs Setup hooks and `SessionStart` hooks with the `startup` matcher, then exits without starting a conversation. `--init` and `--maintenance` fire Setup hooks only when combined with `-p` (print mode); in an interactive session those two flags do not currently fire Setup hooks. + +Because Setup does not fire on every launch, a plugin that needs a dependency installed cannot rely on Setup alone. The practical pattern is to check for the dependency on first use and install on miss, for example a hook or skill that tests for `${CLAUDE_PLUGIN_DATA}/node_modules` and runs `npm install` if absent. See the [persistent data directory](/en/plugins-reference#persistent-data-directory) for where to store installed dependencies. + +#### Setup input + +In addition to the [common input fields](#common-input-fields), Setup hooks receive a `trigger` field set to either `"init"` or `"maintenance"`: + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", + "cwd": "/Users/...", + "hook_event_name": "Setup", + "trigger": "init" +} +``` + +#### Setup decision control + +Setup hooks cannot block. On exit code 2, stderr is shown to the user; on any other non-zero exit code, stderr appears only when you launch with `--verbose`. In both cases execution continues. To pass information into Claude's context, return `additionalContext` in JSON output; plain stdout is written to the debug log only. In addition to the [JSON output fields](#json-output) available to all hooks, you can return these event-specific fields: + +| Field | Description | +| :------------------ | :------------------------------------------------------------------------ | +| `additionalContext` | String added to Claude's context. Multiple hooks' values are concatenated | + +```json theme={null} +{ + "hookSpecificOutput": { + "hookEventName": "Setup", + "additionalContext": "Dependencies installed: node_modules, .venv" + } +} +``` + +Setup hooks have access to `CLAUDE_ENV_FILE`. Variables written to that file persist into subsequent Bash commands for the session, just as in [SessionStart hooks](#persist-environment-variables). Only `type: "command"` and `type: "mcp_tool"` hooks are supported. + ### InstructionsLoaded Fires when a `CLAUDE.md` or `.claude/rules/*.md` file is loaded into context. This event fires at session start for eagerly-loaded files and again later when files are lazily loaded, for example when Claude accesses a subdirectory that contains a nested `CLAUDE.md` or when conditional rules with `paths:` frontmatter match. The hook does not support blocking or decision control. It runs asynchronously for observability purposes. @@ -862,6 +1072,8 @@ Runs when the user submits a prompt, before Claude processes it. This allows you to add additional context based on the prompt/conversation, validate prompts, or block certain types of prompts. +`UserPromptSubmit` hooks have a default timeout of 30 seconds for `command`, `http`, and `mcp_tool` types, shorter than the 600-second default for those types on most other events. Because this hook runs before every prompt and blocks model processing until it completes, a stuck hook stalls the session. If your hook needs more time, set the `timeout` field in the hook entry. + #### UserPromptSubmit input In addition to the [common input fields](#common-input-fields), UserPromptSubmit hooks receive the `prompt` field containing the text the user submitted. @@ -890,12 +1102,13 @@ Plain stdout is shown as hook output in the transcript. The `additionalContext` To block a prompt, return a JSON object with `decision` set to `"block"`: -| Field | Description | -| :------------------ | :----------------------------------------------------------------------------------------------------------------- | -| `decision` | `"block"` prevents the prompt from being processed and erases it from context. Omit to allow the prompt to proceed | -| `reason` | Shown to the user when `decision` is `"block"`. Not added to context | -| `additionalContext` | String added to Claude's context | -| `sessionTitle` | Sets the session title, same effect as `/rename`. Use to name sessions automatically based on the prompt content | +| Field | Description | +| :----------------------- | :--------------------------------------------------------------------------------------------------------------------- | +| `decision` | `"block"` prevents the prompt from being processed and erases it from context. Omit to allow the prompt to proceed | +| `reason` | Shown to the user when `decision` is `"block"`. Not added to context | +| `additionalContext` | String added to Claude's context alongside the submitted prompt. See [Add context for Claude](#add-context-for-claude) | +| `sessionTitle` | Sets the session title. Use to name sessions automatically based on the prompt content | +| `suppressOriginalPrompt` | If `true` when `decision` is `"block"`, omits the original prompt text from the block message shown to the user | ```json theme={null} { @@ -945,11 +1158,11 @@ In addition to the [common input fields](#common-input-fields), UserPromptExpans `UserPromptExpansion` hooks can block the expansion or add context. All [JSON output fields](#json-output) are available. -| Field | Description | -| :------------------ | :------------------------------------------------------------------------------- | -| `decision` | `"block"` prevents the slash command from expanding. Omit to allow it to proceed | -| `reason` | Shown to the user when `decision` is `"block"` | -| `additionalContext` | String added to Claude's context alongside the expanded prompt | +| Field | Description | +| :------------------ | :-------------------------------------------------------------------------------------------------------------------- | +| `decision` | `"block"` prevents the slash command from expanding. Omit to allow it to proceed | +| `reason` | Shown to the user when `decision` is `"block"` | +| `additionalContext` | String added to Claude's context alongside the expanded prompt. See [Add context for Claude](#add-context-for-claude) | ```json theme={null} { @@ -962,6 +1175,140 @@ In addition to the [common input fields](#common-input-fields), UserPromptExpans } ``` +### MessageDisplay + +Runs while an assistant message streams to the screen. Claude Code displays the message in increments: each time a batch of newly completed lines is ready to render, the hook runs once with those lines and Claude Code renders the hook's replacement text in their place. A long message produces several calls; a short message may produce only one. + +Use MessageDisplay to: + +* strip markdown for a minimal display +* transform the text an Agent SDK application shows its users +* redact API keys or internal hostnames from Claude's responses + +Claude Code holds each batch until your hook returns, so keep the hook fast. If the hook fails or times out, Claude Code displays the original text. The default timeout for this event is 10 seconds; if your hook needs more time, set the `timeout` field in the hook entry. + +MessageDisplay is display-only: the replacement text changes only what is rendered on screen. The transcript and what Claude sees keep the original text, so Claude never sees the replacement, and verbose mode shows the original. The hook receives assistant message text only, so tool results and the text you type render unchanged. + +MessageDisplay does not support matchers and fires for every assistant message that streams text; messages with no text, such as tool-call-only responses, do not trigger it. + +In non-interactive runs, including Agent SDK queries and `claude -p`, MessageDisplay runs once per assistant message instead of once per batch of lines. The single call arrives after the message completes and carries the full message text: `index` is `0`, `final` is `true`, and `delta` holds the entire message. A hook that collects the `delta` text for each message receives the same total text in both modes. + +#### MessageDisplay input + +In addition to the [common input fields](#common-input-fields), MessageDisplay hooks receive identifiers for the turn and message, the position of this call within the message, and the new text in `delta`. Batch boundaries depend on how the text streams, so use `index` and `final` to track progress through a message rather than expecting lines to be grouped a particular way. + +| Field | Description | +| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `turn_id` | UUID of the current turn | +| `message_id` | UUID of the assistant message being displayed. Stable across every batch of the same message. This is not the API `msg_…` id, so it cannot be correlated with transcript message ids | +| `index` | Zero-based index of this batch within the message | +| `final` | `true` on the message's last batch. Each message has exactly one final batch | +| `delta` | The newly completed lines since the prior batch, terminating newlines included. Always whole lines, except the final batch which may end mid-line. In interactive runs, the final batch's delta is empty when the message ends on a newline, so treat `final`, not a non-empty delta, as the end-of-message signal. In Agent SDK and `claude -p` runs, the single call carries the entire message | + +```json theme={null} +{ + "session_id": "abc123", + "transcript_path": "/Users/.../.claude/projects/.../transcript.jsonl", + "cwd": "/Users/my-project", + "hook_event_name": "MessageDisplay", + "turn_id": "0c9e6a2f-7d41-4f4e-9a15-3f4f7c2b8d10", + "message_id": "5b2a9c8e-1f63-4d8a-b7c4-9e0d2a6f1c3b", + "index": 0, + "final": false, + "delta": "Here is the plan:\n" +} +``` + +#### MessageDisplay output + +In addition to the [JSON output fields](#json-output) available to all hooks, MessageDisplay hooks can return `displayContent` to replace the delta on screen: + +| Field | Description | +| :--------------- | :-------------------------------------------------------------------- | +| `displayContent` | Text displayed in place of the delta. Omit it to display the original | + +MessageDisplay hooks have no decision control. They cannot block the message or change what is stored in the transcript or sent to Claude. + +This example strips markdown formatting from Claude's responses for a plain-text display. The script reads each batch from stdin, removes bold markers and inline code backticks from `delta`, and returns the result as `displayContent`. + + + + Register a command hook for the event in your settings file: + + ```json theme={null} + { + "hooks": { + "MessageDisplay": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/plain-display.sh", + "args": [] + } + ] + } + ] + } + } + ``` + + Save this script to `.claude/hooks/plain-display.sh` in your project and make it executable with `chmod +x`: + + ```bash theme={null} + #!/bin/bash + jq '{hookSpecificOutput: {hookEventName: "MessageDisplay", displayContent: (.delta | gsub("\\*\\*"; "") | gsub("`"; ""))}}' + ``` + + The script needs `jq` on your `PATH`. + + + + Register a command hook that runs the script through PowerShell: + + ```json theme={null} + { + "hooks": { + "MessageDisplay": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${CLAUDE_PROJECT_DIR}/.claude/hooks/plain-display.ps1" + ] + } + ] + } + ] + } + } + ``` + + The `-NoProfile` flag skips loading your PowerShell profile so the hook starts fast, and `-ExecutionPolicy Bypass` lets PowerShell run the local script file. + + Save this script to `.claude/hooks/plain-display.ps1` in your project: + + ```powershell theme={null} + $batch = [Console]::In.ReadToEnd() | ConvertFrom-Json + $text = $batch.delta -replace '\*\*', '' -replace '`', '' + @{ + hookSpecificOutput = @{ + hookEventName = "MessageDisplay" + displayContent = $text + } + } | ConvertTo-Json + ``` + + + +Batches with no markdown pass through unchanged. If the script fails, for example because `jq` is missing, Claude Code displays the original text and notes the failure only in [debug output](#debug-hooks), not in the session. + ### PreToolUse Runs after Claude creates tool parameters and before processing the tool call. Matches on tool name: `Bash`, `Edit`, `Write`, `Read`, `Glob`, `Grep`, `Agent`, `WebFetch`, `WebSearch`, `AskUserQuestion`, `ExitPlanMode`, and any [MCP tool names](#match-mcp-tools). @@ -1065,6 +1412,25 @@ Spawns a [subagent](/en/sub-agents). | `subagent_type` | string | `"Explore"` | Type of specialized agent to use | | `model` | string | `"sonnet"` | Optional model alias to override the default | +In `PostToolUse`, `tool_response` for a completed Agent call carries the subagent's final text along with usage telemetry. Read these fields to record per-subagent cost from a hook: + +| Field | Type | Example | Description | +| :------------------ | :----- | :---------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | +| `status` | string | `"completed"` | `"completed"` for synchronous calls, `"async_launched"` for `run_in_background: true` | +| `agentId` | string | `"a4d2c8f1e0b3a297"` | Identifier for the subagent run | +| `content` | array | `[{"type": "text", "text": "Found 12 endpoints..."}]` | The subagent's final text blocks | +| `resolvedModel` | string | `"claude-sonnet-4-5"` | Model the subagent ran on, which may differ from the requested model. {/* min-version: 2.1.174 */}Requires Claude Code v2.1.174 or later | +| `totalTokens` | number | `12450` | Total tokens billed across the subagent's turns | +| `totalDurationMs` | number | `48211` | Wall-clock duration of the subagent run | +| `totalToolUseCount` | number | `7` | Count of tool calls the subagent made | +| `usage` | object | `{"input_tokens": 8320, ...}` | Per-type token breakdown: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` | + +For `run_in_background: true` calls, the tool returns immediately after launching the subagent, so `tool_response` carries no usage fields. It has `status: "async_launched"`, `agentId`, `description`, `prompt`, `outputFile`, and `resolvedModel`. + +The `resolvedModel` field names the model the subagent actually runs on, which can differ from the `model` value in `tool_input`, such as when `availableModels` or another override applies. It requires Claude Code v2.1.174 or later. + + + ##### AskUserQuestion Asks the user one to four multiple-choice questions. @@ -1074,6 +1440,18 @@ Asks the user one to four multiple-choice questions. | `questions` | array | `[{"question": "Which framework?", "header": "Framework", "options": [{"label": "React"}], "multiSelect": false}]` | Questions to present, each with a `question` string, short `header`, `options` array, and optional `multiSelect` flag | | `answers` | object | `{"Which framework?": "React"}` | Optional. Maps question text to the selected option label. Multi-select answers join labels with commas. Claude does not set this field; supply it via `updatedInput` to answer programmatically | +##### ExitPlanMode + +Presents a plan and asks the user to approve it before Claude leaves [plan mode](/en/permission-modes#analyze-before-you-edit-with-plan-mode). Claude writes the plan to a file on disk before calling the tool, so the literal `tool_input` from the model only carries `allowedPrompts`. Claude Code injects the plan content and file path before passing the input to hooks. + +| Field | Type | Example | Description | +| :--------------- | :----- | :------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `plan` | string | `"## Refactor auth\n1. Extract..."` | Plan content in Markdown. Injected from the plan file on disk | +| `planFilePath` | string | `"/Users/.../plans/refactor-auth.md"` | Path to the plan file. Injected | +| `allowedPrompts` | array | `[{"tool": "Bash", "prompt": "run tests"}]` | Optional. Prompt-based permissions Claude is requesting to implement the plan, each with a `tool` name and a `prompt` describing the category of action | + +In `PostToolUse`, `tool_response` is an object with `plan` and `filePath` fields holding the approved plan, plus internal status flags. Read `tool_response.plan` for the plan content rather than re-reading the file from disk. + #### PreToolUse decision control `PreToolUse` hooks can control whether a tool call proceeds. Unlike other hooks that use a top-level `decision` field, PreToolUse returns its decision inside a `hookSpecificOutput` object. This gives it richer control: four outcomes (allow, deny, ask, or defer) plus the ability to modify tool input before execution. @@ -1083,7 +1461,7 @@ Asks the user one to four multiple-choice questions. | `permissionDecision` | `"allow"` skips the permission prompt. `"deny"` prevents the tool call. `"ask"` prompts the user to confirm. `"defer"` exits gracefully so the tool can be resumed later. [Deny and ask rules](/en/permissions#manage-permissions) are still evaluated regardless of what the hook returns | | `permissionDecisionReason` | For `"allow"` and `"ask"`, shown to the user but not Claude. For `"deny"`, shown to Claude. For `"defer"`, ignored | | `updatedInput` | Modifies the tool's input parameters before execution. Replaces the entire input object, so include unchanged fields alongside modified ones. Combine with `"allow"` to auto-approve, or `"ask"` to show the modified input to the user. For `"defer"`, ignored | -| `additionalContext` | String added to Claude's context before the tool executes. For `"defer"`, ignored | +| `additionalContext` | String added to Claude's context alongside the tool result. Ignored when `permissionDecision` is `"defer"`. See [Add context for Claude](#add-context-for-claude) | When multiple PreToolUse hooks return different decisions, precedence is `deny` > `defer` > `ask` > `allow`. @@ -1147,9 +1525,9 @@ There is no timeout or retry limit. The session remains on disk until you resume If the deferred tool is no longer available when you resume, the process exits with `stop_reason: "tool_deferred_unavailable"` and `is_error: true` before the hook fires. This happens when an MCP server that provided the tool is not connected for the resumed session. The `deferred_tool_use` payload is still included so you can identify which tool went missing. - - `--resume` does not restore the permission mode from the prior session. Pass the same `--permission-mode` flag on resume that was active when the tool was deferred. Claude Code logs a warning if the modes differ. - + + `--resume` restores the permission mode that was active when the tool was deferred, so you do not need to pass `--permission-mode` again. The exceptions are `plan` and `bypassPermissions`, which are never carried over. Passing `--permission-mode` explicitly on resume overrides the restored value. + ### PermissionRequest @@ -1220,7 +1598,7 @@ The `updatedPermissions` output field and the [`permission_suggestions` input fi | `addRules` | `rules`, `behavior`, `destination` | Adds permission rules. `rules` is an array of `{toolName, ruleContent?}` objects. Omit `ruleContent` to match the whole tool. `behavior` is `"allow"`, `"deny"`, or `"ask"` | | `replaceRules` | `rules`, `behavior`, `destination` | Replaces all rules of the given `behavior` at the `destination` with the provided `rules` | | `removeRules` | `rules`, `behavior`, `destination` | Removes matching rules of the given `behavior` | -| `setMode` | `mode`, `destination` | Changes the permission mode. Valid modes are `default`, `acceptEdits`, `dontAsk`, `bypassPermissions`, and `plan` | +| `setMode` | `mode`, `destination` | Changes the permission mode. Valid modes are `default`, `auto`, `acceptEdits`, `dontAsk`, `bypassPermissions`, and `plan` | | `addDirectories` | `directories`, `destination` | Adds working directories. `directories` is an array of path strings | | `removeDirectories` | `directories`, `destination` | Removes working directories | @@ -1265,32 +1643,50 @@ Matches on tool name, same values as PreToolUse. "filePath": "/path/to/file.txt", "success": true }, - "tool_use_id": "toolu_01ABC123..." + "tool_use_id": "toolu_01ABC123...", + "duration_ms": 12 } ``` +| Field | Description | +| :------------ | :------------------------------------------------------------------------------------------------------------ | +| `duration_ms` | Optional. Tool execution time in milliseconds. Excludes time spent in permission prompts and PreToolUse hooks | + #### PostToolUse decision control `PostToolUse` hooks can provide feedback to Claude after tool execution. In addition to the [JSON output fields](#json-output) available to all hooks, your hook script can return these event-specific fields: -| Field | Description | -| :--------------------- | :----------------------------------------------------------------------------------------- | -| `decision` | `"block"` prompts Claude with the `reason`. Omit to allow the action to proceed | -| `reason` | Explanation shown to Claude when `decision` is `"block"` | -| `additionalContext` | Additional context for Claude to consider | -| `updatedMCPToolOutput` | For [MCP tools](#match-mcp-tools) only: replaces the tool's output with the provided value | +| Field | Description | +| :--------------------- | :--------------------------------------------------------------------------------------------------------------------------------- | +| `decision` | `"block"` adds the `reason` next to the tool result. Claude still sees the original output; to replace it, use `updatedToolOutput` | +| `reason` | Explanation shown to Claude when `decision` is `"block"` | +| `additionalContext` | String added to Claude's context alongside the tool result. See [Add context for Claude](#add-context-for-claude) | +| `updatedToolOutput` | Replaces the tool's output with the provided value before it is sent to Claude. The value must match the tool's output shape | +| `updatedMCPToolOutput` | Replaces the output for [MCP tools](#match-mcp-tools) only. Prefer `updatedToolOutput`, which works for all tools | + +The example below replaces the output of a `Bash` call. The replacement value matches the `Bash` tool's output shape: ```json theme={null} { - "decision": "block", - "reason": "Explanation for decision", "hookSpecificOutput": { "hookEventName": "PostToolUse", - "additionalContext": "Additional information for Claude" + "additionalContext": "Additional information for Claude", + "updatedToolOutput": { + "stdout": "[redacted]", + "stderr": "", + "interrupted": false, + "isImage": false + } } } ``` + + `updatedToolOutput` only changes what Claude sees. The tool has already run by the time the hook fires, so any files written, commands executed, or network requests sent have already taken effect. Telemetry such as OpenTelemetry tool spans and analytics events also captures the original output before the hook runs. To prevent or modify a tool call before it runs, use a [PreToolUse](#pretooluse) hook instead. + + The replacement value must match the tool's output shape. Built-in tools return structured objects rather than plain strings. For example, `Bash` returns an object with `stdout`, `stderr`, `interrupted`, and `isImage` fields. For built-in tools, a value that does not match the tool's output schema is ignored and the original output is used. MCP tool output is passed through without schema validation. Stripping error details that Claude needs can cause it to proceed on a false assumption. + + ### PostToolUseFailure Runs when a tool execution fails. This event fires for tool calls that throw errors or return failure results. Use this to log failures, send alerts, or provide corrective feedback to Claude. @@ -1315,22 +1711,24 @@ PostToolUseFailure hooks receive the same `tool_name` and `tool_input` fields as }, "tool_use_id": "toolu_01ABC123...", "error": "Command exited with non-zero status code 1", - "is_interrupt": false + "is_interrupt": false, + "duration_ms": 4187 } ``` -| Field | Description | -| :------------- | :------------------------------------------------------------------------------ | -| `error` | String describing what went wrong | -| `is_interrupt` | Optional boolean indicating whether the failure was caused by user interruption | +| Field | Description | +| :------------- | :------------------------------------------------------------------------------------------------------------ | +| `error` | String describing what went wrong | +| `is_interrupt` | Optional boolean indicating whether the failure was caused by user interruption | +| `duration_ms` | Optional. Tool execution time in milliseconds. Excludes time spent in permission prompts and PreToolUse hooks | #### PostToolUseFailure decision control `PostToolUseFailure` hooks can provide context to Claude after a tool failure. In addition to the [JSON output fields](#json-output) available to all hooks, your hook script can return these event-specific fields: -| Field | Description | -| :------------------ | :------------------------------------------------------------ | -| `additionalContext` | Additional context for Claude to consider alongside the error | +| Field | Description | +| :------------------ | :---------------------------------------------------------------------------------------------------------- | +| `additionalContext` | String added to Claude's context alongside the error. See [Add context for Claude](#add-context-for-claude) | ```json theme={null} { @@ -1383,9 +1781,9 @@ In addition to the [common input fields](#common-input-fields), PostToolBatch ho `PostToolBatch` hooks can inject context for Claude. In addition to the [JSON output fields](#json-output) available to all hooks, your hook script can return these event-specific fields: -| Field | Description | -| :------------------ | :------------------------------------------------------ | -| `additionalContext` | Context string injected once before the next model call | +| Field | Description | +| :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `additionalContext` | Context string injected once before the next model call. See [Add context for Claude](#add-context-for-claude) for delivery details, what to put in it, and how resumed sessions handle past values | ```json theme={null} { @@ -1396,12 +1794,6 @@ In addition to the [common input fields](#common-input-fields), PostToolBatch ho } ``` - - Injected `additionalContext` is persisted to the session transcript. On `--continue` or `--resume`, the saved text is replayed from disk and the hook does not re-run for past turns. Prefer static context such as conventions or file-type guidance over dynamic values like timestamps or the current commit SHA, since those become stale on resume. - - Frame the context as factual information rather than imperative system instructions. Text written as out-of-band system commands can trigger Claude's prompt-injection defenses, which surfaces the injection to the user instead of acting on it. - - Returning `decision: "block"` or `continue: false` stops the agentic loop before the next model call. ### PermissionDenied @@ -1452,7 +1844,7 @@ When `retry` is `true`, Claude Code adds a message to the conversation telling t ### Notification -Runs when Claude Code sends notifications. Matches on notification type: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`. Omit the matcher to run hooks for all notification types. +Runs when Claude Code sends notifications. Matches on notification type: `permission_prompt`, `idle_prompt`, `auth_success`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`. Omit the matcher to run hooks for all notification types. Use separate matchers to run different handlers depending on the notification type. This configuration triggers a permission-specific alert script when Claude needs permission approval and a different notification when Claude has been idle: @@ -1493,25 +1885,21 @@ In addition to the [common input fields](#common-input-fields), Notification hoo "transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl", "cwd": "/Users/...", "hook_event_name": "Notification", - "message": "Claude needs your permission to use Bash", + "message": "Claude needs your permission", "title": "Permission needed", "notification_type": "permission_prompt" } ``` -Notification hooks cannot block or modify notifications. In addition to the [JSON output fields](#json-output) available to all hooks, you can return `additionalContext` to add context to the conversation: - -| Field | Description | -| :------------------ | :------------------------------- | -| `additionalContext` | String added to Claude's context | +Notification hooks cannot block or modify notifications. They are intended for side effects such as forwarding the notification to an external service. The [common JSON output fields](#json-output) such as `systemMessage` apply. ### SubagentStart -Runs when a Claude Code subagent is spawned via the Agent tool. Supports matchers to filter by agent type name (built-in agents like `Bash`, `Explore`, `Plan`, or custom agent names from `.claude/agents/`). +Runs when a Claude Code subagent is spawned via the Agent tool. Supports matchers to filter by agent type name. For built-in agents, this is the agent name like `general-purpose`, `Explore`, or `Plan`. For [custom subagents](/en/sub-agents), this is the `name` field from the agent's frontmatter, not the filename. #### SubagentStart input -In addition to the [common input fields](#common-input-fields), SubagentStart hooks receive `agent_id` with the unique identifier for the subagent and `agent_type` with the agent name (built-in agents like `"Bash"`, `"Explore"`, `"Plan"`, or custom agent names). +In addition to the [common input fields](#common-input-fields), SubagentStart hooks receive `agent_id` with the unique identifier for the subagent and `agent_type` with the agent name (built-in agents like `"general-purpose"`, `"Explore"`, `"Plan"`, or custom agent names). ```json theme={null} { @@ -1526,9 +1914,9 @@ In addition to the [common input fields](#common-input-fields), SubagentStart ho SubagentStart hooks cannot block subagent creation, but they can inject context into the subagent. In addition to the [JSON output fields](#json-output) available to all hooks, you can return: -| Field | Description | -| :------------------ | :------------------------------------- | -| `additionalContext` | String added to the subagent's context | +| Field | Description | +| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `additionalContext` | String added to the subagent's context at the start of its conversation, before its first prompt. See [Add context for Claude](#add-context-for-claude) | ```json theme={null} { @@ -1547,6 +1935,8 @@ Runs when a Claude Code subagent has finished responding. Matches on agent type, In addition to the [common input fields](#common-input-fields), SubagentStop hooks receive `stop_hook_active`, `agent_id`, `agent_type`, `agent_transcript_path`, and `last_assistant_message`. The `agent_type` field is the value used for matcher filtering. The `transcript_path` is the main session's transcript, while `agent_transcript_path` is the subagent's own transcript stored in a nested `subagents/` folder. The `last_assistant_message` field contains the text content of the subagent's final response, so hooks can access it without parsing the transcript file. +SubagentStop hooks also receive the `background_tasks` and `session_crons` arrays described under [Stop input](#stop-input), available in Claude Code v2.1.145 or later. Both arrays are scoped to the parent session, not the subagent. + ```json theme={null} { "session_id": "abc123", @@ -1558,11 +1948,13 @@ In addition to the [common input fields](#common-input-fields), SubagentStop hoo "agent_id": "def456", "agent_type": "Explore", "agent_transcript_path": "~/.claude/projects/.../abc123/subagents/agent-def456.jsonl", - "last_assistant_message": "Analysis complete. Found 3 potential issues..." + "last_assistant_message": "Analysis complete. Found 3 potential issues...", + "background_tasks": [], + "session_crons": [] } ``` -SubagentStop hooks use the same decision control format as [Stop hooks](#stop-decision-control). +SubagentStop hooks use the same decision control format as [Stop hooks](#stop-decision-control), including `hookSpecificOutput.additionalContext` with `hookEventName` set to `"SubagentStop"`, for non-error feedback that keeps the subagent running. Returning `decision: "block"` with a `reason` keeps the subagent running and delivers `reason` to the subagent as its next instruction. To inject context into the parent session after a subagent returns, use a [`PostToolUse`](#posttooluse) hook on the `Agent` tool instead. ### TaskCreated @@ -1585,17 +1977,17 @@ In addition to the [common input fields](#common-input-fields), TaskCreated hook "task_subject": "Implement user authentication", "task_description": "Add login and signup endpoints", "teammate_name": "implementer", - "team_name": "my-project" + "team_name": "session-a1b2c3d4" } ``` -| Field | Description | -| :----------------- | :---------------------------------------------------- | -| `task_id` | Identifier of the task being created | -| `task_subject` | Title of the task | -| `task_description` | Detailed description of the task. May be absent | -| `teammate_name` | Name of the teammate creating the task. May be absent | -| `team_name` | Name of the team. May be absent | +| Field | Description | +| :----------------- | :------------------------------------------------------------------------- | +| `task_id` | Identifier of the task being created | +| `task_subject` | Title of the task | +| `task_description` | Detailed description of the task. May be absent | +| `teammate_name` | Name of the teammate creating the task. May be absent | +| `team_name` | Deprecated. Session-derived team name; will be removed in a future release | #### TaskCreated decision control @@ -1640,17 +2032,17 @@ In addition to the [common input fields](#common-input-fields), TaskCompleted ho "task_subject": "Implement user authentication", "task_description": "Add login and signup endpoints", "teammate_name": "implementer", - "team_name": "my-project" + "team_name": "session-a1b2c3d4" } ``` -| Field | Description | -| :----------------- | :------------------------------------------------------ | -| `task_id` | Identifier of the task being completed | -| `task_subject` | Title of the task | -| `task_description` | Detailed description of the task. May be absent | -| `teammate_name` | Name of the teammate completing the task. May be absent | -| `team_name` | Name of the team. May be absent | +| Field | Description | +| :----------------- | :------------------------------------------------------------------------- | +| `task_id` | Identifier of the task being completed | +| `task_subject` | Title of the task | +| `task_description` | Detailed description of the task. May be absent | +| `teammate_name` | Name of the teammate completing the task. May be absent | +| `team_name` | Deprecated. Session-derived team name; will be removed in a future release | #### TaskCompleted decision control @@ -1681,9 +2073,42 @@ Runs when the main Claude Code agent has finished responding. Does not run if the stoppage occurred due to a user interrupt. API errors fire [StopFailure](#stopfailure) instead. + + The [`/goal`](/en/goal) command is a built-in shortcut for a session-scoped prompt-based Stop hook. Use it when you want Claude to keep working until a condition holds without writing hook configuration. + + #### Stop input -In addition to the [common input fields](#common-input-fields), Stop hooks receive `stop_hook_active` and `last_assistant_message`. The `stop_hook_active` field is `true` when Claude Code is already continuing as a result of a stop hook. Check this value or process the transcript to prevent Claude Code from running indefinitely. The `last_assistant_message` field contains the text content of Claude's final response, so hooks can access it without parsing the transcript file. +In addition to the [common input fields](#common-input-fields), Stop hooks receive `stop_hook_active`, `last_assistant_message`, `background_tasks`, and `session_crons`. The `stop_hook_active` field is `true` when Claude Code is already continuing as a result of a stop hook. Check this value or process the transcript to avoid blocking on a condition that will never resolve. Claude Code overrides the hook and ends the turn after 8 consecutive blocks. + +The `last_assistant_message` field contains the text content of Claude's final response, so hooks can access it without parsing the transcript file. + +The `background_tasks` and `session_crons` arrays, available in Claude Code v2.1.145 or later, let hooks distinguish "session is done" from "session is paused waiting for background work to wake it back up". Both arrays are present when the task registry is reachable and are empty when nothing is in flight or scheduled. + +Each entry in `background_tasks` describes one in-flight task and uses these fields: + +| Field | Description | +| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Task identifier | +| `type` | Friendly task-type label such as `shell`, `subagent`, `monitor`, `workflow`, `teammate`, `cloud session`, or `MCP task`. Each label identifies which Claude Code feature created the task. Falls back to the raw discriminant for unrecognized types | +| `status` | Current task status | +| `description` | Free-text description, capped at 1000 characters with an in-string `… [+N chars]` marker when clipped | +| `command` | Shell command line, capped at 1000 characters. Present only for `shell` tasks | +| `agent_type` | Subagent type name. Present only for `subagent` tasks | +| `server` | MCP server name. Present only for `monitor` and `MCP task` tasks | +| `tool` | MCP tool name. Present only for `monitor` and `MCP task` tasks | +| `name` | Workflow name. Present only for `workflow` tasks | + +Each entry in `session_crons` describes one session-scoped scheduled wakeup, sourced from `CronCreate`, `ScheduleWakeup`, and `/loop`: + +| Field | Description | +| :---------- | :------------------------------------------------------------------------------------------------------------------- | +| `id` | Cron task identifier | +| `schedule` | Cron expression, for example `0 9 * * 1-5` | +| `recurring` | `false` for one-shot wakeups whose schedule encodes a single fire time, `true` for tasks that re-fire on every match | +| `prompt` | Prompt submitted when the cron fires, capped at 1000 characters with the same `… [+N chars]` marker | + +This example shows a Stop input with one in-flight shell task and one recurring cron: ```json theme={null} { @@ -1693,7 +2118,24 @@ In addition to the [common input fields](#common-input-fields), Stop hooks recei "permission_mode": "default", "hook_event_name": "Stop", "stop_hook_active": true, - "last_assistant_message": "I've completed the refactoring. Here's a summary..." + "last_assistant_message": "I've completed the refactoring. Here's a summary...", + "background_tasks": [ + { + "id": "task-001", + "type": "shell", + "status": "running", + "description": "tail logs", + "command": "tail -f /var/log/syslog" + } + ], + "session_crons": [ + { + "id": "cron-001", + "schedule": "0 9 * * 1-5", + "recurring": true, + "prompt": "check the build" + } + ] } ``` @@ -1701,10 +2143,11 @@ In addition to the [common input fields](#common-input-fields), Stop hooks recei `Stop` and `SubagentStop` hooks can control whether Claude continues. In addition to the [JSON output fields](#json-output) available to all hooks, your hook script can return these event-specific fields: -| Field | Description | -| :--------- | :------------------------------------------------------------------------- | -| `decision` | `"block"` prevents Claude from stopping. Omit to allow Claude to stop | -| `reason` | Required when `decision` is `"block"`. Tells Claude why it should continue | +| Field | Description | +| :------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `decision` | `"block"` prevents Claude from stopping. Omit to allow Claude to stop | +| `reason` | Required when `decision` is `"block"`. Tells Claude why it should continue | +| `hookSpecificOutput.additionalContext` | Non-error feedback for Claude. The conversation continues so Claude can act on it, but unlike `decision: "block"` it is shown in the transcript as hook feedback rather than a hook error | ```json theme={null} { @@ -1713,6 +2156,17 @@ In addition to the [common input fields](#common-input-fields), Stop hooks recei } ``` +Use `additionalContext` when the hook is working as designed and giving Claude guidance, such as "run the test suite before finishing". It keeps the conversation going through the same loop protections as `decision: "block"`, namely the `stop_hook_active` input and the 8-consecutive-continuation cap, but the transcript labels it `Stop hook feedback` and no hook error notification is shown: + +```json theme={null} +{ + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": "Please run the test suite before finishing" + } +} +``` + ### StopFailure Runs instead of [Stop](#stop) when the turn ends due to an API error. Output and exit code are ignored. Use this to log failures, send alerts, or take recovery actions when Claude cannot complete a response due to rate limits, authentication problems, or other API errors. @@ -1723,7 +2177,7 @@ In addition to the [common input fields](#common-input-fields), StopFailure hook | Field | Description | | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `error` | Error type: `rate_limit`, `authentication_failed`, `billing_error`, `invalid_request`, `server_error`, `max_output_tokens`, or `unknown` | +| `error` | Error type: `rate_limit`, `overloaded`, `authentication_failed`, `oauth_org_not_allowed`, `billing_error`, `invalid_request`, `model_not_found`, `server_error`, `max_output_tokens`, or `unknown` | | `error_details` | Additional details about the error, when available | | `last_assistant_message` | The rendered error text shown in the conversation. Unlike `Stop` and `SubagentStop`, where this field holds Claude's conversational output, for `StopFailure` it contains the API error string itself, such as `"API Error: Rate limit reached"` | @@ -1759,14 +2213,14 @@ In addition to the [common input fields](#common-input-fields), TeammateIdle hoo "permission_mode": "default", "hook_event_name": "TeammateIdle", "teammate_name": "researcher", - "team_name": "my-project" + "team_name": "session-a1b2c3d4" } ``` -| Field | Description | -| :-------------- | :-------------------------------------------- | -| `teammate_name` | Name of the teammate that is about to go idle | -| `team_name` | Name of the team | +| Field | Description | +| :-------------- | :------------------------------------------------------------------------- | +| `teammate_name` | Name of the teammate that is about to go idle | +| `team_name` | Deprecated. Session-derived team name; will be removed in a future release | #### TeammateIdle decision control @@ -1814,7 +2268,8 @@ This example logs all configuration changes for security auditing: "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-config-change.sh" + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/audit-config-change.sh", + "args": [] } ] } @@ -1934,7 +2389,7 @@ FileChanged hooks have no decision control. They cannot block the file change fr When you run `claude --worktree` or a [subagent uses `isolation: "worktree"`](/en/sub-agents#choose-the-subagent-scope), Claude Code creates an isolated working copy using `git worktree`. If you configure a WorktreeCreate hook, it replaces the default git behavior, letting you use a different version control system like SVN, Perforce, or Mercurial. -Because the hook replaces the default behavior entirely, [`.worktreeinclude`](/en/common-workflows#copy-gitignored-files-to-worktrees) is not processed. If you need to copy local configuration files like `.env` into the new worktree, do it inside your hook script. +Because the hook replaces the default behavior entirely, [`.worktreeinclude`](/en/worktrees#copy-gitignored-files-into-worktrees) is not processed. If you need to copy local configuration files like `.env` into the new worktree, do it inside your hook script. The hook must return the absolute path to the created worktree directory. Claude Code uses this path as the working directory for the isolated session. Command hooks print it on stdout; HTTP hooks return it via `hookSpecificOutput.worktreePath`. @@ -2239,6 +2694,7 @@ In addition to command, HTTP, and MCP tool hooks, Claude Code supports prompt-ba Events that support all five hook types (`command`, `http`, `mcp_tool`, `prompt`, and `agent`): +* `PermissionDenied` * `PermissionRequest` * `PostToolBatch` * `PostToolUse` @@ -2248,6 +2704,7 @@ Events that support all five hook types (`command`, `http`, `mcp_tool`, `prompt` * `SubagentStop` * `TaskCompleted` * `TaskCreated` +* `TeammateIdle` * `UserPromptExpansion` * `UserPromptSubmit` @@ -2260,13 +2717,11 @@ Events that support `command`, `http`, and `mcp_tool` hooks but not `prompt` or * `FileChanged` * `InstructionsLoaded` * `Notification` -* `PermissionDenied` * `PostCompact` * `PreCompact` * `SessionEnd` * `StopFailure` * `SubagentStart` -* `TeammateIdle` * `WorktreeCreate` * `WorktreeRemove` @@ -2303,12 +2758,13 @@ This `Stop` hook asks the LLM to evaluate whether all tasks are complete before } ``` -| Field | Required | Description | -| :-------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `type` | yes | Must be `"prompt"` | -| `prompt` | yes | The prompt text to send to the LLM. Use `$ARGUMENTS` as a placeholder for the hook input JSON. If `$ARGUMENTS` is not present, input JSON is appended to the prompt | -| `model` | no | Model to use for evaluation. Defaults to a fast model | -| `timeout` | no | Timeout in seconds. Default: 30 | +| Field | Required | Description | +| :---------------- | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | yes | Must be `"prompt"` | +| `prompt` | yes | The prompt text to send to the LLM. Use `$ARGUMENTS` as a placeholder for the hook input JSON. If `$ARGUMENTS` is not present, input JSON is appended to the prompt | +| `model` | no | Model to use for evaluation. Defaults to a fast model | +| `timeout` | no | Timeout in seconds. Default: 30 | +| `continueOnBlock` | no | When the prompt returns `ok: false`, feed the reason back to Claude and continue the turn instead of stopping. Default: `false`. Implemented as `continue: true` on the resulting `decision: "block"`. See [Response schema](#response-schema) for per-event behavior | ### Response schema @@ -2321,10 +2777,23 @@ The LLM must respond with JSON containing: } ``` -| Field | Description | -| :------- | :--------------------------------------------------------- | -| `ok` | `true` allows the action, `false` prevents it | -| `reason` | Required when `ok` is `false`. Explanation shown to Claude | +| Field | Description | +| :------- | :---------------------------------------------------------------------------------------- | +| `ok` | `true` to allow. `false` produces a `decision: "block"`. See the per-event behavior below | +| `reason` | Required when `ok` is `false`. Used as the block reason | + +What happens on `ok: false` depends on the event: + +* `Stop` and `SubagentStop`: the reason is fed back to Claude as its next instruction and the turn continues +* `PreToolUse`: the tool call is denied and the reason is returned to Claude as the tool error, equivalent to a command hook's `permissionDecision: "deny"` +* `PostToolUse`: by default the turn ends and the reason appears in the chat as a warning line. Set `continueOnBlock: true` to feed the reason back to Claude and continue the turn instead +* `PostToolBatch`, `UserPromptSubmit`, and `UserPromptExpansion`: the turn ends and the reason appears as a warning line. These events end the turn on `decision: "block"` regardless of `continue` +* `PostToolUseFailure`, `TaskCreated`, and `TaskCompleted`: the reason is returned to Claude as a tool error, similar to `PreToolUse` +* `TeammateIdle`: by default the teammate stops and the reason appears as a warning line. Set `continueOnBlock: true` to feed the reason back to the teammate and keep it working instead +* `PermissionRequest`: `ok: false` has no effect. To deny an approval from a hook, use a [command hook](#command-hook-fields) returning `hookSpecificOutput.decision.behavior: "deny"` +* `PermissionDenied`: `ok: false` has no effect because the denial already happened. The only output this event reads is `hookSpecificOutput.retry`, which prompt and agent hooks cannot set — they run on this event, but their output is discarded. Use a [command hook](#command-hook-fields) to return `retry` + +If you need finer control on any event, use a [command hook](#command-hook-fields) with the per-event fields described in [Decision control](#decision-control). ### Example: Multi-criteria Stop hook @@ -2436,7 +2905,7 @@ The `timeout` field sets the maximum time in seconds for the background process. When an async hook fires, Claude Code starts the hook process and immediately continues without waiting for it to finish. The hook receives the same JSON input via stdin as a synchronous hook. -After the background process exits, if the hook produced a JSON response with a `systemMessage` or `additionalContext` field, that content is delivered to Claude as context on the next conversation turn. +After the background process exits, if the hook produced a JSON response with an `additionalContext` field, that content is delivered to Claude as context on the next conversation turn. A `systemMessage` field is shown to you, not to Claude. Async hook completion notifications are suppressed by default. To see them, enable verbose mode with `Ctrl+O` or start Claude Code with `--verbose`. @@ -2457,15 +2926,16 @@ if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then exit 0 fi -# Run tests and report results via systemMessage +# Run tests and report results to Claude via additionalContext RESULT=$(npm test 2>&1) EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ]; then - echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}" + MSG="Tests passed after editing $FILE_PATH" else - echo "{\"systemMessage\": \"Tests failed after editing $FILE_PATH: $RESULT\"}" + MSG="Tests failed after editing $FILE_PATH: $RESULT" fi +jq -nc --arg msg "$MSG" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}' ``` Then add this configuration to `.claude/settings.json` in your project root. The `async: true` flag lets Claude keep working while tests run: @@ -2479,7 +2949,8 @@ Then add this configuration to `.claude/settings.json` in your project root. The "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests-async.sh", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-async.sh", + "args": [], "async": true, "timeout": 300 } @@ -2516,7 +2987,7 @@ Keep these practices in mind when writing hooks: * **Validate and sanitize inputs**: never trust input data blindly * **Always quote shell variables**: use `"$VAR"` not `$VAR` * **Block path traversal**: check for `..` in file paths -* **Use absolute paths**: specify full paths for scripts, using `"$CLAUDE_PROJECT_DIR"` for the project root +* **Use absolute paths**: specify full paths for scripts. In exec form, use `${CLAUDE_PROJECT_DIR}` and the path needs no quoting. In shell form, wrap it in double quotes * **Skip sensitive files**: avoid `.env`, `.git/`, keys, etc. ## Windows PowerShell tool @@ -2555,4 +3026,4 @@ Hook execution details, including which hooks matched, their exit codes, and ful For more granular hook matching details, set `CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose` to see additional log lines such as hook matcher counts and query matching. -For troubleshooting common issues like hooks not firing, infinite Stop hook loops, or configuration errors, see [Limitations and troubleshooting](/en/hooks-guide#limitations-and-troubleshooting) in the guide. For a broader diagnostic walkthrough covering `/context`, `/doctor`, and settings precedence, see [Debug your config](/en/debug-your-config). +For troubleshooting common issues like hooks not firing, Stop hooks that keep blocking, or configuration errors, see [Limitations and troubleshooting](/en/hooks-guide#limitations-and-troubleshooting) in the guide. For a broader diagnostic walkthrough covering `/context`, `/doctor`, and settings precedence, see [Debug your config](/en/debug-your-config). diff --git a/docs/upstream/settings.md b/docs/upstream/settings.md index dcbf76e..d3e4823 100644 --- a/docs/upstream/settings.md +++ b/docs/upstream/settings.md @@ -1,10 +1,3 @@ - - > ## Documentation Index > Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt > Use this file to discover all available pages before exploring further. @@ -13,7 +6,7 @@ > Configure Claude Code with global and project-level settings, and environment variables. -Claude Code offers a variety of settings to configure its behavior to meet your needs. You can configure Claude Code by running the `/config` command when using the interactive REPL, which opens a tabbed Settings interface where you can view status information and modify configuration options. +Claude Code offers a variety of settings to configure its behavior to meet your needs. You can configure Claude Code by running the `/config` command, which opens a tabbed Settings interface where you can view status information and modify configuration options. {/* min-version: 2.1.181 */}From v2.1.181, you can change a single option without opening the interface by passing `key=value` to `/config`, for example `/config verbose=true`. ## Configuration scopes @@ -21,12 +14,12 @@ Claude Code uses a **scope system** to determine where configurations apply and ### Available scopes -| Scope | Location | Who it affects | Shared with team? | -| :---------- | :--------------------------------------------------------------------------------- | :----------------------------------- | :--------------------- | -| **Managed** | Server-managed settings, plist / registry, or system-level `managed-settings.json` | All users on the machine | Yes (deployed by IT) | -| **User** | `~/.claude/` directory | You, across all projects | No | -| **Project** | `.claude/` in repository | All collaborators on this repository | Yes (committed to git) | -| **Local** | `.claude/settings.local.json` | You, in this repository only | No (gitignored) | +| Scope | Location | Who it affects | Shared with team? | +| :---------- | :--------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------ | +| **Managed** | Server-managed settings, plist / registry, or system-level `managed-settings.json` | All organization members for server-managed delivery; all users on the machine for plist, HKLM registry, and file delivery; the current user for HKCU registry delivery | Yes (deployed by IT) | +| **User** | `~/.claude/` directory | You, across all projects | No | +| **Project** | `.claude/` in repository | All collaborators on this repository | Yes (committed to git) | +| **Local** | `.claude/settings.local.json` | You, in this repository only | No (gitignored when Claude Code creates it) | ### When to use each scope @@ -56,7 +49,7 @@ Claude Code uses a **scope system** to determine where configurations apply and ### How scopes interact -When the same setting is configured in multiple scopes, more specific scopes take precedence: +When the same setting appears in multiple scopes, Claude Code applies them in priority order: 1. **Managed** (highest) - can't be overridden by anything 2. **Command line arguments** - temporary session overrides @@ -64,7 +57,7 @@ When the same setting is configured in multiple scopes, more specific scopes tak 4. **Project** - overrides user settings 5. **User** (lowest) - applies when nothing else specifies the setting -For example, if a permission is allowed in user settings but denied in project settings, the project setting takes precedence and the permission is blocked. +For example, if your user settings set `spinnerTipsEnabled` to `true` and project settings set it to `false`, the project value applies. Permission rules behave differently because they merge across scopes rather than override. See [Settings precedence](#settings-precedence). ### What uses scopes @@ -78,6 +71,8 @@ Scopes apply to many Claude Code features: | **Plugins** | `~/.claude/settings.json` | `.claude/settings.json` | `.claude/settings.local.json` | | **CLAUDE.md** | `~/.claude/CLAUDE.md` | `CLAUDE.md` or `.claude/CLAUDE.md` | `CLAUDE.local.md` | +On Windows, paths shown as `~/.claude` resolve to `%USERPROFILE%\.claude`. + *** ## Settings files @@ -89,7 +84,7 @@ Code through hierarchical settings: projects. * **Project settings** are saved in your project directory: * `.claude/settings.json` for settings that are checked into source control and shared with your team - * `.claude/settings.local.json` for settings that are not checked in, useful for personal preferences and experimentation. Claude Code will configure git to ignore `.claude/settings.local.json` when it is created. + * `.claude/settings.local.json` for settings that are not checked in, useful for personal preferences and experimentation. When Claude Code creates `.claude/settings.local.json`, it configures git to ignore the file. If you create the file yourself, add it to your gitignore manually. * **Managed settings**: For organizations that need centralized control, Claude Code supports multiple delivery mechanisms for managed settings. All use the same JSON format and cannot be overridden by user or project settings: * **Server-managed settings**: delivered from Anthropic's servers via the Claude.ai admin console. See [server-managed settings](/en/server-managed-settings). @@ -113,7 +108,7 @@ Code through hierarchical settings: Use numeric prefixes to control merge order, for example `10-telemetry.json` and `20-security.json`. - See [managed settings](/en/permissions#managed-only-settings) and [Managed MCP configuration](/en/mcp#managed-mcp-configuration) for details. + See [managed settings](/en/permissions#managed-only-settings) and [Managed MCP configuration](/en/managed-mcp) for details. This [repository](https://github.com/anthropics/claude-code/tree/main/examples/mdm) includes starter deployment templates for Jamf, Iru (Kandji), Intune, and Group Policy. Use these as starting points and adjust them to fit your needs. @@ -121,7 +116,7 @@ Code through hierarchical settings: Managed deployments can also restrict **plugin marketplace additions** using `strictKnownMarketplaces`. For more information, see [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions). -* **Other configuration** is stored in `~/.claude.json`. This file contains your preferences (theme, notification settings, editor mode), OAuth session, [MCP server](/en/mcp) configurations for user and local scopes, per-project state (allowed tools, trust settings), and various caches. Project-scoped MCP servers are stored separately in `.mcp.json`. +* **Other configuration** is stored in `~/.claude.json`. This file contains your OAuth session, [MCP server](/en/mcp) configurations for user and local scopes, per-project state (allowed tools, trust settings), and various caches. Project-scoped MCP servers are stored separately in `.mcp.json`. Claude Code automatically creates timestamped backups of configuration files and retains the five most recent backups to prevent data loss. @@ -159,122 +154,205 @@ The `$schema` line in the example above points to the [official JSON schema](htt The published schema is updated periodically and may not include settings added in the most recent CLI releases, so a validation warning on a recently documented field does not necessarily mean your configuration is invalid. +### When edits take effect + +Claude Code watches your settings files and reloads them when they change, so edits to most keys apply to the running session without a restart. This includes `permissions`, `hooks`, and credential helpers like `apiKeyHelper`. The reload covers user, project, local, and managed settings, and the [`ConfigChange` hook](/en/hooks#configchange) fires for each detected change. + +A few keys are read once at session start and apply on the next restart instead: + +* `model`: use [`/model`](/en/model-config#setting-your-model) to switch mid-session +* [`outputStyle`](/en/output-styles): part of the system prompt, which is rebuilt on `/clear` or restart + +### Invalid entries in managed settings + +Managed settings parse tolerantly. When a managed configuration contains an entry that fails schema validation, Claude Code strips that entry, records a warning, and enforces every remaining valid policy. A single typo cannot disable the rest of your organization's policy. This behavior is consistent across all three delivery mechanisms: [server-managed settings](/en/server-managed-settings), plist and registry policies deployed through MDM, and `managed-settings.json` files. Requires Claude Code v2.1.169 or later. + +Security-enforcement fields are handled per field instead of being stripped wholesale when they are present but invalid: + +| Field | Behavior when present but invalid | +| :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `allowedMcpServers` | Enforced as an empty allowlist, so no MCP servers are admitted until the value is fixed. An individual invalid entry is stripped and the valid subset is enforced. | +| `allowManagedMcpServersOnly` | Treated as `true`. | +| `availableModels` | {/* min-version: 2.1.175 */}Enforced as an empty allowlist, so only the Default model is available until the value is fixed. An individual non-string entry is stripped and the valid subset is enforced. Applies in v2.1.175 and later. | +| `enforceAvailableModels` | {/* min-version: 2.1.175 */}Treated as `true`. Applies in v2.1.175 and later. | +| `forceLoginOrgUUID` | No organization is permitted to log in until the value is fixed. | +| `deniedMcpServers` | An individual invalid entry is stripped and the valid subset is enforced. A wholly invalid value is dropped with a warning, since denying every server would block servers the policy never named. | +| `sandbox.credentials` | {/* min-version: 2.1.191 */}An individual invalid entry in `files` or `envVars` is stripped with a warning and the valid subset is enforced. A wholly invalid `credentials` value is dropped with a warning while the rest of `sandbox` still applies. Applies in v2.1.191 and later. | + +`requiredMinimumVersion` and `requiredMaximumVersion` fail open by design: an invalid value is stripped rather than enforced, so a bad policy push cannot prevent Claude Code from starting. + +Validation errors surface in three places: + +* Interactive sessions show a dialog at startup listing the invalid entries. +* Headless runs with `-p` print a summary to stderr. +* [`claude doctor`](/en/debug-your-config) lists each invalid entry with its source and field. + +Validate policy changes by running `claude doctor` on a test machine before deploying them fleet-wide. + +This tolerance applies only to managed settings. User, project, and local settings files remain strict: a file that fails validation is rejected as a whole and reported. + ### Available settings `settings.json` supports a number of options: -| Key | Description | Example | -| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | -| `agent` | Run the main thread as a named subagent. Applies that subagent's system prompt, tool restrictions, and model. See [Invoke subagents explicitly](/en/sub-agents#invoke-subagents-explicitly) | `"code-reviewer"` | -| `allowedChannelPlugins` | (Managed settings only) Allowlist of channel plugins that may push messages. Replaces the default Anthropic allowlist when set. Undefined = fall back to the default, empty array = block all channel plugins. Requires `channelsEnabled: true`. See [Restrict which channel plugins can run](/en/channels#restrict-which-channel-plugins-can-run) | `[{ "marketplace": "claude-plugins-official", "plugin": "telegram" }]` | -| `allowedHttpHookUrls` | Allowlist of URL patterns that HTTP hooks may target. Supports `*` as a wildcard. When set, hooks with non-matching URLs are blocked. Undefined = no restriction, empty array = block all HTTP hooks. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["https://hooks.example.com/*"]` | -| `allowedMcpServers` | When set in managed-settings.json, allowlist of MCP servers users can configure. Undefined = no restrictions, empty array = lockdown. Applies to all scopes. Denylist takes precedence. See [Managed MCP configuration](/en/mcp#managed-mcp-configuration) | `[{ "serverName": "github" }]` | -| `allowManagedHooksOnly` | (Managed settings only) Only managed hooks, SDK hooks, and hooks from plugins force-enabled in managed settings `enabledPlugins` are loaded. User, project, and all other plugin hooks are blocked. See [Hook configuration](#hook-configuration) | `true` | -| `allowManagedMcpServersOnly` | (Managed settings only) Only `allowedMcpServers` from managed settings are respected. `deniedMcpServers` still merges from all sources. Users can still add MCP servers, but only the admin-defined allowlist applies. See [Managed MCP configuration](/en/mcp#managed-mcp-configuration) | `true` | -| `allowManagedPermissionRulesOnly` | (Managed settings only) Prevent user and project settings from defining `allow`, `ask`, or `deny` permission rules. Only rules in managed settings apply. See [Managed-only settings](/en/permissions#managed-only-settings) | `true` | -| `alwaysThinkingEnabled` | Enable [extended thinking](/en/common-workflows#use-extended-thinking-thinking-mode) by default for all sessions. Typically configured via the `/config` command rather than editing directly | `true` | -| `apiKeyHelper` | Custom script, to be executed in `/bin/sh`, to generate an auth value. This value will be sent as `X-Api-Key` and `Authorization: Bearer` headers for model requests | `/bin/generate_temp_api_key.sh` | -| `attribution` | Customize attribution for git commits and pull requests. See [Attribution settings](#attribution-settings) | `{"commit": "🤖 Generated with Claude Code", "pr": ""}` | -| `autoMemoryDirectory` | Custom directory for [auto memory](/en/memory#storage-location) storage. Accepts `~/`-expanded paths. Not accepted in project settings (`.claude/settings.json`) to prevent shared repos from redirecting memory writes to sensitive locations. Accepted from policy, local, and user settings | `"~/my-memory-dir"` | -| `autoMode` | Customize what the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier blocks and allows. Contains `environment`, `allow`, and `soft_deny` arrays of prose rules. Include the literal string `"$defaults"` in an array to inherit the built-in rules at that position. See [Configure auto mode](/en/auto-mode-config). Not read from shared project settings | `{"soft_deny": ["$defaults", "Never run terraform apply"]}` | -| `autoUpdatesChannel` | Release channel to follow for updates. Use `"stable"` for a version that is typically about one week old and skips versions with major regressions, or `"latest"` (default) for the most recent release | `"stable"` | -| `availableModels` | Restrict which models users can select via `/model`, `--model`, or `ANTHROPIC_MODEL`. Does not affect the Default option. See [Restrict model selection](/en/model-config#restrict-model-selection) | `["sonnet", "haiku"]` | -| `awaySummaryEnabled` | Show a one-line session recap when you return to the terminal after a few minutes away. Set to `false` or turn off Session recap in `/config` to disable. Same as [`CLAUDE_CODE_ENABLE_AWAY_SUMMARY`](/en/env-vars) | `true` | -| `awsAuthRefresh` | Custom script that modifies the `.aws` directory (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `aws sso login --profile myprofile` | -| `awsCredentialExport` | Custom script that outputs JSON with AWS credentials (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `/bin/generate_aws_grant.sh` | -| `blockedMarketplaces` | (Managed settings only) Blocklist of marketplace sources. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. Blocked sources are checked before downloading, so they never touch the filesystem. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "untrusted/plugins" }]` | -| `channelsEnabled` | (Managed settings only) Allow [channels](/en/channels) for Team and Enterprise users. Unset or `false` blocks channel message delivery regardless of what users pass to `--channels` | `true` | -| `cleanupPeriodDays` | Session files older than this period are deleted at startup (default: 30 days, minimum 1). Setting to `0` is rejected with a validation error. Also controls the age cutoff for automatic removal of [orphaned subagent worktrees](/en/common-workflows#worktree-cleanup) at startup. To disable transcript writes entirely, set the [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable, or in non-interactive mode (`-p`) use the `--no-session-persistence` flag or the `persistSession: false` SDK option. | `20` | -| `companyAnnouncements` | Announcement to display to users at startup. If multiple announcements are provided, they will be cycled through at random. | `["Welcome to Acme Corp! Review our code guidelines at docs.acme.com"]` | -| `defaultShell` | Default shell for input-box `!` commands. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` routes interactive `!` commands through PowerShell on Windows. Requires `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`. See [PowerShell tool](/en/tools-reference#powershell-tool) | `"powershell"` | -| `deniedMcpServers` | When set in managed-settings.json, denylist of MCP servers that are explicitly blocked. Applies to all scopes including managed servers. Denylist takes precedence over allowlist. See [Managed MCP configuration](/en/mcp#managed-mcp-configuration) | `[{ "serverName": "filesystem" }]` | -| `disableAllHooks` | Disable all [hooks](/en/hooks) and any custom [status line](/en/statusline) | `true` | -| `disableAutoMode` | Set to `"disable"` to prevent [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) from being activated. Removes `auto` from the `Shift+Tab` cycle and rejects `--permission-mode auto` at startup. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `"disable"` | -| `disableDeepLinkRegistration` | Set to `"disable"` to prevent Claude Code from registering the `claude-cli://` protocol handler with the operating system on startup. Deep links let external tools open a Claude Code session with a pre-filled prompt via `claude-cli://open?q=...`. The `q` parameter supports multi-line prompts using URL-encoded newlines (`%0A`). Useful in environments where protocol handler registration is restricted or managed separately | `"disable"` | -| `disabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to reject | `["filesystem"]` | -| `disableSkillShellExecution` | Disable inline shell execution for `` !`...` `` and ` ```! ` blocks in [skills](/en/skills) and custom commands from user, project, plugin, or additional-directory sources. Commands are replaced with `[shell command execution disabled by policy]` instead of being run. Bundled and managed skills are not affected. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `true` | -| `effortLevel` | Persist the [effort level](/en/model-config#adjust-effort-level) across sessions. Accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"`. Written automatically when you run `/effort` with one of those values. See [Adjust effort level](/en/model-config#adjust-effort-level) for supported models | `"xhigh"` | -| `enableAllProjectMcpServers` | Automatically approve all MCP servers defined in project `.mcp.json` files | `true` | -| `enabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to approve | `["memory", "github"]` | -| `env` | Environment variables that will be applied to every session | `{"FOO": "bar"}` | -| `fastModePerSessionOptIn` | When `true`, fast mode does not persist across sessions. Each session starts with fast mode off, requiring users to enable it with `/fast`. The user's fast mode preference is still saved. See [Require per-session opt-in](/en/fast-mode#require-per-session-opt-in) | `true` | -| `feedbackSurveyRate` | Probability (0–1) that the [session quality survey](/en/data-usage#session-quality-surveys) appears when eligible. Set to `0` to suppress entirely. Useful when using Bedrock, Vertex, or Foundry where the default sample rate does not apply | `0.05` | -| `fileSuggestion` | Configure a custom script for `@` file autocomplete. See [File suggestion settings](#file-suggestion-settings) | `{"type": "command", "command": "~/.claude/file-suggestion.sh"}` | -| `forceLoginMethod` | Use `claudeai` to restrict login to Claude.ai accounts, `console` to restrict login to Claude Console (API usage billing) accounts | `claudeai` | -| `forceLoginOrgUUID` | Require login to belong to a specific organization. Accepts a single UUID string, which also pre-selects that organization during login, or an array of UUIDs where any listed organization is accepted without pre-selection. When set in managed settings, login fails if the authenticated account does not belong to a listed organization; an empty array fails closed and blocks login with a misconfiguration message | `"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"` or `["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]` | -| `forceRemoteSettingsRefresh` | (Managed settings only) Block CLI startup until remote managed settings are freshly fetched from the server. If the fetch fails, the CLI exits rather than continuing with cached or no settings. When not set, startup continues without waiting for remote settings. See [fail-closed enforcement](/en/server-managed-settings#enforce-fail-closed-startup) | `true` | -| `hooks` | Configure custom commands to run at lifecycle events. See [hooks documentation](/en/hooks) for format | See [hooks](/en/hooks) | -| `httpHookAllowedEnvVars` | Allowlist of environment variable names HTTP hooks may interpolate into headers. When set, each hook's effective `allowedEnvVars` is the intersection with this list. Undefined = no restriction. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["MY_TOKEN", "HOOK_SECRET"]` | -| `includeCoAuthoredBy` | **Deprecated**: Use `attribution` instead. Whether to include the `co-authored-by Claude` byline in git commits and pull requests (default: `true`) | `false` | -| `includeGitInstructions` | Include built-in commit and PR workflow instructions and the git status snapshot in Claude's system prompt (default: `true`). Set to `false` to remove both, for example when using your own git workflow skills. The `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS` environment variable takes precedence over this setting when set | `false` | -| `language` | Configure Claude's preferred response language (e.g., `"japanese"`, `"spanish"`, `"french"`). Claude will respond in this language by default. Also sets the [voice dictation](/en/voice-dictation#change-the-dictation-language) language | `"japanese"` | -| `minimumVersion` | Floor that prevents background auto-updates and `claude update` from installing a version below this one. Switching from the `"latest"` channel to `"stable"` via `/config` prompts you to stay on the current version or allow the downgrade. Choosing to stay sets this value. Also useful in [managed settings](/en/permissions#managed-settings) to pin an organization-wide minimum | `"2.1.100"` | -| `model` | Override the default model to use for Claude Code | `"claude-sonnet-4-6"` | -| `modelOverrides` | Map Anthropic model IDs to provider-specific model IDs such as Bedrock inference profile ARNs. Each model picker entry uses its mapped value when calling the provider API. See [Override model IDs per version](/en/model-config#override-model-ids-per-version) | `{"claude-opus-4-6": "arn:aws:bedrock:..."}` | -| `otelHeadersHelper` | Script to generate dynamic OpenTelemetry headers. Runs at startup and periodically (see [Dynamic headers](/en/monitoring-usage#dynamic-headers)) | `/bin/generate_otel_headers.sh` | -| `outputStyle` | Configure an output style to adjust the system prompt. See [output styles documentation](/en/output-styles) | `"Explanatory"` | -| `permissions` | See table below for structure of permissions. | | -| `plansDirectory` | Customize where plan files are stored. Path is relative to project root. Default: `~/.claude/plans` | `"./plans"` | -| `pluginTrustMessage` | (Managed settings only) Custom message appended to the plugin trust warning shown before installation. Use this to add organization-specific context, for example to confirm that plugins from your internal marketplace are vetted. | `"All plugins from our marketplace are approved by IT"` | -| `prefersReducedMotion` | Reduce or disable UI animations (spinners, shimmer, flash effects) for accessibility | `true` | -| `respectGitignore` | Control whether the `@` file picker respects `.gitignore` patterns. When `true` (default), files matching `.gitignore` patterns are excluded from suggestions | `false` | -| `showClearContextOnPlanAccept` | Show the "clear context" option on the plan accept screen. Defaults to `false`. Set to `true` to restore the option | `true` | -| `showThinkingSummaries` | Show [extended thinking](/en/common-workflows#use-extended-thinking-thinking-mode) summaries in interactive sessions. When unset or `false` (default in interactive mode), thinking blocks are redacted by the API and shown as a collapsed stub. Redaction only changes what you see, not what the model generates: to reduce thinking spend, [lower the budget or disable thinking](/en/common-workflows#use-extended-thinking-thinking-mode) instead. Non-interactive mode (`-p`) and SDK callers always receive summaries regardless of this setting | `true` | -| `skipWebFetchPreflight` | Skip the [WebFetch domain safety check](/en/data-usage#webfetch-domain-safety-check) that sends each requested hostname to `api.anthropic.com` before fetching. Set to `true` in environments that block traffic to Anthropic, such as Bedrock, Vertex AI, or Foundry deployments with restrictive egress. When skipped, WebFetch attempts any URL without consulting the blocklist | `true` | -| `spinnerTipsEnabled` | Show tips in the spinner while Claude is working. Set to `false` to disable tips (default: `true`) | `false` | -| `spinnerTipsOverride` | Override spinner tips with custom strings. `tips`: array of tip strings. `excludeDefault`: if `true`, only show custom tips; if `false` or absent, custom tips are merged with built-in tips | `{ "excludeDefault": true, "tips": ["Use our internal tool X"] }` | -| `spinnerVerbs` | Customize the action verbs shown in the spinner and turn duration messages. Set `mode` to `"replace"` to use only your verbs, or `"append"` to add them to the defaults | `{"mode": "append", "verbs": ["Pondering", "Crafting"]}` | -| `sshConfigs` | SSH connections to show in the [Desktop](/en/desktop#pre-configure-ssh-connections-for-your-team) environment dropdown. Each entry requires `id`, `name`, and `sshHost`; `sshPort`, `sshIdentityFile`, and `startDirectory` are optional. When set in managed settings, connections are read-only for users. Read from managed and user settings only | `[{"id": "dev-vm", "name": "Dev VM", "sshHost": "user@dev.example.com"}]` | -| `statusLine` | Configure a custom status line to display context. See [`statusLine` documentation](/en/statusline) | `{"type": "command", "command": "~/.claude/statusline.sh"}` | -| `strictKnownMarketplaces` | (Managed settings only) Allowlist of plugin marketplace sources. Undefined = no restrictions, empty array = lockdown. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "acme-corp/plugins" }]` | -| `tui` | Terminal UI renderer. Use `"fullscreen"` for the flicker-free [alt-screen renderer](/en/fullscreen) with virtualized scrollback. Use `"default"` for the classic main-screen renderer. Set via `/tui` | `"fullscreen"` | -| `useAutoModeDuringPlan` | Whether plan mode uses auto mode semantics when auto mode is available. Default: `true`. Not read from shared project settings. Appears in `/config` as "Use auto mode during plan" | `false` | -| `viewMode` | Default transcript view mode on startup: `"default"`, `"verbose"`, or `"focus"`. Overrides the sticky `/focus` selection when set | `"verbose"` | -| `voice` | [Voice dictation](/en/voice-dictation) settings: `enabled` turns dictation on, `mode` selects `"hold"` or `"tap"`, and `autoSubmit` sends the prompt on key release in hold mode. Written automatically when you run `/voice`. Requires a Claude.ai account | `{ "enabled": true, "mode": "tap" }` | -| `voiceEnabled` | Legacy alias for `voice.enabled`. Prefer the `voice` object | `true` | -| `wslInheritsWindowsSettings` | (Windows managed settings only) When `true`, Claude Code on WSL reads managed settings from the Windows policy chain in addition to `/etc/claude-code`, with Windows sources taking priority. Only honored when set in the HKLM registry key or `C:\Program Files\ClaudeCode\managed-settings.json`, both of which require Windows admin to write. For HKCU policy to also apply on WSL, the flag must additionally be set in HKCU itself. Has no effect on native Windows | `true` | +| Key | Description | Example | +| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------ | +| `advisorModel` | {/* min-version: 2.1.98 */}Model for the server-side [advisor tool](/en/advisor). Accepts a model alias such as `"opus"`, `"sonnet"`, or `"fable"` ({/* min-version: 2.1.170 */}v2.1.170+), or a full model ID. Written automatically when you run `/advisor`. Unset to disable the advisor. Requires Claude Code v2.1.98 or later | `"opus"` | +| `agent` | Run the main thread as a named subagent, and set the default agent for sessions dispatched from `claude agents`. Applies that subagent's system prompt, tool restrictions, and model. See [Invoke subagents explicitly](/en/sub-agents#invoke-subagents-explicitly) | `"code-reviewer"` | +| `agentPushNotifEnabled` | {/* min-version: 2.1.119 */}When [Remote Control](/en/remote-control) is connected, allow Claude to send proactive push notifications to your phone, for example when a long task finishes. Default: `false`. Appears in `/config` as **Push when Claude decides**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | +| `allowAllClaudeAiMcps` | (Managed settings only) Load claude.ai connectors alongside a deployed `managed-mcp.json`, which otherwise takes exclusive control and suppresses them. See [Managed MCP configuration](/en/managed-mcp) | `true` | +| `allowedChannelPlugins` | (Managed settings only) Allowlist of channel plugins that may push messages. Replaces the default Anthropic allowlist when set. Undefined = fall back to the default, empty array = block all channel plugins. Requires `channelsEnabled: true`. See [Restrict which channel plugins can run](/en/channels#restrict-which-channel-plugins-can-run) | `[{ "marketplace": "claude-plugins-official", "plugin": "telegram" }]` | +| `allowedHttpHookUrls` | Allowlist of URL patterns that HTTP hooks may target. Supports `*` as a wildcard. When set, hooks with non-matching URLs are blocked. Undefined = no restriction, empty array = block all HTTP hooks. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["https://hooks.example.com/*"]` | +| `allowedMcpServers` | When set in managed-settings.json, allowlist of MCP servers users can configure. Undefined = no restrictions, empty array = lockdown. Applies to all scopes. Denylist takes precedence. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "github" }]` | +| `allowManagedHooksOnly` | (Managed settings only) Only managed hooks, SDK hooks, and hooks from plugins force-enabled in managed settings `enabledPlugins` are loaded. User, project, and all other plugin hooks are blocked. See [Hook configuration](#hook-configuration) | `true` | +| `allowManagedMcpServersOnly` | (Managed settings only) Only `allowedMcpServers` from managed settings are respected. `deniedMcpServers` still merges from all sources. Users can still add MCP servers, but only the admin-defined allowlist applies. See [Managed MCP configuration](/en/managed-mcp) | `true` | +| `allowManagedPermissionRulesOnly` | (Managed settings only) Prevent user and project settings from defining `allow`, `ask`, or `deny` permission rules. Only rules in managed settings apply. See [Managed-only settings](/en/permissions#managed-only-settings) | `true` | +| `alwaysThinkingEnabled` | Enable [extended thinking](/en/model-config#extended-thinking) by default for all sessions. Typically configured via the `/config` command rather than editing directly. To force thinking off regardless of this setting, set [`MAX_THINKING_TOKENS=0`](/en/env-vars) in `env`, which disables thinking on the Anthropic API except on Fable 5, which cannot have thinking turned off. On [third-party providers](/en/third-party-integrations) this omits the `thinking` parameter instead, and adaptive-reasoning models may still think | `true` | +| `apiKeyHelper` | Custom command, run through the system shell (`/bin/sh` on macOS and Linux, `cmd` on Windows), to generate an auth value. This value will be sent as `X-Api-Key` and `Authorization: Bearer` headers for model requests. Set the refresh interval with [`CLAUDE_CODE_API_KEY_HELPER_TTL_MS`](/en/env-vars) | `/bin/generate_temp_api_key.sh` | +| `attribution` | Customize attribution for git commits and pull requests. See [Attribution settings](#attribution-settings) | `{"commit": "🤖 Generated with Claude Code", "pr": ""}` | +| `autoCompactEnabled` | {/* min-version: 2.1.119 */}Automatically compact the conversation when context approaches the limit. Default: `true`. Appears in `/config` as **Auto-compact**. To disable via environment variable, set [`DISABLE_AUTO_COMPACT`](/en/env-vars) in `env` | `false` | +| `autoMemoryDirectory` | Custom directory for [auto memory](/en/memory#storage-location) storage. Accepts an absolute path or a `~/`-prefixed path. From project or local settings, this is honored only after you accept the workspace trust dialog, since a cloned repository can supply this file | `"~/my-memory-dir"` | +| `autoMemoryEnabled` | Enable [auto memory](/en/memory#enable-or-disable-auto-memory). When `false`, Claude does not read from or write to the auto memory directory. Default: `true`. You can also toggle this with `/memory` during a session. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_AUTO_MEMORY`](/en/env-vars) in `env` | `false` | +| `autoMode` | Customize what the [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) classifier blocks and allows. Contains `environment`, `allow`, `soft_deny`, and `hard_deny` arrays of prose rules. Include the literal string `"$defaults"` in an array to inherit the built-in rules at that position. See [Configure auto mode](/en/auto-mode-config). Not read from shared project settings | `{"soft_deny": ["$defaults", "Never run terraform apply"]}` | +| `autoScrollEnabled` | In [fullscreen rendering](/en/fullscreen), follow new output to the bottom of the conversation. Default: `true`. Appears in `/config` as **Auto-scroll**. Permission prompts still scroll into view when this is off | `false` | +| `autoUpdatesChannel` | Release channel to follow for updates. Use `"stable"` for a version that is typically about one week old and skips versions with major regressions, or `"latest"` (default) for the most recent release. To disable auto-updates entirely, set [`DISABLE_AUTOUPDATER`](/en/setup#disable-auto-updates) in `env` | `"stable"` | +| `availableModels` | Restrict which models users can select for the main session, [subagents](/en/sub-agents), [skills](/en/skills), and the [advisor](/en/advisor). Does not affect the Default option unless `enforceAvailableModels` is also set. See [Restrict model selection](/en/model-config#restrict-model-selection) | `["sonnet", "haiku"]` | +| `awaySummaryEnabled` | Show a one-line session recap when you return to the terminal after a few minutes away. Set to `false` or turn off Session recap in `/config` to disable. Same as [`CLAUDE_CODE_ENABLE_AWAY_SUMMARY`](/en/env-vars) | `true` | +| `awsAuthRefresh` | Custom script that modifies the `.aws` directory (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `aws sso login --profile myprofile` | +| `awsCredentialExport` | Custom script that outputs JSON with AWS credentials (see [advanced credential configuration](/en/amazon-bedrock#advanced-credential-configuration)) | `/bin/generate_aws_grant.sh` | +| `axScreenReader` | {/* min-version: 2.1.181 */}Render screen-reader friendly output: flat text without decorative borders or animations. Screen-reader mode always uses the classic renderer, so the `tui` setting has no effect while it is active. The [`CLAUDE_AX_SCREEN_READER`](/en/env-vars) environment variable and the [`--ax-screen-reader`](/en/cli-reference#cli-flags) flag take precedence. Requires Claude Code v2.1.181 or later | `true` | +| `blockedMarketplaces` | (Managed settings only) Blocklist of marketplace sources. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. Blocked sources are checked before downloading, so they never touch the filesystem. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "untrusted/plugins" }]` | +| `channelsEnabled` | (Managed settings only) Allow [channels](/en/channels) for the organization. On claude.ai Team and Enterprise plans, channels are blocked when this is unset or `false`. For [Anthropic Console](/en/authentication#claude-console-authentication) accounts using API key authentication, channels are allowed by default unless your organization deploys managed settings, in which case this key must be set to `true` | `true` | +| `claudeMd` | (Managed settings only) CLAUDE.md-style instructions injected as organization-managed memory. Only honored when set in managed or policy settings and ignored in user, project, and local settings. See [organization-wide CLAUDE.md](/en/memory#deploy-organization-wide-claude-md) | `"Always run make lint before committing."` | +| `claudeMdExcludes` | Glob patterns or absolute paths of `CLAUDE.md` files to skip when loading [memory](/en/memory). Patterns match against absolute file paths. Only applies to user, project, and local memory; managed policy files cannot be excluded | `["**/vendor/**/CLAUDE.md"]` | +| `cleanupPeriodDays` | Session files older than this period are deleted at startup (default: 30 days, minimum 1). Setting to `0` is rejected with a validation error. Also controls the age cutoff for automatic removal of [orphaned subagent worktrees](/en/worktrees#clean-up-worktrees) at startup. To disable transcript writes entirely, set the [`CLAUDE_CODE_SKIP_PROMPT_HISTORY`](/en/env-vars) environment variable, or in non-interactive mode (`-p`) use the `--no-session-persistence` flag or the `persistSession: false` SDK option. | `20` | +| `companyAnnouncements` | Announcement to display to users at startup. If multiple announcements are provided, they will be cycled through at random. | `["Welcome to Acme Corp! Review our code guidelines at docs.acme.com"]` | +| `defaultShell` | Default shell for input-box `!` commands. Accepts `"bash"` (default) or `"powershell"`. Setting `"powershell"` routes interactive `!` commands through PowerShell on Windows. Requires `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`. See [PowerShell tool](/en/tools-reference#powershell-tool) | `"powershell"` | +| `deniedMcpServers` | When set in managed-settings.json, denylist of MCP servers that are explicitly blocked. Applies to all scopes including managed servers. Denylist takes precedence over allowlist. See [Managed MCP configuration](/en/managed-mcp) | `[{ "serverName": "filesystem" }]` | +| `disableAgentView` | Set to `true` to turn off [background agents and agent view](/en/agent-view): `claude agents`, `--bg`, `/background`, and the on-demand supervisor. Typically set in [managed settings](/en/permissions#managed-settings). Equivalent to setting `CLAUDE_CODE_DISABLE_AGENT_VIEW` to `1` | `true` | +| `disableAllHooks` | Disable all [hooks](/en/hooks) and any custom [status line](/en/statusline) | `true` | +| `disableArtifact` | Set to `true` to disable the [Artifact](/en/artifacts) tool, which publishes session output as a private web page on claude.ai. Equivalent to setting `CLAUDE_CODE_DISABLE_ARTIFACT` to `1` | `true` | +| `disableAutoMode` | Set to `"disable"` to prevent [auto mode](/en/permission-modes#eliminate-prompts-with-auto-mode) from being activated. Removes `auto` from the `Shift+Tab` cycle and rejects `--permission-mode auto` at startup. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `"disable"` | +| `disableBundledSkills` | Set to `true` to disable the [skills](/en/skills) and workflows that ship with Claude Code: bundled skills and workflows are removed entirely, while built-in slash commands like `/init` stay typable but are hidden from the model. Skills from plugins, `.claude/skills/`, and `.claude/commands/` are unaffected. Equivalent to setting `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS` to `1` | `true` | +| `disableClaudeAiConnectors` | {/* min-version: 2.1.182 */}Disable [claude.ai MCP connectors](/en/mcp#use-mcp-servers-from-claude-ai) so they are not auto-fetched or connected. Set in any settings scope. `true` in any source takes precedence, so a checked-in project `.claude/settings.json` can opt a repo out of cloud connectors, but a project-level `false` cannot override a user- or policy-level `true`. Servers passed explicitly via `--mcp-config` are unaffected. To deny individual connectors instead of all of them, use [`deniedMcpServers`](/en/managed-mcp). Requires Claude Code v2.1.182 or later | `true` | +| `disableDeepLinkRegistration` | Set to `"disable"` to prevent Claude Code from registering the `claude-cli://` protocol handler with the operating system on startup. [Deep links](/en/deep-links) let external tools open a Claude Code session with a pre-filled prompt. Useful in environments where protocol handler registration is restricted or managed separately | `"disable"` | +| `disabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to reject | `["filesystem"]` | +| `disableRemoteControl` | {/* min-version: 2.1.128 */}Disable [Remote Control](/en/remote-control): blocks `claude remote-control`, the `--remote-control` flag, auto-start, and the in-session toggle. Typically placed in [managed settings](/en/permissions#managed-settings) for per-device MDM enforcement, but works from any scope. Requires Claude Code v2.1.128 or later | `true` | +| `disableSkillShellExecution` | Disable inline shell execution for `` !`...` `` and ` ```! ` blocks in [skills](/en/skills) and custom commands from user, project, plugin, or additional-directory sources. Commands are replaced with `[shell command execution disabled by policy]` instead of being run. Bundled and managed skills are not affected. Most useful in [managed settings](/en/permissions#managed-settings) where users cannot override it | `true` | +| `disableWorkflows` | Disable [dynamic workflows](/en/workflows#turn-workflows-off) and the bundled workflow commands. Default: `false`. Equivalent to setting `CLAUDE_CODE_DISABLE_WORKFLOWS` to `1` | `true` | +| `editorMode` | Key binding mode for the input prompt: `"normal"` or `"vim"`. Default: `"normal"`. Appears in `/config` as **Editor mode** | `"vim"` | +| `effortLevel` | Persist the [effort level](/en/model-config#adjust-effort-level) across sessions. Accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"`. Written automatically when you run `/effort` with one of those values. `--effort` and [`CLAUDE_CODE_EFFORT_LEVEL`](/en/env-vars) override this for one session. See [Adjust effort level](/en/model-config#adjust-effort-level) for supported models | `"xhigh"` | +| `enableAllProjectMcpServers` | Automatically approve all MCP servers defined in project `.mcp.json` files | `true` | +| `enabledMcpjsonServers` | List of specific MCP servers from `.mcp.json` files to approve | `["memory", "github"]` | +| `enforceAvailableModels` | {/* min-version: 2.1.175 */}Extend the `availableModels` allowlist to the Default model. When `true` in managed settings and `availableModels` is a non-empty array, the Default option falls back to the first allowlisted entry that is available. Has no effect when `availableModels` is unset or empty. See [Enforce the allowlist for the Default model](/en/model-config#enforce-the-allowlist-for-the-default-model). Requires Claude Code v2.1.175 or later | `true` | +| `env` | Environment variables applied to every session and to subprocesses Claude Code spawns from it. {/* min-version: 2.1.143 */}As of v2.1.143, `NO_COLOR` and `FORCE_COLOR` set here are passed to subprocesses but do not change Claude Code's own interface colors. Set those in your shell before launching `claude` to change interface colors | `{"FOO": "bar"}` | +| `fallbackModel` | Fallback model(s) to try in order when the primary model is overloaded or unavailable. Claude Code switches to the next available model in the chain for the rest of the turn and shows a notice. `"default"` expands to the default model. Chains are capped at three models; extra entries are ignored. Unlike most array settings, this key does not merge across settings files: the highest-precedence file that defines it supplies the entire chain. The [`--fallback-model`](/en/cli-reference#cli-flags) flag overrides this for one session. See [Fallback model chains](/en/model-config#fallback-model-chains) | `["claude-sonnet-4-6", "claude-haiku-4-5"]` | +| `fastModePerSessionOptIn` | When `true`, fast mode does not persist across sessions. Each session starts with fast mode off, requiring users to enable it with `/fast`. The user's fast mode preference is still saved. See [Require per-session opt-in](/en/fast-mode#require-per-session-opt-in) | `true` | +| `feedbackSurveyRate` | Probability (0–1) that the [session quality survey](/en/data-usage#session-quality-surveys) appears when eligible. Set to `0` to suppress entirely, or set [`CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY`](/en/env-vars) in `env`. Useful when using Bedrock, Vertex, or Foundry where the default sample rate does not apply | `0.05` | +| `fileCheckpointingEnabled` | {/* min-version: 2.1.119 */}Snapshot files before each edit so [`/rewind`](/en/checkpointing) can restore them. Default: `true`. Appears in `/config` as **Rewind code (checkpoints)**. To disable via environment variable, set [`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`](/en/env-vars) in `env` | `false` | +| `fileSuggestion` | Configure a custom script for `@` file autocomplete. See [File suggestion settings](#file-suggestion-settings) | `{"type": "command", "command": "~/.claude/file-suggestion.sh"}` | +| `footerLinksRegexes` | {/* min-version: 2.1.176 */}Render extra clickable badges in the footer when a regex matches turn output. Each entry has a `pattern`, a `url` template with `{name}` placeholders filled from named capture groups, and an optional `label`. Read from user, `--settings` flag, and managed settings only. See [Footer link badges](#footer-link-badges) for URL constraints, scheme allowlist, and limits. Requires Claude Code v2.1.176 or later | `[{"type": "regex", "pattern": "\\b(?PROJ-\\d+)\\b", "url": "https://issues.example.com/browse/{key}", "label": "{key}"}]` | +| `forceLoginMethod` | Use `claudeai` to restrict login to Claude.ai accounts, `console` to restrict login to Claude Console accounts. When set in managed settings, sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup, since neither value can be satisfied without first-party OAuth. Third-party provider sessions such as Bedrock, Vertex, and Foundry are not blocked: they authenticate against your cloud provider rather than Anthropic | `claudeai` | +| `forceLoginOrgUUID` | Require login to belong to a specific Anthropic organization. Accepts a single UUID string, which also pre-selects that organization during login, or an array of UUIDs where any listed organization is accepted without pre-selection. When set in managed settings, login fails if the authenticated account does not belong to a listed organization, and sessions authenticated by `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `apiKeyHelper` are blocked at startup since organization membership cannot be verified for them. Third-party provider sessions such as Bedrock, Vertex, and Foundry are not blocked: use your cloud IAM to restrict which cloud accounts can be used. An empty array fails closed and blocks login with a misconfiguration message | `"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"` or `["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"]` | +| `forceRemoteSettingsRefresh` | (Managed settings only) Block CLI startup until remote managed settings are freshly fetched from the server. If the fetch fails, the CLI exits rather than continuing with cached or no settings. When not set, startup continues without waiting for remote settings. See [fail-closed enforcement](/en/server-managed-settings#enforce-fail-closed-startup) | `true` | +| `gcpAuthRefresh` | Custom script that refreshes GCP Application Default Credentials when they expire or cannot be loaded. See [advanced credential configuration](/en/google-vertex-ai#advanced-credential-configuration) | `gcloud auth application-default login` | +| `hooks` | Configure custom commands to run at lifecycle events. See [hooks documentation](/en/hooks) for format | See [hooks](/en/hooks) | +| `httpHookAllowedEnvVars` | Allowlist of environment variable names HTTP hooks may interpolate into headers. When set, each hook's effective `allowedEnvVars` is the intersection with this list. Undefined = no restriction. Arrays merge across settings sources. See [Hook configuration](#hook-configuration) | `["MY_TOKEN", "HOOK_SECRET"]` | +| `includeCoAuthoredBy` | **Deprecated**: Use `attribution` instead. Whether to include the `co-authored-by Claude` byline in git commits and pull requests (default: `true`) | `false` | +| `includeGitInstructions` | Include built-in commit and PR workflow instructions and the git status snapshot in Claude's system prompt (default: `true`). Set to `false` to remove both, for example when using your own git workflow skills. The `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS` environment variable takes precedence over this setting when set | `false` | +| `inputNeededNotifEnabled` | {/* min-version: 2.1.119 */}When [Remote Control](/en/remote-control) is connected, send a push notification to your phone when a permission prompt or question is waiting for your input. Default: `false`. Appears in `/config` as **Push when actions required**. See [Mobile push notifications](/en/remote-control#mobile-push-notifications). Requires Claude Code v2.1.119 or later | `true` | +| `language` | Configure Claude's preferred response language (e.g., `"japanese"`, `"spanish"`, `"french"`). Claude will respond in this language by default. Also sets the language for [voice dictation](/en/voice-dictation#change-the-dictation-language) and auto-generated session titles. {/* min-version: 2.1.176 */}As of v2.1.176, when not set, session titles match the language of your conversation | `"japanese"` | +| `maxSkillDescriptionChars` | {/* min-version: 2.1.105 */}Per-skill character cap on the combined `description` and `when_to_use` text in the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn (default: `1536`). Text longer than this is truncated. Raise to keep long descriptions intact at the cost of more context per turn; lower to fit more skills under [`skillListingBudgetFraction`](#available-settings). Requires Claude Code v2.1.105 or later | `2048` | +| `minimumVersion` | Floor that prevents background auto-updates and `claude update` from installing a version below this one. Switching from the `"latest"` channel to `"stable"` via `/config` prompts you to stay on the current version or allow the downgrade. Choosing to stay sets this value. Also useful in [managed settings](/en/permissions#managed-settings) to pin an organization-wide minimum. For a hard floor that blocks startup entirely, see `requiredMinimumVersion` | `"2.1.100"` | +| `model` | Override the default model to use for Claude Code. `--model` and [`ANTHROPIC_MODEL`](/en/model-config#environment-variables) override this for one session | `"claude-sonnet-4-6"` | +| `modelOverrides` | Map Anthropic model IDs to provider-specific model IDs such as Bedrock inference profile ARNs. Each model picker entry uses its mapped value when calling the provider API. See [Override model IDs per version](/en/model-config#override-model-ids-per-version) | `{"claude-opus-4-6": "arn:aws:bedrock:..."}` | +| `otelHeadersHelper` | Script to generate dynamic OpenTelemetry headers. Runs at startup and periodically. Set the refresh interval with [`CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS`](/en/env-vars). See [Dynamic headers](/en/monitoring-usage#dynamic-headers) | `/bin/generate_otel_headers.sh` | +| `outputStyle` | Configure an output style to adjust the system prompt. See [output styles documentation](/en/output-styles) | `"Explanatory"` | +| `parentSettingsBehavior` | {/* min-version: 2.1.133 */}(Managed settings only) Controls whether managed settings supplied programmatically by an embedding host process, such as the Agent SDK or an IDE extension, apply when an admin-deployed managed tier is also present. `"first-wins"`: the parent-supplied settings are dropped and only the admin tier applies. `"merge"`: the parent-supplied settings apply under the admin tier, filtered so they can tighten policy but not loosen it. Has no effect when no admin tier is deployed. Default: `"first-wins"`. Requires Claude Code v2.1.133 or later | `"merge"` | +| `permissions` | See table below for structure of permissions. | | +| `plansDirectory` | Customize where plan files are stored. Path is relative to project root. Default: `~/.claude/plans` | `"./plans"` | +| `pluginSuggestionMarketplaces` | (Managed settings only) Marketplace names whose plugins can appear as contextual install suggestions. No marketplace-declared suggestions surface without this allowlist; the built-in first-party frontend-design tip is unaffected. Suggestions come from each plugin's `relevance` declaration in its marketplace entry. A name only takes effect when the marketplace is registered on the machine and its registered source is also declared in managed settings, either as the `extraKnownMarketplaces` entry for that name or as an entry of `strictKnownMarketplaces`. A marketplace registered from a different source under an allowlisted name is ignored. The official marketplace is exempt from the source requirement: allowlisting its name alone suffices, since that name can only register from the official Anthropic source. | `["acme-corp-plugins"]` | +| `pluginTrustMessage` | (Managed settings only) Custom message appended to the plugin trust warning shown before installation. Use this to add organization-specific context, for example to confirm that plugins from your internal marketplace are vetted. | `"All plugins from our marketplace are approved by IT"` | +| `policyHelper` | {/* min-version: 2.1.136 */}Admin-deployed executable that computes managed settings dynamically at startup. Only honored from MDM or a system `managed-settings.json` file. See [Compute managed settings with a policy helper](#compute-managed-settings-with-a-policy-helper). Requires Claude Code v2.1.136 or later | `{"path": "/usr/local/bin/claude-policy"}` | +| `preferredNotifChannel` | Method for task-complete and permission-prompt notifications: `"auto"`, `"terminal_bell"`, `"iterm2"`, `"iterm2_with_bell"`, `"kitty"`, `"ghostty"`, or `"notifications_disabled"`. Default: `"auto"`, which sends a desktop notification in iTerm2, Ghostty, and Kitty and does nothing in other terminals. Set `"terminal_bell"` to ring the bell character in any terminal. Appears in `/config` as **Notifications**. See [Get a terminal bell or notification](/en/terminal-config#get-a-terminal-bell-or-notification) | `"terminal_bell"` | +| `prefersReducedMotion` | Reduce or disable UI animations (spinners, shimmer, flash effects) for accessibility | `true` | +| `prUrlTemplate` | URL template for the PR badge shown in the footer and in tool-result summaries. Substitutes `{host}`, `{owner}`, `{repo}`, `{number}`, and `{url}` from the `gh`-reported PR URL. Use to point PR links at an internal code-review tool instead of `github.com`. Does not affect `#123` autolinks in Claude's prose | `"https://reviews.example.com/{owner}/{repo}/pull/{number}"` | +| `remoteControlAtStartup` | {/* min-version: 2.1.119 */}Connect [Remote Control](/en/remote-control) automatically when each interactive session starts, instead of waiting for `/remote-control`. Set to `true` to always auto-connect, `false` to never auto-connect, or leave unset to follow your organization's default. Appears in `/config` as **Enable Remote Control for all sessions**. See [Enable Remote Control for all sessions](/en/remote-control#enable-remote-control-for-all-sessions) | `false` | +| `requiredMaximumVersion` | Managed settings only. Maximum Claude Code version allowed to start. If the running version is newer, Claude Code exits at startup and instructs the user to install an approved version through the organization's approved method; `claude install ` may also work. Background auto-updates and `claude update` skip versions above the ceiling, so an in-range installation stays in range. `claude update`, `claude install`, and `claude doctor` keep working above the ceiling so users can recover. Versions that predate this setting ignore it | `"2.1.150"` | +| `requiredMinimumVersion` | Managed settings only. Minimum Claude Code version required to start. If the running version is older, Claude Code exits at startup and instructs the user to update through the organization's approved method. `claude update`, `claude install`, and `claude doctor` keep working below the floor so users can recover. Differs from `minimumVersion`, which prevents downgrades but never blocks startup. Versions that predate this setting ignore it | `"2.1.150"` | +| `respectGitignore` | Control whether the `@` file picker respects `.gitignore` patterns. When `true` (default), files matching `.gitignore` patterns are excluded from suggestions | `false` | +| `respondToBashCommands` | {/* min-version: 2.1.186 */}Whether Claude responds after an input-box `!` shell command runs. Set to `false` to add the command output to context without a response. Default: `true`. See [Shell mode with `!` prefix](/en/interactive-mode#shell-mode-with-prefix). Requires Claude Code v2.1.186 or later | `false` | +| `showClearContextOnPlanAccept` | Show the "clear context" option on the plan accept screen. Defaults to `false`. Set to `true` to restore the option | `true` | +| `showThinkingSummaries` | Show [extended thinking](/en/model-config#extended-thinking) summaries in interactive sessions. When unset or `false` (default in interactive mode), thinking blocks are redacted by the API and shown as a collapsed stub. Redaction only changes what you see, not what the model generates: to reduce thinking spend, [lower the budget or disable thinking](/en/model-config#extended-thinking) instead. This setting has no effect in non-interactive mode (`-p`), the Agent SDK, or IDE extensions such as VS Code | `true` | +| `showTurnDuration` | Show turn duration messages after responses, e.g. "Cooked for 1m 6s". Default: `true`. Appears in `/config` as **Show turn duration** | `false` | +| `skillListingBudgetFraction` | {/* min-version: 2.1.105 */}Fraction of the model's context window reserved for the [skill listing](/en/skills#skill-descriptions-are-cut-short) Claude sees each turn (default: `0.01` = 1%). When the listing exceeds the budget, descriptions for the least-used skills are collapsed to bare names so Claude can still invoke them but won't see why. Raise to keep more descriptions visible at the cost of more context per turn. `/doctor` shows the current truncation count and which skills are affected. Requires Claude Code v2.1.105 or later | `0.02` | +| `skillOverrides` | {/* min-version: 2.1.129 */}Per-skill visibility overrides keyed by skill name. Value is `"on"`, `"name-only"`, `"user-invocable-only"`, or `"off"`. Lets you hide or collapse a skill without editing its SKILL.md. Does not apply to plugin skills, which are managed through `/plugin`. The `/skills` menu writes these to `.claude/settings.local.json`. See [Override skill visibility from settings](/en/skills#override-skill-visibility-from-settings). Requires Claude Code v2.1.129 or later | `{"legacy-context": "name-only", "deploy": "off"}` | +| `skipWebFetchPreflight` | Skip the [WebFetch domain safety check](/en/data-usage#webfetch-domain-safety-check) that sends each requested hostname to `api.anthropic.com` before fetching. Set to `true` in environments that block traffic to Anthropic, such as Bedrock, Vertex AI, or Foundry deployments with restrictive egress. When skipped, WebFetch attempts any URL without consulting the blocklist | `true` | +| `spinnerTipsEnabled` | Show tips in the spinner while Claude is working. Set to `false` to disable tips (default: `true`) | `false` | +| `spinnerTipsOverride` | Override spinner tips with custom strings. `tips`: array of tip strings. `excludeDefault`: if `true`, only show custom tips; if `false` or absent, custom tips are merged with built-in tips | `{ "excludeDefault": true, "tips": ["Use our internal tool X"] }` | +| `spinnerVerbs` | Customize the action verbs shown while a turn is in progress. Set `mode` to `"replace"` to use only your verbs, or `"append"` to add them to the defaults | `{"mode": "append", "verbs": ["Pondering", "Crafting"]}` | +| `sshConfigs` | SSH connections to show in the [Desktop](/en/desktop#pre-configure-ssh-connections-for-your-team) environment dropdown. Each entry requires `id`, `name`, and `sshHost`; `sshPort`, `sshIdentityFile`, and `startDirectory` are optional. When set in managed settings, connections are read-only for users. Read from managed and user settings only | `[{"id": "dev-vm", "name": "Dev VM", "sshHost": "user@dev.example.com"}]` | +| `statusLine` | Configure a custom status line to display context. See [`statusLine` documentation](/en/statusline) | `{"type": "command", "command": "~/.claude/statusline.sh"}` | +| `strictKnownMarketplaces` | (Managed settings only) Allowlist of plugin marketplace sources. Undefined = no restrictions, empty array = lockdown. Enforced on marketplace add and on plugin install, update, refresh, and auto-update, so a marketplace added before the policy was set cannot be used to fetch plugins. See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) | `[{ "source": "github", "repo": "acme-corp/plugins" }]` | +| `strictPluginOnlyCustomization` | (Managed settings only) Block skills, agents, hooks, and MCP servers from user and project sources, so they can only come from plugins or managed settings. `true` locks all four surfaces; an array locks only the named ones. See [`strictPluginOnlyCustomization`](#strictpluginonlycustomization) | `["skills", "hooks"]` | +| `syntaxHighlightingDisabled` | Disable syntax highlighting in diffs, code blocks, and file previews | `true` | +| `teammateMode` | How [agent team](/en/agent-teams) teammates display: `in-process` (the default), `auto` (split panes when running inside tmux or iTerm2, in-process otherwise), `tmux` (split panes using tmux or iTerm2, detected from your terminal), or {/* min-version: 2.1.186 */}`iterm2` (iTerm2 native split panes via the `it2` CLI, added in v2.1.186). The default changed from `auto` in v2.1.179. `--teammate-mode` overrides this for one session. See [choose a display mode](/en/agent-teams#choose-a-display-mode) | `"auto"` | +| `terminalProgressBarEnabled` | Show the terminal progress bar in supported terminals: ConEmu, Ghostty 1.2.0+, and iTerm2 3.6.6+. Default: `true`. Appears in `/config` as **Terminal progress bar** | `false` | +| `theme` | {/* min-version: 2.1.119 */}Color theme for the interface: `"auto"`, `"dark"`, `"light"`, `"dark-daltonized"`, `"light-daltonized"`, `"dark-ansi"`, `"light-ansi"`, or a custom theme reference such as `"custom:"` or `"custom::"`. Default: `"dark"`. See [Create a custom theme](/en/terminal-config#create-a-custom-theme). Appears in `/config` as **Theme** | `"dark"` | +| `tui` | Terminal UI renderer. Use `"fullscreen"` for the flicker-free [alt-screen renderer](/en/fullscreen) with virtualized scrollback. Use `"default"` for the classic main-screen renderer. Set via `/tui`. You can also set the [`CLAUDE_CODE_NO_FLICKER`](/en/env-vars) environment variable. Background sessions opened from [agent view](/en/agent-view) always use the fullscreen renderer regardless of this setting | `"fullscreen"` | +| `ultracode` | Turn on [ultracode](/en/workflows#let-claude-decide-with-ultracode) for the session. Session-only and not read from `settings.json`. Set through `/effort ultracode`, `--settings`, or an Agent SDK control request | `true` | +| `useAutoModeDuringPlan` | Whether plan mode uses auto mode semantics when auto mode is available. Default: `true`. Not read from shared project settings. Appears in `/config` as "Use auto mode during plan" | `false` | +| `verbose` | {/* min-version: 2.1.119 */}Show full tool output instead of truncated summaries. Default: `false`. Appears in `/config` as **Verbose output**. The `--verbose` flag overrides this for one session | `true` | +| `viewMode` | Default transcript view mode on startup: `"default"`, `"verbose"`, or `"focus"`. Overrides the sticky `/focus` selection when set. The `--verbose` flag overrides this for one session | `"verbose"` | +| `voice` | [Voice dictation](/en/voice-dictation) settings: `enabled` turns dictation on, `mode` selects `"hold"` or `"tap"`, and `autoSubmit` sends the prompt on key release in hold mode. Written automatically when you run `/voice`. Requires a Claude.ai account | `{ "enabled": true, "mode": "tap" }` | +| `voiceEnabled` | Legacy alias for `voice.enabled`. Prefer the `voice` object | `true` | +| `wheelScrollAccelerationEnabled` | {/* min-version: 2.1.174 */}In [fullscreen rendering](/en/fullscreen#mouse-wheel-scrolling), accelerate mouse-wheel scroll speed during fast scrolls. Default: `true`. Set to `false` for a constant scroll rate per wheel notch. Requires Claude Code v2.1.174 or later | `false` | +| `workflowKeywordTriggerEnabled` | {/* min-version: 2.1.157 */}Whether the keyword `ultracode` in a prompt triggers a [dynamic workflow](/en/workflows#ask-for-a-workflow-in-your-prompt). Set to `false` to type the word without triggering one. The `ultracode` effort setting, `/workflows`, and saved workflow commands are unaffected. Default: `true`. Appears in `/config` as **Ultracode keyword trigger**. Added in v2.1.157; before v2.1.160 the trigger keyword was `workflow` | `false` | +| `wslInheritsWindowsSettings` | (Windows managed settings only) When `true`, Claude Code on WSL reads managed settings from the Windows policy chain in addition to `/etc/claude-code`, with Windows sources taking priority. Only honored when set in the HKLM registry key or `C:\Program Files\ClaudeCode\managed-settings.json`, both of which require Windows admin to write. For HKCU policy to also apply on WSL, the flag must additionally be set in HKCU itself. Has no effect on native Windows | `true` | ### Global config settings These settings are stored in `~/.claude.json` rather than `settings.json`. Adding them to `settings.json` will trigger a schema validation error. -| Key | Description | Example | -| :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------- | -| `autoConnectIde` | Automatically connect to a running IDE when Claude Code starts from an external terminal. Default: `false`. Appears in `/config` as **Auto-connect to IDE (external terminal)** when running outside a VS Code or JetBrains terminal | `true` | -| `autoInstallIdeExtension` | Automatically install the Claude Code IDE extension when running from a VS Code terminal. Default: `true`. Appears in `/config` as **Auto-install IDE extension** when running inside a VS Code or JetBrains terminal. You can also set the [`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL`](/en/env-vars) environment variable | `false` | -| `autoScrollEnabled` | In [fullscreen rendering](/en/fullscreen), follow new output to the bottom of the conversation. Default: `true`. Appears in `/config` as **Auto-scroll**. Permission prompts still scroll into view when this is off | `false` | -| `editorMode` | Key binding mode for the input prompt: `"normal"` or `"vim"`. Default: `"normal"`. Appears in `/config` as **Editor mode** | `"vim"` | -| `externalEditorContext` | Prepend Claude's previous response as `#`-commented context when you open the external editor with `Ctrl+G`. Default: `false`. Appears in `/config` as **Show last response in external editor** | `true` | -| `showTurnDuration` | Show turn duration messages after responses, e.g. "Cooked for 1m 6s". Default: `true`. Appears in `/config` as **Show turn duration** | `false` | -| `terminalProgressBarEnabled` | Show the terminal progress bar in supported terminals: ConEmu, Ghostty 1.2.0+, and iTerm2 3.6.6+. Default: `true`. Appears in `/config` as **Terminal progress bar** | `false` | -| `teammateMode` | How [agent team](/en/agent-teams) teammates display: `auto` (picks split panes in tmux or iTerm2, in-process otherwise), `in-process`, or `tmux`. See [choose a display mode](/en/agent-teams#choose-a-display-mode) | `"in-process"` | + + Versions before v2.1.119 also store a number of `/config` preference keys here instead of in `settings.json`, including `theme`, `verbose`, `editorMode`, `autoCompactEnabled`, and `preferredNotifChannel`. + + +| Key | Description | Example | +| :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------- | +| `autoConnectIde` | Automatically connect to a running IDE when Claude Code starts from an external terminal. Default: `false`. Appears in `/config` as **Auto-connect to IDE (external terminal)** when running outside a VS Code or JetBrains terminal. The [`CLAUDE_CODE_AUTO_CONNECT_IDE`](/en/env-vars) environment variable overrides this when set | `true` | +| `autoInstallIdeExtension` | Automatically install the Claude Code IDE extension when running from a VS Code terminal. Default: `true`. Appears in `/config` as **Auto-install IDE extension** when running inside a VS Code or JetBrains terminal. You can also set the [`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL`](/en/env-vars) environment variable | `false` | +| `externalEditorContext` | Prepend Claude's previous response as `#`-commented context when you open the external editor with `Ctrl+G`. Default: `false`. Appears in `/config` as **Show last response in external editor** | `true` | +| `teammateDefaultModel` | Default model for [agent team](/en/agent-teams) teammates when the spawn prompt doesn't specify one. Set to a model alias such as `"sonnet"`, or `null` to inherit the lead's current `/model` selection. Appears in `/config` as **Default teammate model** | `"sonnet"` | ### Worktree settings -Configure how `--worktree` creates and manages git worktrees. Use these settings to reduce disk usage and startup time in large monorepos. +Configure how `--worktree` creates and manages git worktrees. -| Key | Description | Example | -| :---------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------ | -| `worktree.symlinkDirectories` | Directories to symlink from the main repository into each worktree to avoid duplicating large directories on disk. No directories are symlinked by default | `["node_modules", ".cache"]` | -| `worktree.sparsePaths` | Directories to check out in each worktree via git sparse-checkout (cone mode). Only the listed paths are written to disk, which is faster in large monorepos | `["packages/my-app", "shared/utils"]` | +| Key | Description | Example | +| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------ | +| `worktree.baseRef` | Which ref new worktrees branch from. `"fresh"` (default) branches from `origin/` for a clean tree matching the remote. `"head"` branches from your current local `HEAD`, so unpushed commits and feature-branch state are present in the worktree. Applies to `--worktree`, the `EnterWorktree` tool, and subagent isolation | `"head"` | +| `worktree.symlinkDirectories` | Directories to symlink from the main repository into each worktree to avoid duplicating large directories on disk. No directories are symlinked by default | `["node_modules", ".cache"]` | +| `worktree.sparsePaths` | Directories to check out in each worktree via git sparse-checkout. Only the listed directories plus root-level files are written to disk, which is faster in large monorepos | `["packages/my-app", "shared/utils"]` | +| `worktree.bgIsolation` | {/* min-version: 2.1.143 */}Isolation mode for [background sessions](/en/agent-view#how-file-edits-are-isolated). `"worktree"` (default) blocks `Edit`/`Write` in the main checkout until `EnterWorktree` is called. `"none"` lets background jobs edit the working copy directly. Requires Claude Code v2.1.143 or later | `"none"` | -To copy gitignored files like `.env` into new worktrees, use a [`.worktreeinclude` file](/en/common-workflows#copy-gitignored-files-to-worktrees) in your project root instead of a setting. +To copy gitignored files like `.env` into new worktrees, use a [`.worktreeinclude` file](/en/worktrees#copy-gitignored-files-into-worktrees) in your project root instead of a setting. ### Permission settings -| Keys | Description | Example | -| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | -| `allow` | Array of permission rules to allow tool use. See [Permission rule syntax](#permission-rule-syntax) below for pattern matching details | `[ "Bash(git diff *)" ]` | -| `ask` | Array of permission rules to ask for confirmation upon tool use. See [Permission rule syntax](#permission-rule-syntax) below | `[ "Bash(git push *)" ]` | -| `deny` | Array of permission rules to deny tool use. Use this to exclude sensitive files from Claude Code access. See [Permission rule syntax](#permission-rule-syntax) and [Bash permission limitations](/en/permissions#tool-specific-permission-rules) | `[ "WebFetch", "Bash(curl *)", "Read(./.env)", "Read(./secrets/**)" ]` | -| `additionalDirectories` | Additional [working directories](/en/permissions#working-directories) for file access. Most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories | `[ "../docs/" ]` | -| `defaultMode` | Default [permission mode](/en/permission-modes) when opening Claude Code. Valid values: `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`. The `--permission-mode` CLI flag overrides this setting for a single session | `"acceptEdits"` | -| `disableBypassPermissionsMode` | Set to `"disable"` to prevent `bypassPermissions` mode from being activated. This disables the `--dangerously-skip-permissions` command-line flag. Typically placed in [managed settings](/en/permissions#managed-settings) to enforce organizational policy, but works from any scope | `"disable"` | -| `skipDangerousModePermissionPrompt` | Skip the confirmation prompt shown before entering bypass permissions mode via `--dangerously-skip-permissions` or `defaultMode: "bypassPermissions"`. Ignored when set in project settings (`.claude/settings.json`) to prevent untrusted repositories from auto-bypassing the prompt | `true` | +| Keys | Description | Example | +| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------- | +| `allow` | Array of permission rules to allow tool use. Tool-name globs are supported only in the tool position after a literal `mcp____` prefix, such as `mcp__github__get_*`; the server segment must be glob-free. See [Permission rule syntax](#permission-rule-syntax) below for pattern matching details | `[ "Bash(git diff *)" ]` | +| `ask` | Array of permission rules to ask for confirmation upon tool use. See [Permission rule syntax](#permission-rule-syntax) below | `[ "Bash(git push *)" ]` | +| `deny` | Array of permission rules to deny tool use. Use this to exclude sensitive files from Claude Code access. Tool names accept glob patterns: `"*"` denies every tool and `"mcp__*"` denies all MCP tools. See [Permission rule syntax](#permission-rule-syntax) and [Bash permission limitations](/en/permissions#tool-specific-permission-rules) | `[ "WebFetch", "Bash(curl *)", "Read(./.env)", "Read(./secrets/**)" ]` | +| `additionalDirectories` | Additional [working directories](/en/permissions#working-directories) for file access. Most `.claude/` configuration is [not discovered](/en/permissions#additional-directories-grant-file-access-not-configuration) from these directories | `[ "../docs/" ]` | +| `defaultMode` | Default [permission mode](/en/permission-modes) when opening Claude Code. Valid values: `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`. {/* min-version: 2.1.142 */}As of Claude Code v2.1.142, `auto` is ignored when set in project or local settings (`.claude/settings.json`, `.claude/settings.local.json`) so a repository cannot grant itself auto mode. Set it in `~/.claude/settings.json` instead. The `--permission-mode` CLI flag overrides this setting for a single session | `"acceptEdits"` | +| `disableBypassPermissionsMode` | Set to `"disable"` to prevent `bypassPermissions` mode from being activated. This disables the `--dangerously-skip-permissions` command-line flag. Typically placed in [managed settings](/en/permissions#managed-settings) to enforce organizational policy, but works from any scope | `"disable"` | +| `skipDangerousModePermissionPrompt` | Skip the confirmation prompt shown before entering bypass permissions mode via `--dangerously-skip-permissions` or `defaultMode: "bypassPermissions"`. Ignored when set in project settings (`.claude/settings.json`) to prevent untrusted repositories from auto-bypassing the prompt | `true` | ### Permission rule syntax -Permission rules follow the format `Tool` or `Tool(specifier)`. Rules are evaluated in order: deny rules first, then ask, then allow. The first matching rule wins. +Permission rules follow the format `Tool` or `Tool(specifier)`. Rules are evaluated in order: deny rules first, then ask, then allow. The first match determines the outcome regardless of rule specificity. See the [permission rule evaluation order](/en/permissions#manage-permissions) for details. Quick examples: @@ -291,33 +369,38 @@ For the complete rule syntax reference, including wildcard behavior, tool-specif Configure advanced sandboxing behavior. Sandboxing isolates bash commands from your filesystem and network. See [Sandboxing](/en/sandboxing) for details. -| Keys | Description | Example | -| :------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------- | -| `enabled` | Enable bash sandboxing (macOS, Linux, and WSL2). Default: false | `true` | -| `failIfUnavailable` | Exit with an error at startup if `sandbox.enabled` is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. Intended for managed settings deployments that require sandboxing as a hard gate | `true` | -| `autoAllowBashIfSandboxed` | Auto-approve bash commands when sandboxed. Default: true | `true` | -| `excludedCommands` | Commands that should run outside of the sandbox | `["docker *"]` | -| `allowUnsandboxedCommands` | Allow commands to run outside the sandbox via the `dangerouslyDisableSandbox` parameter. When set to `false`, the `dangerouslyDisableSandbox` escape hatch is completely disabled and all commands must run sandboxed (or be in `excludedCommands`). Useful for enterprise policies that require strict sandboxing. Default: true | `false` | -| `filesystem.allowWrite` | Additional paths where sandboxed commands can write. Arrays are merged across all settings scopes: user, project, and managed paths are combined, not replaced. Also merged with paths from `Edit(...)` allow permission rules. See [path prefixes](#sandbox-path-prefixes) below. | `["/tmp/build", "~/.kube"]` | -| `filesystem.denyWrite` | Paths where sandboxed commands cannot write. Arrays are merged across all settings scopes. Also merged with paths from `Edit(...)` deny permission rules. | `["/etc", "/usr/local/bin"]` | -| `filesystem.denyRead` | Paths where sandboxed commands cannot read. Arrays are merged across all settings scopes. Also merged with paths from `Read(...)` deny permission rules. | `["~/.aws/credentials"]` | -| `filesystem.allowRead` | Paths to re-allow reading within `denyRead` regions. Takes precedence over `denyRead`. Arrays are merged across all settings scopes. Use this to create workspace-only read access patterns. | `["."]` | -| `filesystem.allowManagedReadPathsOnly` | (Managed settings only) Only `filesystem.allowRead` paths from managed settings are respected. `denyRead` still merges from all sources. Default: false | `true` | -| `network.allowUnixSockets` | (macOS only) Unix socket paths accessible in sandbox. Ignored on Linux and WSL2, where the seccomp filter cannot inspect socket paths; use `allowAllUnixSockets` instead. | `["~/.ssh/agent-socket"]` | -| `network.allowAllUnixSockets` | Allow all Unix socket connections in sandbox. On Linux and WSL2 this is the only way to permit Unix sockets, since it skips the seccomp filter that otherwise blocks `socket(AF_UNIX, ...)` calls. Default: false | `true` | -| `network.allowLocalBinding` | Allow binding to localhost ports (macOS only). Default: false | `true` | -| `network.allowMachLookup` | Additional XPC/Mach service names the sandbox may look up (macOS only). Supports a single trailing `*` for prefix matching. Needed for tools that communicate via XPC such as the iOS Simulator or Playwright. | `["com.apple.coresimulator.*"]` | -| `network.allowedDomains` | Array of domains to allow for outbound network traffic. Supports wildcards (e.g., `*.example.com`). | `["github.com", "*.npmjs.org"]` | -| `network.deniedDomains` | Array of domains to block for outbound network traffic. Supports the same wildcard syntax as `allowedDomains`. Takes precedence over `allowedDomains` when both match. Merged from all settings sources regardless of `allowManagedDomainsOnly`. | `["sensitive.cloud.example.com"]` | -| `network.allowManagedDomainsOnly` | (Managed settings only) Only `allowedDomains` and `WebFetch(domain:...)` allow rules from managed settings are respected. Domains from user, project, and local settings are ignored. Non-allowed domains are blocked automatically without prompting the user. Denied domains are still respected from all sources. Default: false | `true` | -| `network.httpProxyPort` | HTTP proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8080` | -| `network.socksProxyPort` | SOCKS5 proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8081` | -| `enableWeakerNestedSandbox` | Enable weaker sandbox for unprivileged Docker environments (Linux and WSL2 only). **Reduces security.** Default: false | `true` | -| `enableWeakerNetworkIsolation` | (macOS only) Allow access to the system TLS trust service (`com.apple.trustd.agent`) in the sandbox. Required for Go-based tools like `gh`, `gcloud`, and `terraform` to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Reduces security** by opening a potential data exfiltration path. Default: false | `true` | +| Keys | Description | Example | +| :------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | +| `enabled` | Enable bash sandboxing (macOS, Linux, and WSL2). Default: false | `true` | +| `failIfUnavailable` | Exit with an error at startup if `sandbox.enabled` is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. Intended for managed settings deployments that require sandboxing as a hard gate | `true` | +| `autoAllowBashIfSandboxed` | Auto-approve bash commands when sandboxed. Default: true | `true` | +| `excludedCommands` | Commands that should run outside of the sandbox | `["docker *"]` | +| `allowUnsandboxedCommands` | Allow commands to run outside the sandbox via the `dangerouslyDisableSandbox` parameter. When set to `false`, the `dangerouslyDisableSandbox` escape hatch is completely disabled and all commands must run sandboxed (or be in `excludedCommands`). Useful for enterprise policies that require strict sandboxing. Default: true | `false` | +| `filesystem.allowWrite` | Additional paths where sandboxed commands can write. Arrays are merged across all settings scopes: user, project, and managed paths are combined, not replaced. Also merged with paths from `Edit(...)` allow permission rules. See [path prefixes](#sandbox-path-prefixes) below. | `["/tmp/build", "~/.kube"]` | +| `filesystem.denyWrite` | Paths where sandboxed commands cannot write. Arrays are merged across all settings scopes. Also merged with paths from `Edit(...)` deny permission rules. | `["/etc", "/usr/local/bin"]` | +| `filesystem.denyRead` | Paths where sandboxed commands cannot read. Arrays are merged across all settings scopes. Also merged with paths from `Read(...)` deny permission rules. | `["~/.aws/credentials"]` | +| `filesystem.allowRead` | Paths to re-allow reading within `denyRead` regions. Takes precedence over `denyRead`. Arrays are merged across all settings scopes. Use this to create workspace-only read access patterns. | `["."]` | +| `filesystem.allowManagedReadPathsOnly` | (Managed settings only) Only `filesystem.allowRead` paths from managed settings are respected. `denyRead` still merges from all sources. Default: false | `true` | +| `credentials.files` | Credential files or directories that sandboxed commands cannot read. Applies the same read block as `filesystem.denyRead`; the separate key keeps credential paths grouped with `credentials.envVars` and apart from general filesystem rules. Each entry is `{ "path": "...", "mode": "deny" }`. Paths use the same [prefixes](#sandbox-path-prefixes) as `filesystem.*` settings. Arrays are merged across all settings scopes. Only `deny` is supported. Requires Claude Code v2.1.187 or later. | `[{ "path": "~/.aws/credentials", "mode": "deny" }]` | +| `credentials.envVars` | Environment variables to unset before running sandboxed commands. Each entry is `{ "name": "...", "mode": "deny" }`. Arrays are merged across all settings scopes. Only `deny` is supported. Requires Claude Code v2.1.187 or later. | `[{ "name": "GITHUB_TOKEN", "mode": "deny" }]` | +| `network.allowUnixSockets` | (macOS only) Unix socket paths accessible in sandbox. Ignored on Linux and WSL2, where the seccomp filter cannot inspect socket paths; use `allowAllUnixSockets` instead. | `["~/.ssh/agent-socket"]` | +| `network.allowAllUnixSockets` | Allow all Unix socket connections in sandbox. On Linux and WSL2 this is the only way to permit Unix sockets, since it skips the seccomp filter that otherwise blocks `socket(AF_UNIX, ...)` calls. Default: false | `true` | +| `network.allowLocalBinding` | Allow binding to localhost ports (macOS only). Default: false | `true` | +| `network.allowMachLookup` | Additional XPC/Mach service names the sandbox may look up (macOS only). Supports a single trailing `*` for prefix matching. Needed for tools that communicate via XPC such as the iOS Simulator or Playwright. | `["com.apple.coresimulator.*"]` | +| `network.allowedDomains` | Array of domains to allow for outbound network traffic. Supports wildcards (e.g., `*.example.com`). | `["github.com", "*.npmjs.org"]` | +| `network.deniedDomains` | Array of domains to block for outbound network traffic. Supports the same wildcard syntax as `allowedDomains`. Takes precedence over `allowedDomains` when both match. Merged from all settings sources regardless of `allowManagedDomainsOnly`. | `["sensitive.cloud.example.com"]` | +| `network.allowManagedDomainsOnly` | (Managed settings only) Only `allowedDomains` and `WebFetch(domain:...)` allow rules from managed settings are respected. Domains from user, project, and local settings are ignored. Non-allowed domains are blocked automatically without prompting the user. Denied domains are still respected from all sources. Default: false | `true` | +| `network.httpProxyPort` | HTTP proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8080` | +| `network.socksProxyPort` | SOCKS5 proxy port used if you wish to bring your own proxy. If not specified, Claude will run its own proxy. | `8081` | +| `enableWeakerNestedSandbox` | Enable weaker sandbox for unprivileged Docker environments (Linux and WSL2 only). **Reduces security.** Default: false | `true` | +| `enableWeakerNetworkIsolation` | (macOS only) Allow access to the system TLS trust service (`com.apple.trustd.agent`) in the sandbox. Required for Go-based tools like `gh`, `gcloud`, and `terraform` to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Reduces security** by opening a potential data exfiltration path. Default: false | `true` | +| `allowAppleEvents` | (macOS only) Allow sandboxed commands to send Apple Events. Required for `open`, `osascript`, and tools that open URLs in a browser, which otherwise fail with error `-600`. **Removes code-execution isolation.** Sandboxed commands can launch other applications unsandboxed with no user prompt; they can also send AppleScript commands to running applications such as Terminal, subject to the per-app macOS automation-consent prompt (TCC). Only honored from user, managed, or CLI settings, not from project settings. Default: false | `true` | +| `bwrapPath` | (Managed settings only, Linux/WSL2) Absolute path to the bubblewrap (`bwrap`) binary. Overrides automatic detection via `PATH`. Only honored from [managed settings](/en/settings#settings-precedence), not from user or project settings. Useful when `bwrap` is installed at a non-standard location in managed environments. | `/opt/admin/bwrap` | +| `socatPath` | (Managed settings only, Linux/WSL2) Absolute path to the `socat` binary used for the sandbox network proxy. Overrides automatic detection via `PATH`. Only honored from managed settings. | `/opt/admin/socat` | #### Sandbox path prefixes -Paths in `filesystem.allowWrite`, `filesystem.denyWrite`, `filesystem.denyRead`, and `filesystem.allowRead` support these prefixes: +Paths in `filesystem.allowWrite`, `filesystem.denyWrite`, `filesystem.denyRead`, `filesystem.allowRead`, and `credentials.files` support these prefixes: | Prefix | Meaning | Example | | :---------------- | :------------------------------------------------------------------------------------- | :------------------------------------------------------------------------ | @@ -363,19 +446,20 @@ Claude Code adds attribution to git commits and pull requests. These are configu * Commits use [git trailers](https://git-scm.com/docs/git-interpret-trailers) (like `Co-Authored-By`) by default, which can be customized or disabled * Pull request descriptions are plain text -| Keys | Description | -| :------- | :----------------------------------------------------------------------------------------- | -| `commit` | Attribution for git commits, including any trailers. Empty string hides commit attribution | -| `pr` | Attribution for pull request descriptions. Empty string hides pull request attribution | +| Keys | Description | +| :----------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `commit` | Attribution for git commits, including any trailers. Empty string hides commit attribution | +| `pr` | Attribution for pull request descriptions. Empty string hides pull request attribution | +| `sessionUrl` | Whether to append the claude.ai session link as a `Claude-Session` trailer on commits and a link in pull request descriptions when running from a web or Remote Control session. Defaults to `true`. Set to `false` to omit the link | **Default commit attribution:** ```text theme={null} -🤖 Generated with [Claude Code](https://claude.com/claude-code) - - Co-Authored-By: Claude Sonnet 4.6 +Co-Authored-By: Claude Sonnet 4.6 ``` +The model name in the trailer reflects the active model for the session. + **Default pull request attribution:** ```text theme={null} @@ -394,7 +478,7 @@ Claude Code adds attribution to git commits and pull requests. These are configu ``` - The `attribution` setting takes precedence over the deprecated `includeCoAuthoredBy` setting. To hide all attribution, set `commit` and `pr` to empty strings. + The `attribution` setting takes precedence over the deprecated `includeCoAuthoredBy` setting. To hide all attribution, set `commit` and `pr` to empty strings and `sessionUrl` to `false`. ### File suggestion settings @@ -429,9 +513,48 @@ src/components/Form.tsx ```bash theme={null} #!/bin/bash query=$(cat | jq -r '.query') +# Replace your-repo-file-index with your own file search command your-repo-file-index --query "$query" | head -20 ``` +### Footer link badges + +The `footerLinksRegexes` setting renders extra clickable badges in the footer below the input box. Use it to turn IDs printed by project CLIs, such as review tools and issue trackers, into session links. + +Each entry's `pattern` regex is matched against turn output: tool results, including file contents and fetched pages, and Claude's own responses. `{name}` placeholders in `url` and `label` are filled from named capture groups in the pattern. + +The following example renders a badge whenever an issue key like `PROJ-1234` appears in turn output. The `(?...)` named group captures the key, and `{key}` substitutes it into the URL and label: + +```json ~/.claude/settings.json theme={null} +{ + "footerLinksRegexes": [ + { + "type": "regex", + "pattern": "\\b(?PROJ-\\d+)\\b", + "url": "https://issues.example.com/browse/{key}", + "label": "{key}" + } + ] +} +``` + +With this configured, when `PROJ-1234` appears in a tool result or in Claude's reply, a `PROJ-1234` badge appears in the footer linking to `https://issues.example.com/browse/PROJ-1234`. + +The following constraints apply to each entry: + +| Constraint | Behavior | +| :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| URL origin | Captured values are URL-encoded and the constructed URL must share the template's literal origin. A capture can fill a path segment or query value but cannot change where the link points | +| URL length | Constructed URLs longer than 2048 characters are dropped | +| URL scheme | Must be `https`, `http`, or a recognized editor or workspace deep-link scheme: `vscode`, `vscode-insiders`, `cursor`, `windsurf`, `zed`, `jetbrains`, `idea`, `slack`, `linear`, `notion`, `figma` | +| Label | Defaults to the matched text and is truncated to 28 display columns | +| Badge count | At most 5 badges render. The oldest is displaced by newer matches and `/clear` removes them | +| Settings scope | Read from user settings, the `--settings` flag, and managed settings only. Ignored in project `.claude/settings.json` and local `.claude/settings.local.json` | + +When a turn completes, Claude Code matches each entry's `pattern` regex against the turn output on the main thread, so a slow regex blocks the UI until it finishes. Nested quantifiers such as `(a+)+$` can take exponentially long against certain inputs and freeze the session, so keep each `pattern` linear and avoid nesting `+` or `*`. + +Footer badges render alongside a [custom status line](/en/statusline) when one is configured; neither replaces the other. Use a status line for a script-driven row that computes its own content from session data, and footer badges to turn IDs from the conversation into links without a script. + ### Hook configuration These settings control which hooks are allowed to run and what HTTP hooks can access. The `allowManagedHooksOnly` setting can only be configured in [managed settings](#settings-files). The URL and env var allowlists can be set at any settings level and merge across sources. @@ -444,7 +567,7 @@ These settings control which hooks are allowed to run and what HTTP hooks can ac **Restrict HTTP hook URLs:** -Limit which URLs HTTP hooks can target. Supports `*` as a wildcard for matching. When the array is defined, HTTP hooks targeting non-matching URLs are silently blocked. +Limit which URLs HTTP hooks can target. Supports `*` as a wildcard for matching. When the array is defined, HTTP hooks targeting non-matching URLs are silently blocked. Hostname matching is case-insensitive and ignores a trailing FQDN dot, matching DNS semantics. ```json theme={null} { @@ -462,6 +585,32 @@ Limit which environment variable names HTTP hooks can interpolate into header va } ``` +### Compute managed settings with a policy helper + +The `policyHelper` setting points at an executable that computes managed settings at startup, so admins can derive policy from device posture, identity, or a remote service instead of a static file. Configure it from MDM or a system `managed-settings.json` file. Claude Code ignores `policyHelper` when it appears in any other scope, including user settings, project settings, the HKCU registry hive, and [server-managed settings](/en/server-managed-settings). + +The setting accepts these keys: + +| Key | Type | Description | +| ------------------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `path` | string | Absolute path to the helper executable | +| `timeoutMs` | number | How long to wait for the helper before treating the run as failed | +| `refreshIntervalMs` | number | How often to re-run the helper in the background. Set to `0` to disable refresh, or to at least `60000` | + +The helper writes a JSON envelope to stdout. Put the settings under a `managedSettings` key rather than at the top level, since a bare settings object parses with `managedSettings` undefined and applies nothing: + +```json theme={null} +{ + "managedSettings": { + "permissions": { "deny": ["Read(//etc/secrets/**)"] } + }, + "claudeMd": "# Organization context\n...", + "appendSystemPrompt": "Always cite the internal style guide." +} +``` + +When the helper emits `managedSettings`, that object replaces the file-based managed settings for the run. When the helper exits non-zero at startup, Claude Code prints the error and refuses to start, so a helper that needs outage resilience should serve from its own cache and exit `0`. + ### Settings precedence Settings apply in order of precedence. From highest to lowest: @@ -470,9 +619,10 @@ Settings apply in order of precedence. From highest to lowest: * Policies deployed by IT through server delivery, MDM configuration profiles, registry policies, or managed settings files * Cannot be overridden by any other level, including command line arguments * Within the managed tier, precedence is: server-managed > MDM/OS-level policies > file-based (`managed-settings.d/*.json` + `managed-settings.json`) > HKCU registry (Windows only). Only one managed source is used; sources do not merge across tiers. Within the file-based tier, drop-in files and the base file are merged together. + * Embedding hosts such as Claude Desktop can supply policy via the SDK `managedSettings` option. By default this is ignored when an admin-deployed managed source is present: server-managed settings, an MDM or OS-level policy, or a managed settings file. The user-writable HKCU registry fallback does not count as an admin-deployed source. Administrators can opt in by setting [`parentSettingsBehavior`](#available-settings) to `"merge"`. The embedder's values are filtered so they can tighten managed policy but not loosen it. 2. **Command line arguments** - * Temporary overrides for a specific session + * Temporary overrides for a specific session. JSON passed via `--settings ` merges with file-based settings using the same rules as the other layers: a key set here overrides the same key in local, project, or user settings, and omitting a key leaves the lower-layer value in place 3. **Local project settings** (`.claude/settings.local.json`) * Personal project-specific settings @@ -485,15 +635,24 @@ Settings apply in order of precedence. From highest to lowest: This hierarchy ensures that organizational policies are always enforced while still allowing teams and individuals to customize their experience. The same precedence applies whether you run Claude Code from the CLI, the [VS Code extension](/en/vs-code), or a [JetBrains IDE](/en/jetbrains). -For example, if your user settings allow `Bash(npm run *)` but a project's shared settings deny it, the project setting takes precedence and the command is blocked. +For example, if your user settings set `permissions.defaultMode` to `acceptEdits` and a project's shared settings set it to `default`, the project value applies. The example below covers how array-valued settings such as permission rules combine instead. **Array settings merge across scopes.** When the same array-valued setting (such as `sandbox.filesystem.allowWrite` or `permissions.allow`) appears in multiple scopes, the arrays are **concatenated and deduplicated**, not replaced. This means lower-priority scopes can add entries without overriding those set by higher-priority scopes, and vice versa. For example, if managed settings set `allowWrite` to `["/opt/company-tools"]` and a user adds `["~/.kube"]`, both paths are included in the final configuration. + + Two array settings do not merge this way: + + * [`fallbackModel`](#available-settings) is an ordered chain where position carries meaning: the highest-precedence file that defines it supplies the entire value. + * [`availableModels`](#available-settings): {/* min-version: 2.1.175 */}when the [highest-precedence managed source](/en/server-managed-settings#settings-precedence) defines it, that list applies as-is and user, project, and local entries cannot extend it. Across non-managed scopes the arrays merge as usual. See [Merge behavior](/en/model-config#merge-behavior). ### Verify active settings -Run `/status` inside Claude Code to see which settings sources are active and where they come from. The output shows each configuration layer (managed, user, project) along with its origin, such as `Enterprise managed settings (remote)`, `Enterprise managed settings (plist)`, `Enterprise managed settings (HKLM)`, `Enterprise managed settings (HKCU)`, or `Enterprise managed settings (file)`. If a settings file contains errors, `/status` reports the issue so you can fix it. +Run `/status` inside Claude Code to see which settings sources are active. Inside the menu, the **Status** tab includes a `Setting sources` line that lists each layer Claude Code loaded for the current session, such as `User settings` or `Project local settings`. When [managed settings](/en/admin-setup#decide-how-settings-reach-devices) are in effect, the entry shows the delivery channel in parentheses, for example `Enterprise managed settings (remote)`, `(plist)`, `(HKLM)`, `(HKCU)`, or `(file)`. A layer appears in the list only when that source is loaded with at least one key, so an empty list means no settings sources were found. + +The `Setting sources` line confirms which sources are being read. It does not show which layer supplied each individual key. The **Config** tab in the same dialog is an editor for a fixed set of toggles such as theme and verbose output, not a view of your `settings.json` contents. + +If a settings file contains errors, such as invalid JSON or a value that fails validation, `/status` lists the affected files. Run `/doctor` to see the details for each error. ### Key points about the configuration system @@ -502,7 +661,7 @@ Run `/status` inside Claude Code to see which settings sources are active and wh * **Skills**: Custom prompts that can be invoked with `/skill-name` or loaded by Claude automatically * **MCP servers**: Extend Claude Code with additional tools and integrations * **Precedence**: Higher-level configurations (Managed) override lower-level ones (User/Project) -* **Inheritance**: Settings are merged, with more specific settings adding to or overriding broader ones +* **Inheritance**: Settings merge across scopes; scalar values from higher-priority scopes override, and arrays concatenate, with two exceptions described in the [array-merge Note](#settings-precedence) ### System prompt @@ -554,8 +713,10 @@ Plugin-related settings in `settings.json`: }, "extraKnownMarketplaces": { "acme-tools": { - "source": "github", - "repo": "acme-corp/claude-plugins" + "source": { + "source": "github", + "repo": "acme-corp/claude-plugins" + } } } } @@ -563,15 +724,21 @@ Plugin-related settings in `settings.json`: #### `enabledPlugins` -Controls which plugins are enabled. Format: `"plugin-name@marketplace-name": true/false` +Controls which plugins are enabled. Format: `"plugin-name@marketplace-name": true/false`. A plugin with no entry at any scope falls back to its [`defaultEnabled`](/en/plugins-reference#default-enablement) value. **Scopes**: * **User settings** (`~/.claude/settings.json`): Personal plugin preferences * **Project settings** (`.claude/settings.json`): Project-specific plugins shared with team -* **Local settings** (`.claude/settings.local.json`): Per-machine overrides (not committed) +* **Local settings** (`.claude/settings.local.json`): Per-machine overrides, gitignored when Claude Code creates it * **Managed settings** (`managed-settings.json`): Organization-wide policy overrides that block installation at all scopes and hide the plugin from the marketplace + + Project settings take precedence over user settings, so setting a plugin to `false` in `~/.claude/settings.json` does not disable a plugin that the project's `.claude/settings.json` enables. To opt out of a project-enabled plugin on your machine, set it to `false` in `.claude/settings.local.json` instead. + + Plugins force-enabled by managed settings cannot be disabled this way, since managed settings override local settings. + + **Example**: ```json theme={null} @@ -624,6 +791,12 @@ Defines additional marketplaces that should be made available for the repository * `hostPattern`: regex pattern to match marketplace hosts (uses `hostPattern`) * `settings`: inline marketplace declared directly in settings.json without a separate hosted repository (uses `name` and `plugins`) +The `git` source type works with any git hosting service, including self-hosted GitLab and Bitbucket. Claude Code clones the repository with the same authentication that `git clone` would use on that machine: configured credential helpers, SSH keys, or a host-specific token environment variable. See [Private repositories](/en/plugin-marketplaces#private-repositories) for setup details. + +For `github` and `git` sources, set `"skipLfs": true` inside the `source` object (alongside `repo` or `url`) to skip Git LFS downloads when Claude Code clones or updates the marketplace repository. LFS pointer files remain as pointers instead of downloading their content. Use this when the repository contains large LFS objects unrelated to plugin content. {/* min-version: 2.1.153 */}Requires Claude Code v2.1.153 or later. + +Each marketplace entry also accepts an optional `autoUpdate` Boolean. Set `"autoUpdate": true` alongside `source` to make Claude Code refresh that marketplace and update its installed plugins at startup. When omitted, official Anthropic marketplaces default to `true` and all other marketplaces default to `false`. See [Configure auto-updates](/en/discover-plugins#configure-auto-updates). + Use `source: 'settings'` to declare a small set of plugins inline without setting up a hosted marketplace repository. Plugins listed here must reference external sources such as GitHub or npm. You still need to enable each plugin separately in `enabledPlugins`. ```json theme={null} @@ -663,7 +836,7 @@ Use `source: 'settings'` to declare a small set of plugins inline without settin * Only available in managed settings (`managed-settings.json`) * Cannot be overridden by user or project settings (highest precedence) * Enforced BEFORE network/filesystem operations (blocked sources never execute) -* Uses exact matching for source specifications (including `ref`, `path` for git sources), except `hostPattern`, which uses regex matching +* Uses exact matching for source specifications (including `ref`, `path` for git sources), except `hostPattern` and `pathPattern`, which use regex matching **Allowlist behavior**: @@ -673,7 +846,7 @@ Use `source: 'settings'` to declare a small set of plugins inline without settin **All supported source types**: -The allowlist supports multiple marketplace source types. Most sources use exact matching, while `hostPattern` uses regex matching against the marketplace host. +The allowlist supports multiple marketplace source types. Most sources use exact matching, while `hostPattern` and `pathPattern` use regex matching against the marketplace host and filesystem path respectively. 1. **GitHub repositories**: @@ -753,6 +926,17 @@ Host extraction by source type: * `url`: extracts hostname from the URL * `npm`, `file`, `directory`: not supported for host pattern matching +8. **Path pattern matching**: + +```json theme={null} +{ "source": "pathPattern", "pathPattern": "^/opt/approved/" } +{ "source": "pathPattern", "pathPattern": ".*" } +``` + +Fields: `pathPattern` (required: regex pattern matched against the `path` field of `file` and `directory` sources) + +Use path pattern matching to allow filesystem-based marketplaces alongside `hostPattern` restrictions for network sources. Set `".*"` to allow all local paths, or a narrower pattern to restrict to specific directories. + **Configuration examples**: Example: allow specific marketplaces only: @@ -886,6 +1070,33 @@ With only `strictKnownMarketplaces` set, users can still add the allowed marketp See [Managed marketplace restrictions](/en/plugin-marketplaces#managed-marketplace-restrictions) for user-facing documentation. +#### `strictPluginOnlyCustomization` + +**Managed settings only**: blocks skills, agents, hooks, and MCP servers from user and project sources, so they can only come from plugins or managed settings. Combine it with `strictKnownMarketplaces` to control the full customization supply chain: the marketplace allowlist controls which plugins users can install, and this setting blocks everything that doesn't come from a plugin or from managed settings. + + + `strictPluginOnlyCustomization` requires Claude Code v2.1.82 or later. Earlier versions ignore the key and keep loading user and project customizations, so the lockdown isn't enforced until clients update. + + +The value is either `true` to lock all four surfaces, or an array naming the surfaces to lock: + +```json theme={null} +{ + "strictPluginOnlyCustomization": ["skills", "hooks"] +} +``` + +For each locked surface, Claude Code skips user-level and project-level sources and loads only plugin-provided and managed sources: + +| Surface | Blocked when locked | Still loads | +| :------- | :------------------------------------------------ | :--------------------------------------------------------------------- | +| `skills` | `~/.claude/skills/`, `.claude/skills/` | Plugin skills, bundled skills, skills in the managed policy directory | +| `agents` | `~/.claude/agents/`, `.claude/agents/` | Plugin agents, built-in agents, agents in the managed policy directory | +| `hooks` | Hooks in user, project, and local `settings.json` | Plugin hooks, hooks in managed settings | +| `mcp` | Servers in `~/.claude.json` and `.mcp.json` | Plugin MCP servers, [`managed-mcp.json`](/en/managed-mcp) servers | + +Surface names that a Claude Code version doesn't recognize are ignored rather than failing the settings file, so you can add new surface names before all clients have updated. + ### Managing plugins Use the `/plugin` command to manage plugins interactively: @@ -915,4 +1126,4 @@ See the [tools reference](/en/tools-reference) for the full list and Bash tool b * [Permissions](/en/permissions): permission system, rule syntax, tool-specific patterns, and managed policies * [Authentication](/en/authentication): set up user access to Claude Code * [Debug your configuration](/en/debug-your-config): diagnose why a setting, hook, or MCP server isn't taking effect -* [Troubleshooting](/en/troubleshooting): installation, authentication, and platform issues +* [Troubleshoot installation and login](/en/troubleshoot-install): installation, authentication, and platform issues diff --git a/package.json b/package.json index 6fa1df6..954d9df 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "packageManager": "pnpm@10.4.1", "type": "module", - "description": "Agent harness toolkit — Claude Code hooks today, harness-agnostic future. Typed session export, tail tooling, and 28 hook event handlers with Zod validation.", + "description": "Agent harness toolkit — Claude Code hooks today, harness-agnostic future. Typed session export, tail tooling, and 30 hook event handlers with Zod validation.", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "claude-session-export": "./dist/cli/export-sessions.js", - "claude-session-tail": "./dist/cli/tail-session.js" + "claude-session-export": "dist/cli/export-sessions.js", + "claude-session-tail": "dist/cli/tail-session.js" }, "exports": { ".": { @@ -60,6 +61,7 @@ "build": "tsc --project tsconfig.build.json && node scripts/chmod-bins.mjs", "build:emergency": "tsc --project tsconfig.emergency.json && node scripts/chmod-bins.mjs", "dev": "tsx watch src/index.ts", + "docs:sync-upstream": "node scripts/sync-upstream-docs.mjs", "type-check": "tsc --noEmit", "type-check:strict": "tsc --noEmit --strict --noImplicitAny", "check": "pnpm run type-check && pnpm run lint", @@ -81,7 +83,9 @@ "lint:cache-clear": "rm -rf .eslint-custom.cache", "hook:test": "echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo test\"},\"hook_event_name\":\"PreToolUse\",\"session_id\":\"test\",\"transcript_path\":\"/tmp/test.json\",\"cwd\":\"/tmp\",\"permission_mode\":\"default\",\"tool_use_id\":\"test-id\"}' | tsx src/pre-tool-use/bash-validator.ts", "hook:test:notification": "echo '{\"hook_event_name\":\"Notification\",\"session_id\":\"test\",\"transcript_path\":\"/tmp/test.json\",\"cwd\":\"/tmp\",\"permission_mode\":\"default\",\"message\":\"Test notification\",\"notification_type\":\"idle_prompt\"}' | tsx src/lifecycle/notification-handler.ts", + "hook:test:setup": "echo '{\"hook_event_name\":\"Setup\",\"session_id\":\"test\",\"transcript_path\":\"/tmp/test.json\",\"cwd\":\"/tmp\",\"permission_mode\":\"default\",\"trigger\":\"init\"}' | tsx src/lifecycle/setup.ts", "hook:test:session": "echo '{\"hook_event_name\":\"SessionStart\",\"session_id\":\"test\",\"transcript_path\":\"/tmp/test.json\",\"cwd\":\"/tmp\",\"permission_mode\":\"default\",\"source\":\"startup\",\"model\":\"claude-sonnet-4-5-20250929\"}' | tsx src/lifecycle/session-start.ts", + "hook:test:message-display": "echo '{\"hook_event_name\":\"MessageDisplay\",\"session_id\":\"test\",\"transcript_path\":\"/tmp/test.json\",\"cwd\":\"/tmp\",\"permission_mode\":\"default\",\"turn_id\":\"11111111-1111-4111-8111-111111111111\",\"message_id\":\"22222222-2222-4222-8222-222222222222\",\"index\":0,\"final\":false,\"delta\":\"Test message\"}' | tsx src/lifecycle/message-display.ts", "hook:session-id-display": "tsx src/lifecycle/session-id-display.ts", "hook:eslint-disable-blocker": "tsx src/pre-tool-use/eslint-disable-blocker.ts", "export-sessions": "tsx src/cli/export-sessions.ts", @@ -92,7 +96,6 @@ "prepack": "pnpm run clean && pnpm run test:run && pnpm run check && pnpm run build" }, "dependencies": { - "tsx": "^4.0.0", "zod": "^4.3.6" }, "devDependencies": { @@ -104,6 +107,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", + "tsx": "^4.0.0", "typescript": "^5.0.0", "vite": "^6.0.0", "vitest": "^4.1.7" @@ -129,7 +133,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/libar-dev/agent-harness-kit.git" + "url": "git+https://github.com/libar-dev/agent-harness-kit.git" }, "bugs": { "url": "https://github.com/libar-dev/agent-harness-kit/issues" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d9463..693ab22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - tsx: - specifier: ^4.0.0 - version: 4.21.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -39,6 +36,9 @@ importers: prettier: specifier: ^3.0.0 version: 3.8.1 + tsx: + specifier: ^4.0.0 + version: 4.21.0 typescript: specifier: ^5.0.0 version: 5.9.3 diff --git a/scripts/sync-upstream-docs.mjs b/scripts/sync-upstream-docs.mjs new file mode 100644 index 0000000..fe0d5ee --- /dev/null +++ b/scripts/sync-upstream-docs.mjs @@ -0,0 +1,94 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); +const upstreamDir = resolve(repoRoot, 'docs', 'upstream'); + +const upstreamDocs = { + 'hooks-reference.md': 'https://code.claude.com/docs/en/hooks.md', + 'hooks-guide.md': 'https://code.claude.com/docs/en/hooks-guide.md', + 'settings.md': 'https://code.claude.com/docs/en/settings.md', + 'cli-reference.md': 'https://code.claude.com/docs/en/cli-reference.md', + 'headless.md': 'https://code.claude.com/docs/en/headless.md', +}; + +function printUsage() { + const files = Object.keys(upstreamDocs) + .map(fileName => ` - ${fileName}`) + .join('\n'); + + console.log(`Sync mirrored Claude Code docs from code.claude.com markdown endpoints.\n\nUsage:\n node scripts/sync-upstream-docs.mjs [file ...]\n\nFiles:\n${files}`); +} + +function normalizeTargets(args) { + if (args.length === 0) { + return Object.keys(upstreamDocs); + } + + return args.map(arg => { + const normalized = arg.endsWith('.md') ? arg : `${arg}.md`; + + if (!(normalized in upstreamDocs)) { + const supported = Object.keys(upstreamDocs).join(', '); + throw new Error( + `Unsupported upstream doc target: ${arg}. Supported targets: ${supported}` + ); + } + + return normalized; + }); +} + +async function fetchMarkdown(url) { + const response = await fetch(url, { + headers: { + Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.1', + }, + signal: AbortSignal.timeout(60_000), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('text/markdown')) { + throw new Error( + `Unexpected content type for ${url}: ${contentType || 'missing content-type'}` + ); + } + + const body = await response.text(); + return body.endsWith('\n') ? body : `${body}\n`; +} + +async function main() { + const args = process.argv.slice(2).filter(arg => arg !== '--'); + + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const targets = normalizeTargets(args); + mkdirSync(upstreamDir, { recursive: true }); + + for (const fileName of targets) { + const url = upstreamDocs[fileName]; + const markdown = await fetchMarkdown(url); + const destination = resolve(upstreamDir, fileName); + writeFileSync(destination, markdown, 'utf8'); + console.log(`Synced ${fileName} <- ${url}`); + } +} + +try { + await main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exitCode = 1; +} diff --git a/src/cli/export-sessions.ts b/src/cli/export-sessions.ts index 6e686e0..65e75de 100644 --- a/src/cli/export-sessions.ts +++ b/src/cli/export-sessions.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Export Claude Code sessions as clean markdown and/or structured JSONL. + * Export Claude Code sessions as markdown and/or structured JSONL. * * After install, available as: * claude-session-export [project-path] [options] @@ -12,6 +12,15 @@ * - markdown: human-readable .md * - jsonl: structured SessionBlock records — for downstream DB / AI ingestion * - both: emits both per session (default) + * + * Output contract: + * stdout: progress and written-file summaries + * stderr: fatal argument or export errors + * + * Exit codes: + * 0 — success, including no matching sessions + * 1 — project resolution or export failure + * 2 — invalid arguments */ import { writeFile, mkdir } from 'node:fs/promises'; diff --git a/src/cli/tail-session.ts b/src/cli/tail-session.ts index 5752d43..eb19492 100644 --- a/src/cli/tail-session.ts +++ b/src/cli/tail-session.ts @@ -23,10 +23,10 @@ * 1 — file not found / read error * 2 — invalid arguments * - * Designed for live-ingest consumers that spawn this from - * any language (Rust, Go, shell). In one-shot mode, pair with notify-style file - * watchers in the consumer. In --watch mode, this process owns the watching - * loop and the consumer just streams stdout. + * Intended for consumers that spawn this from any language (Rust, Go, shell). + * In one-shot mode, pair with notify-style file watchers in the consumer. + * In --watch mode, this process owns the watching loop and the consumer + * streams stdout. */ import { parseArgs } from 'node:util'; @@ -147,9 +147,7 @@ async function main(path: string, cliArgs: CliArgs): Promise { ); } -// --------------------------------------------------------------------------- -// Watch mode — long-running, emit on each append -// --------------------------------------------------------------------------- +// Watch mode: long-running output on each append. async function runWatch( path: string, @@ -350,9 +348,7 @@ async function runOnePass( return result; } -// --------------------------------------------------------------------------- -// Output helpers -// --------------------------------------------------------------------------- +// Output helpers. async function runTail( path: string, diff --git a/src/lifecycle/config-change.ts b/src/lifecycle/config-change.ts index 4280777..eb65f7a 100644 --- a/src/lifecycle/config-change.ts +++ b/src/lifecycle/config-change.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * ConfigChange Hook Handler - * - * Runs when Claude Code configuration changes. This reference handler logs the - * change without blocking it. + * ConfigChange Hook Handler — Logs configuration changes without blocking them. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/cwd-changed.ts b/src/lifecycle/cwd-changed.ts index 4339460..1c1b2fb 100644 --- a/src/lifecycle/cwd-changed.ts +++ b/src/lifecycle/cwd-changed.ts @@ -1,11 +1,7 @@ #!/usr/bin/env tsx /** - * CwdChanged Hook Handler - * - * Runs when Claude Code changes working directory. This reference handler logs - * the transition; projects can extend it to write environment updates to - * CLAUDE_ENV_FILE. + * CwdChanged Hook Handler — Logs working-directory transitions for env refresh hooks. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/elicitation-result.ts b/src/lifecycle/elicitation-result.ts index f3c7f5a..8c7f279 100644 --- a/src/lifecycle/elicitation-result.ts +++ b/src/lifecycle/elicitation-result.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * ElicitationResult Hook Handler - * - * Runs after a user responds to an MCP elicitation. This reference handler - * logs the result without changing the response. + * ElicitationResult Hook Handler — Logs user elicitation responses without changing them. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/elicitation.ts b/src/lifecycle/elicitation.ts index ce259e2..ea8b4d9 100644 --- a/src/lifecycle/elicitation.ts +++ b/src/lifecycle/elicitation.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * Elicitation Hook Handler - * - * Runs when an MCP server asks Claude Code to collect user input. This - * reference handler logs the request without answering for the user. + * Elicitation Hook Handler — Logs MCP elicitation requests without answering them. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/file-changed.ts b/src/lifecycle/file-changed.ts index 1e40918..bca110b 100644 --- a/src/lifecycle/file-changed.ts +++ b/src/lifecycle/file-changed.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * FileChanged Hook Handler - * - * Runs when a watched file changes. This reference handler logs the event; - * projects can extend it to refresh environment state. + * FileChanged Hook Handler — Logs watched file changes for environment refresh hooks. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts index 7e2fb61..ecffbdc 100644 --- a/src/lifecycle/index.ts +++ b/src/lifecycle/index.ts @@ -1,16 +1,7 @@ #!/usr/bin/env tsx /** - * Combined Lifecycle Hook Handler - * - * This module provides handlers for all Claude Code lifecycle events: - * - UserPromptSubmit: Validate and enhance user prompts - * - Notification: Handle notifications with custom backends - * - SessionStart: Load project context at session start - * - SessionEnd: Cleanup and logging at session end - * - Stop: Handle session stop events - * - PreCompact: Handle context compaction events - * - Other lifecycle events: Reference handlers for current Claude Code hooks + * Lifecycle Hook Handler — Dispatches lifecycle inputs to event-specific handlers. */ import { @@ -44,10 +35,9 @@ import { handleWorktreeRemove } from './worktree-remove.js'; import { handlePostCompact } from './post-compact.js'; import { handleElicitation } from './elicitation.js'; import { handleElicitationResult } from './elicitation-result.js'; +import { handleSetup } from './setup.js'; +import { handleMessageDisplay } from './message-display.js'; -/** - * Main lifecycle event router - */ async function handleLifecycleEvent(input: HookInput): Promise { const { hook_event_name, session_id } = input; @@ -64,8 +54,13 @@ async function handleLifecycleEvent(input: HookInput): Promise { }); } - // Route to specific handlers based on event type switch (hook_event_name) { + case 'Setup': + if (isHookType(input, 'Setup')) { + await handleSetup(input); + } + break; + case 'UserPromptSubmit': if (isHookType(input, 'UserPromptSubmit')) { await validateUserPrompt(input); @@ -78,6 +73,12 @@ async function handleLifecycleEvent(input: HookInput): Promise { } break; + case 'MessageDisplay': + if (isHookType(input, 'MessageDisplay')) { + await handleMessageDisplay(input); + } + break; + case 'SessionStart': if (isHookType(input, 'SessionStart')) { await handleSessionStart(input); @@ -212,9 +213,6 @@ async function handleLifecycleEvent(input: HookInput): Promise { logDebug(`Lifecycle hook completed: ${hook_event_name}`); } -/** - * Log session statistics - */ async function logSessionStatistics( sessionId: string, endReason: string @@ -225,34 +223,24 @@ async function logSessionStatistics( session_id: sessionId, end_reason: endReason, end_timestamp: timestamp, - duration: 'unknown', // Could calculate if we stored start time + duration: 'unknown', }; logInfo(`Session statistics: ${JSON.stringify(sessionInfo)}`); - - // Could write to a log file or analytics service here } catch (error) { logDebug('Failed to log session statistics:', toError(error)); } } -// Removed unused _performSessionCleanup function - -/** - * Main execution entry point for specific event types - */ if (import.meta.url === `file://${process.argv[1]}`) { - // This is a generic handler - specific event handlers should be called directly executeHook(handleLifecycleEvent).catch(error => { console.error('Failed to execute lifecycle hook:', error); process.exit(1); }); } -// Export individual handlers for direct use export { handleLifecycleEvent, logSessionStatistics }; -// Re-export specific handlers for convenience export { handleNotification } from './notification-handler.js'; export { handleSessionStart } from './session-start.js'; export { validateUserPrompt } from './user-prompt-validator.js'; @@ -276,3 +264,5 @@ export { handleWorktreeRemove } from './worktree-remove.js'; export { handlePostCompact } from './post-compact.js'; export { handleElicitation } from './elicitation.js'; export { handleElicitationResult } from './elicitation-result.js'; +export { handleSetup } from './setup.js'; +export { handleMessageDisplay } from './message-display.js'; diff --git a/src/lifecycle/instructions-loaded.ts b/src/lifecycle/instructions-loaded.ts index 6b6814e..5c41414 100644 --- a/src/lifecycle/instructions-loaded.ts +++ b/src/lifecycle/instructions-loaded.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * InstructionsLoaded Hook Handler - * - * Runs after Claude Code loads a memory/instructions file. This reference - * handler logs the load event without changing behavior. + * InstructionsLoaded Hook Handler — Logs loaded memory files without changing behavior. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/message-display.ts b/src/lifecycle/message-display.ts new file mode 100644 index 0000000..638ec02 --- /dev/null +++ b/src/lifecycle/message-display.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env tsx + +/** + * MessageDisplay Hook Handler — Replaces displayed message deltas with validated content. + */ + +import { executeHook, outputJson } from '../utils/index.js'; +import type { + MessageDisplayInput, + MessageDisplayOutput, +} from '../types/index.js'; +import { validateMessageDisplayInput } from '../validation/index.js'; + +async function handleMessageDisplay(input: MessageDisplayInput): Promise { + const validatedInput = validateMessageDisplayInput(input); + const output: MessageDisplayOutput = { + hookSpecificOutput: { + hookEventName: 'MessageDisplay', + displayContent: validatedInput.delta, + }, + }; + + outputJson(output); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + void executeHook(handleMessageDisplay); +} + +export { handleMessageDisplay }; diff --git a/src/lifecycle/notification-handler.ts b/src/lifecycle/notification-handler.ts index 23b60b7..6b9e227 100644 --- a/src/lifecycle/notification-handler.ts +++ b/src/lifecycle/notification-handler.ts @@ -1,13 +1,7 @@ #!/usr/bin/env tsx /** - * Notification Handler Hook - * - * This hook handles Claude Code notifications by: - * - Sending desktop notifications when Claude needs attention - * - Logging notifications for audit purposes - * - Supporting custom notification backends (email, Slack, etc.) - * - Providing different notification styles based on message type + * Notification Hook Handler — Delivers Claude Code notifications through configured sinks. */ import { execFile } from 'node:child_process'; @@ -24,21 +18,12 @@ import type { NotificationInput } from '../types/index.js'; const execFileAsync = promisify(execFile); -/** - * Configuration for notification handling - */ interface NotificationConfig { - /** Whether to show desktop notifications */ desktop: boolean; - /** Whether to log notifications to console */ console: boolean; - /** Whether to send notifications in CI environments */ enableInCI: boolean; - /** Custom notification command */ customCommand?: string; - /** Slack webhook URL */ slackWebhook?: string; - /** Email settings */ email?: { to: string; from?: string; @@ -46,9 +31,6 @@ interface NotificationConfig { }; } -/** - * Notification data structure - */ interface NotificationData { title: string; message: string; @@ -56,9 +38,6 @@ interface NotificationData { icon: string; } -/** - * Get notification configuration from environment - */ function getNotificationConfig(): NotificationConfig { return { desktop: process.env['CLAUDE_HOOK_DESKTOP_NOTIFICATIONS'] !== 'false', @@ -87,24 +66,19 @@ function getNotificationConfig(): NotificationConfig { }; } -/** - * Main notification handling logic - */ async function handleNotification(input: NotificationInput): Promise { - const { message, notification_type, session_id: _session_id } = input; + const { message, notification_type } = input; const config = getNotificationConfig(); logInfo( `Notification received: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}` ); - // Skip notifications in CI unless explicitly enabled if (isCI() && !config.enableInCI) { logDebug('Skipping notification in CI environment'); return; } - // Determine notification type and priority const notificationType = classifyNotification(message, notification_type); const notification = { title: getNotificationTitle(notificationType), @@ -115,35 +89,28 @@ async function handleNotification(input: NotificationInput): Promise { logDebug('Notification details', notification); - // Send notifications through configured channels const promises: Promise[] = []; - // Console notification if (config.console) { promises.push(sendConsoleNotification(notification)); } - // Desktop notification if (config.desktop) { promises.push(sendDesktopNotification(notification)); } - // Custom command notification if (config.customCommand) { promises.push(sendCustomNotification(notification, config.customCommand)); } - // Slack notification if (config.slackWebhook) { promises.push(sendSlackNotification(notification, config.slackWebhook)); } - // Email notification if (config.email) { promises.push(sendEmailNotification(notification, config.email)); } - // Execute all notifications in parallel try { await Promise.all(promises); logDebug('All notifications sent successfully'); @@ -152,9 +119,6 @@ async function handleNotification(input: NotificationInput): Promise { } } -/** - * Classify notification type based on message content - */ function classifyNotification( message: string, notificationType?: NotificationInput['notification_type'] @@ -164,35 +128,40 @@ function classifyNotification( return 'permission'; case 'idle_prompt': case 'elicitation_dialog': + case 'elicitation_response': return 'waiting'; case 'auth_success': + case 'elicitation_complete': return 'info'; - default: + case undefined: break; + default: + return assertNeverNotificationType(notificationType); } const lowerMessage = message.toLowerCase(); if (lowerMessage.includes('permission') || lowerMessage.includes('approve')) { return 'permission'; - } else if ( - lowerMessage.includes('waiting') || - lowerMessage.includes('input') - ) { + } + + if (lowerMessage.includes('waiting') || lowerMessage.includes('input')) { return 'waiting'; - } else if ( - lowerMessage.includes('error') || - lowerMessage.includes('failed') - ) { + } + + if (lowerMessage.includes('error') || lowerMessage.includes('failed')) { return 'error'; - } else { - return 'info'; } + + return 'info'; +} + +function assertNeverNotificationType( + notificationType: never +): 'permission' | 'waiting' | 'error' | 'info' { + throw new Error(`Unhandled notification type: ${String(notificationType)}`); } -/** - * Get notification title based on type - */ function getNotificationTitle(type: string): string { switch (type) { case 'permission': @@ -207,16 +176,11 @@ function getNotificationTitle(type: string): string { } } -/** - * Format notification message for display - */ function formatNotificationMessage(message: string, type: string): string { - // Truncate very long messages if (message.length > 200) { message = message.substring(0, 197) + '...'; } - // Add context based on type switch (type) { case 'permission': return `${message}\n\nClick to return to Claude Code.`; @@ -229,9 +193,6 @@ function formatNotificationMessage(message: string, type: string): string { } } -/** - * Get notification priority - */ function getNotificationPriority(type: string): 'low' | 'normal' | 'high' { switch (type) { case 'permission': @@ -244,9 +205,6 @@ function getNotificationPriority(type: string): 'low' | 'normal' | 'high' { } } -/** - * Get notification icon based on type - */ function getNotificationIcon(type: string): string { switch (type) { case 'permission': @@ -260,9 +218,6 @@ function getNotificationIcon(type: string): string { } } -/** - * Send console notification - */ async function sendConsoleNotification( notification: NotificationData ): Promise { @@ -272,34 +227,27 @@ async function sendConsoleNotification( console.error(formattedMessage); } -/** - * Send desktop notification using system notification service - */ async function sendDesktopNotification( notification: NotificationData ): Promise { try { - // Try different notification systems based on platform const platform = process.platform; if (platform === 'darwin') { - // macOS - use osascript await execFileAsync('osascript', [ '-e', `display notification "${notification.message}" with title "${notification.title}"`, ]); } else if (platform === 'linux') { - // Linux - try notify-send await execFileAsync('notify-send', [ '--urgency', notification.priority === 'high' ? 'critical' : 'normal', '--icon', - 'info', // Could map to different icons + 'info', notification.title, notification.message, ]); } else if (platform === 'win32') { - // Windows - use PowerShell const psScript = ` Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${notification.message}', '${notification.title}', 'OK', 'Information'); @@ -313,15 +261,11 @@ async function sendDesktopNotification( } } -/** - * Send custom notification using user-defined command - */ async function sendCustomNotification( notification: NotificationData, command: string ): Promise { try { - // Replace placeholders in custom command const processedCommand = command .replace(/\{title\}/g, notification.title) .replace(/\{message\}/g, notification.message) @@ -334,9 +278,6 @@ async function sendCustomNotification( } } -/** - * Send Slack notification - */ async function sendSlackNotification( notification: NotificationData, webhookUrl: string @@ -361,16 +302,11 @@ async function sendSlackNotification( } } -/** - * Send email notification - */ async function sendEmailNotification( notification: NotificationData, emailConfig: NonNullable ): Promise { try { - // Simple email sending using system mail command - // For production use, consider using a proper email library const subject = notification.title.replace(/[🔐⏳❌🤖]/gu, '').trim(); const body = `${notification.message}\n\n--\nSent by Claude Code Notification System`; @@ -382,9 +318,6 @@ async function sendEmailNotification( } } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handleNotification).catch(error => { console.error('Failed to execute notification hook:', error); @@ -392,7 +325,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { handleNotification, getNotificationConfig, diff --git a/src/lifecycle/permission-denied.ts b/src/lifecycle/permission-denied.ts index 6b94d98..1bd92aa 100644 --- a/src/lifecycle/permission-denied.ts +++ b/src/lifecycle/permission-denied.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * PermissionDenied Hook Handler - * - * Runs after auto mode denies a tool call. This reference handler records the - * denial and explicitly leaves retry disabled. + * PermissionDenied Hook Handler — Logs auto-mode denials and leaves retry disabled. */ import { executeHook, logInfo, logDebug, outputJson } from '../utils/index.js'; diff --git a/src/lifecycle/permission-request.ts b/src/lifecycle/permission-request.ts index 1232024..b0b2fca 100644 --- a/src/lifecycle/permission-request.ts +++ b/src/lifecycle/permission-request.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * PermissionRequest Hook Handler - * - * Runs when Claude Code is about to show a permission dialog. This reference - * handler logs the request without changing the decision. + * PermissionRequest Hook Handler — Logs permission dialogs without changing decisions. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/post-compact.ts b/src/lifecycle/post-compact.ts index 690dbae..6ae1ff4 100644 --- a/src/lifecycle/post-compact.ts +++ b/src/lifecycle/post-compact.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * PostCompact Hook Handler - * - * Runs after context compaction completes. This reference handler logs the - * generated summary size for audit/observability use cases. + * PostCompact Hook Handler — Logs compaction results for observability. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/pre-compact.ts b/src/lifecycle/pre-compact.ts index 8d522d8..3f7f453 100644 --- a/src/lifecycle/pre-compact.ts +++ b/src/lifecycle/pre-compact.ts @@ -1,17 +1,7 @@ #!/usr/bin/env tsx /** - * PreCompact Hook Handler - * - * This hook runs before Claude Code performs a compact operation, which - * reduces the conversation context when it becomes too large. - * - * Use cases: - * - Save important context that shouldn't be lost during compaction - * - Generate project status summaries - * - Extract key decisions and outcomes - * - Validate custom compact instructions - * - Create backup references for later retrieval + * PreCompact Hook Handler — Preserves context before Claude Code compacts a conversation. */ import { @@ -160,7 +150,7 @@ async function handlePreCompact(input: PreCompactInput): Promise { if (contextSummary.length > 0) { logInfo('Pre-compact context extraction completed'); - // The context summary will be available during compaction. + // Claude Code includes additionalContext in the compaction prompt. outputJson({ hookSpecificOutput: { hookEventName: 'PreCompact', @@ -263,7 +253,7 @@ function extractKeyDecisions(transcript: string): string[] { } /** - * Extract recent file changes from transcript + * Extract file changes from transcript */ function extractRecentChanges(transcript: string): string[] { const changes: string[] = []; @@ -387,7 +377,6 @@ async function getCurrentProjectStatus(): Promise { statusParts.push(`Git: ${gitStatus}`); } - // Check if there are recent TypeScript errors const hasTypeErrors = await checkTypeScriptErrors(); if (hasTypeErrors) { statusParts.push('TypeScript: Has compilation errors'); @@ -395,7 +384,6 @@ async function getCurrentProjectStatus(): Promise { statusParts.push('TypeScript: Clean'); } - // Check for recent test results const testStatus = await getTestStatus(); if (testStatus) { statusParts.push(`Tests: ${testStatus}`); @@ -461,8 +449,8 @@ async function checkTypeScriptErrors(): Promise { * Get test status summary */ async function getTestStatus(): Promise { - // This is a placeholder - in a real implementation, you might: - // - Check for recent test results files + // This is a placeholder for project-specific test status sources: + // - Check test result files // - Parse test output // - Check test coverage reports return null; diff --git a/src/lifecycle/session-end.ts b/src/lifecycle/session-end.ts index 520e127..54c4fea 100644 --- a/src/lifecycle/session-end.ts +++ b/src/lifecycle/session-end.ts @@ -1,18 +1,7 @@ #!/usr/bin/env tsx /** - * SessionEnd Hook Handler - * - * This hook runs when a Claude Code session ends and performs cleanup tasks, - * saves session state, and generates session statistics. - * - * Use cases: - * - Clean up temporary files and processes - * - Save session statistics and metrics - * - Generate session summaries - * - Archive important session data - * - Send notifications about session completion - * - Prepare environment for next session + * SessionEnd Hook Handler — Cleans up and records session outcomes after completion. */ import { @@ -172,7 +161,7 @@ async function handleSessionEnd(input: SessionEndInput): Promise { } /** - * Generate comprehensive session statistics + * Generate session statistics */ async function generateSessionStats( input: SessionEndInput diff --git a/src/lifecycle/session-id-display.ts b/src/lifecycle/session-id-display.ts index 36cb3a3..d704445 100644 --- a/src/lifecycle/session-id-display.ts +++ b/src/lifecycle/session-id-display.ts @@ -1,11 +1,7 @@ #!/usr/bin/env tsx /** - * Session ID Display Hook - * - * Injects the session ID into Claude's context without displaying it. - * Useful for identifying which session you're working in when running - * multiple Claude Code sessions simultaneously. + * SessionStart Hook Handler — Injects the session ID without displaying hook output. */ import { outputJson, executeHook } from '../utils/index.js'; diff --git a/src/lifecycle/session-start.ts b/src/lifecycle/session-start.ts index 3ad8d8a..a1cbc3c 100644 --- a/src/lifecycle/session-start.ts +++ b/src/lifecycle/session-start.ts @@ -1,14 +1,7 @@ #!/usr/bin/env tsx /** - * Session Start Hook - * - * This hook runs when Claude Code sessions start and provides: - * - Project context and recent changes information - * - Development environment status checks - * - Git repository information and recent commits - * - Package.json and dependency information - * - Custom project-specific context loading + * SessionStart Hook Handler — Injects project context when a session begins or resumes. */ import { execFile } from 'node:child_process'; @@ -30,9 +23,6 @@ import type { SessionStartInput } from '../types/index.js'; const execFileAsync = promisify(execFile); -/** - * Package.json structure - */ interface PackageJson { name?: string; version?: string; @@ -64,29 +54,16 @@ function parsePackageJson(content: string): PackageJson { }; } -/** - * Configuration for session start behavior - */ interface SessionStartConfig { - /** Whether to load git information */ gitInfo: boolean; - /** Whether to load project dependencies info */ dependencyInfo: boolean; - /** Whether to load recent file changes */ recentChanges: boolean; - /** Whether to check development server status */ devServerStatus: boolean; - /** Maximum number of recent commits to include */ maxCommits: number; - /** Maximum number of recent changes to include */ maxChanges: number; - /** Custom context files to load */ contextFiles: string[]; } -/** - * Get session start configuration - */ function getSessionStartConfig(): SessionStartConfig { return { gitInfo: process.env['CLAUDE_HOOK_SESSION_GIT'] !== 'false', @@ -110,22 +87,20 @@ function getSessionStartConfig(): SessionStartConfig { }; } -/** - * Main session start logic - */ async function handleSessionStart(input: SessionStartInput): Promise { const { source, session_id, model, agent_type } = input; const config = getSessionStartConfig(); const projectDir = getProjectDir(); + const modelName = model ?? 'unknown'; logInfo(`Session starting (${source}) - loading project context`); const contextSections: string[] = []; - // Add basic session information - contextSections.push(getSessionInfo(source, session_id, model, agent_type)); + contextSections.push( + getSessionInfo(source, session_id, modelName, agent_type) + ); - // Load project information try { const projectInfo = await loadProjectInfo(projectDir, config); if (projectInfo) { @@ -135,7 +110,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { logError('Failed to load project info', toError(error)); } - // Load git information if (config.gitInfo) { try { const gitInfo = await loadGitInfo(projectDir, config); @@ -150,7 +124,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { } } - // Load dependency information if (config.dependencyInfo) { try { const depsInfo = await loadDependencyInfo(projectDir, config); @@ -162,7 +135,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { } } - // Check development server status if (config.devServerStatus && isDevelopment()) { try { const devStatus = await checkDevelopmentStatus(projectDir, config); @@ -174,7 +146,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { } } - // Load recent changes if (config.recentChanges) { try { const recentChanges = await loadRecentChanges(projectDir, config); @@ -186,7 +157,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { } } - // Load custom context files try { const contextFiles = await loadContextFiles( projectDir, @@ -199,7 +169,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { logDebug('Failed to load context files', toError(error)); } - // Combine all context sections if (contextSections.length > 0) { const fullContext = contextSections.join('\n\n---\n\n'); @@ -216,9 +185,6 @@ async function handleSessionStart(input: SessionStartInput): Promise { } } -/** - * Get basic session information - */ function getSessionInfo( source: string, sessionId: string, @@ -238,9 +204,6 @@ ${agentType ? `**Agent Type:** ${agentType}\n` : ''}**Timestamp:** ${timestamp} `; } -/** - * Load basic project information - */ async function loadProjectInfo( projectDir: string, _config: SessionStartConfig @@ -280,15 +243,11 @@ async function loadProjectInfo( } } -/** - * Load git repository information - */ async function loadGitInfo( projectDir: string, _config: SessionStartConfig ): Promise { try { - // Get current branch const { stdout: branch } = await execFileAsync( 'git', ['branch', '--show-current'], @@ -298,7 +257,6 @@ async function loadGitInfo( } ); - // Get recent commits const { stdout: commits } = await execFileAsync( 'git', ['log', `--oneline`, `-${_config.maxCommits}`, '--no-merges'], @@ -308,7 +266,6 @@ async function loadGitInfo( } ); - // Get repository status const { stdout: status } = await execFileAsync( 'git', ['status', '--porcelain'], @@ -329,7 +286,6 @@ async function loadGitInfo( `**Uncommitted Changes:** ${statusLines.length} files modified` ); - // Show first few changed files const changedFiles = statusLines .slice(0, 5) .map(line => ` - ${line.substring(3)}`) @@ -359,9 +315,6 @@ async function loadGitInfo( } } -/** - * Load dependency information - */ async function loadDependencyInfo( projectDir: string, _config: SessionStartConfig @@ -378,7 +331,6 @@ async function loadDependencyInfo( const depCount = Object.keys(packageJson.dependencies).length; info.push(`**Production Dependencies:** ${depCount}`); - // Highlight key dependencies const keyDeps = Object.keys(packageJson.dependencies).filter( dep => dep.includes('react') || @@ -398,7 +350,6 @@ async function loadDependencyInfo( info.push(`**Development Dependencies:** ${devDepCount}`); } - // Check if node_modules exists and when it was last modified try { const nodeModulesPath = join(projectDir, 'node_modules'); await access(nodeModulesPath, constants.F_OK); @@ -413,9 +364,6 @@ async function loadDependencyInfo( } } -/** - * Check development environment status - */ async function checkDevelopmentStatus( projectDir: string, _config: SessionStartConfig @@ -423,7 +371,6 @@ async function checkDevelopmentStatus( try { const info = [`# Development Environment`]; - // Check if TypeScript is configured try { await access(join(projectDir, 'tsconfig.json'), constants.F_OK); info.push(`**TypeScript:** Configured`); @@ -431,7 +378,6 @@ async function checkDevelopmentStatus( info.push(`**TypeScript:** Not configured`); } - // Check if ESLint is configured try { await access(join(projectDir, '.eslintrc.js'), constants.F_OK); info.push(`**ESLint:** Configured`); @@ -444,7 +390,6 @@ async function checkDevelopmentStatus( } } - // Check if Prettier is configured try { await access(join(projectDir, '.prettierrc'), constants.F_OK); info.push(`**Prettier:** Configured`); @@ -458,15 +403,11 @@ async function checkDevelopmentStatus( } } -/** - * Load custom context files - */ async function loadRecentChanges( projectDir: string, _config: SessionStartConfig ): Promise { try { - // Get recently modified files from git const { stdout: recentFiles } = await execFileAsync( 'git', ['diff', '--name-only', 'HEAD~1'], @@ -493,9 +434,6 @@ async function loadRecentChanges( } } -/** - * Load custom context files - */ async function loadContextFiles( projectDir: string, contextFiles: string[] @@ -517,7 +455,6 @@ async function loadContextFiles( loadedFiles.push(`## ${fileName}\n\n${truncatedContent}`); } catch { - // File doesn't exist or can't be read, skip it continue; } } @@ -529,9 +466,6 @@ async function loadContextFiles( return null; } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handleSessionStart).catch(error => { console.error('Failed to execute session start hook:', error); @@ -539,7 +473,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { handleSessionStart, getSessionStartConfig, diff --git a/src/lifecycle/setup.ts b/src/lifecycle/setup.ts new file mode 100644 index 0000000..f6fd431 --- /dev/null +++ b/src/lifecycle/setup.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env tsx + +/** + * Setup Hook Handler — Provides optional setup context before Claude Code starts work. + */ + +import { executeHook, logDebug, logInfo, outputJson } from '../utils/index.js'; +import type { SetupInput, SetupOutput } from '../types/index.js'; +import { validateSetupInput } from '../validation/index.js'; + +function getAdditionalContext(_input: SetupInput): string | undefined { + return undefined; +} + +async function handleSetup(input: SetupInput): Promise { + const validatedInput = validateSetupInput(input); + const additionalContext = getAdditionalContext(validatedInput); + + logInfo(`Setup hook triggered (${validatedInput.trigger})`); + logDebug('Setup details', { + trigger: validatedInput.trigger, + }); + + if (additionalContext === undefined) { + return; + } + + const output: SetupOutput = { + hookSpecificOutput: { + hookEventName: 'Setup', + additionalContext, + }, + }; + + outputJson(output); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + void executeHook(handleSetup); +} + +export { handleSetup, getAdditionalContext }; diff --git a/src/lifecycle/stop-failure.ts b/src/lifecycle/stop-failure.ts index 5e38619..e83a4ae 100644 --- a/src/lifecycle/stop-failure.ts +++ b/src/lifecycle/stop-failure.ts @@ -1,10 +1,8 @@ #!/usr/bin/env tsx /** - * StopFailure Hook Handler - * - * Runs when a turn ends because of an API error. Claude Code ignores output - * and exit code for this event; this handler is for observability only. + * StopFailure Hook Handler — Logs API-error turn endings for observability. + * Claude Code ignores output and exit code for this event. */ import { executeHook, logInfo, logDebug, outputJson } from '../utils/index.js'; diff --git a/src/lifecycle/stop-handler.ts b/src/lifecycle/stop-handler.ts index 436f4a9..c8c12f3 100644 --- a/src/lifecycle/stop-handler.ts +++ b/src/lifecycle/stop-handler.ts @@ -1,16 +1,7 @@ #!/usr/bin/env tsx /** - * Stop Hook Handler - * - * This hook runs when Claude Code finishes responding and can control - * whether Claude is allowed to stop or should continue with additional tasks. - * - * Use cases: - * - Verify all requested tasks were completed - * - Check for incomplete processes or pending work - * - Ensure proper cleanup was performed - * - Request additional follow-up actions + * Stop Hook Handler — Blocks session completion when configured checks find issues. */ import { @@ -101,7 +92,6 @@ async function handleStop(input: StopInput): Promise { } } - // Check for failed tests (if tests were run recently) if (config.checkFailedTests) { const failedTests = await checkFailedTests(); if (failedTests.length > 0) { @@ -143,7 +133,7 @@ async function handleStop(input: StopInput): Promise { } /** - * Check for incomplete tasks by looking for TODO comments, failing processes, etc. + * Check for incomplete tasks by scanning for TODO markers, failing processes, etc. */ async function checkIncompleteTasks(): Promise { const issues: string[] = []; @@ -214,18 +204,16 @@ async function checkUncommittedChanges(): Promise { } /** - * Check for recently failed tests + * Check for failed test indicators */ async function checkFailedTests(): Promise { const failedTests: string[] = []; try { - // This is a simple heuristic - in a real implementation, - // you might parse test output files or check recent test runs + // The .test-failures sentinel is the built-in failed-test source. const { access } = await import('node:fs/promises'); const { constants } = await import('node:fs'); - // Check if there's a recent test failure indicator file try { await access(`${getProjectDir()}/.test-failures`, constants.F_OK); failedTests.push('Recent test failures detected'); diff --git a/src/lifecycle/subagent-start.ts b/src/lifecycle/subagent-start.ts index 917611c..b4d8b4e 100644 --- a/src/lifecycle/subagent-start.ts +++ b/src/lifecycle/subagent-start.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * SubagentStart Hook Handler - * - * Runs when a subagent is created. This reference handler logs the subagent - * metadata without injecting additional context. + * SubagentStart Hook Handler — Logs subagent creation without injecting context. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/subagent-stop.ts b/src/lifecycle/subagent-stop.ts index e5e9fb1..3c444eb 100644 --- a/src/lifecycle/subagent-stop.ts +++ b/src/lifecycle/subagent-stop.ts @@ -1,17 +1,7 @@ #!/usr/bin/env tsx /** - * SubagentStop Hook Handler - * - * This hook runs when Claude Code subagent tasks complete and can control - * whether the subagent is allowed to stop or should continue with additional work. - * - * Use cases: - * - Validate that subagent tasks were completed successfully - * - Check for error conditions that require retry or escalation - * - Aggregate results from multiple subagent operations - * - Ensure proper handoff to main thread - * - Log subagent performance metrics + * SubagentStop Hook Handler — Validates subagent completion before allowing stop. */ import { diff --git a/src/lifecycle/task-completed.ts b/src/lifecycle/task-completed.ts index 4de7a4d..522466a 100644 --- a/src/lifecycle/task-completed.ts +++ b/src/lifecycle/task-completed.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * TaskCompleted Hook Handler - * - * Runs when an agent-team task completes. This reference handler logs the task - * without blocking completion. + * TaskCompleted Hook Handler — Logs agent-team task completion without blocking it. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/task-created.ts b/src/lifecycle/task-created.ts index f660f82..be6f337 100644 --- a/src/lifecycle/task-created.ts +++ b/src/lifecycle/task-created.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * TaskCreated Hook Handler - * - * Runs when an agent-team task is created. This reference handler logs the - * task without blocking creation. + * TaskCreated Hook Handler — Logs agent-team task creation without blocking it. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/teammate-idle.ts b/src/lifecycle/teammate-idle.ts index 01c2e46..f26b9e2 100644 --- a/src/lifecycle/teammate-idle.ts +++ b/src/lifecycle/teammate-idle.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * TeammateIdle Hook Handler - * - * Runs when an agent-team teammate is about to become idle. This reference - * handler logs the event without stopping the teammate. + * TeammateIdle Hook Handler — Logs idle teammate events without stopping the teammate. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/user-prompt-validator.ts b/src/lifecycle/user-prompt-validator.ts index 6a672ff..9b2e45a 100644 --- a/src/lifecycle/user-prompt-validator.ts +++ b/src/lifecycle/user-prompt-validator.ts @@ -1,14 +1,7 @@ #!/usr/bin/env tsx /** - * User Prompt Validator Hook - * - * This hook validates and enhances user prompts by: - * - Checking for security-sensitive content before processing - * - Adding helpful context based on prompt content - * - Validating prompt format and structure - * - Preventing common prompt injection attempts - * - Adding project-specific context when relevant + * UserPromptSubmit Hook Handler — Validates prompts and adds context before processing. */ import { @@ -131,7 +124,7 @@ async function validateUserPrompt(input: UserPromptSubmitInput): Promise { `⚠️ Prompt contains patterns similar to injection attempts: ${injectionCheck.issues.join(', ')}` ); - // For now, warn but don't block - could be made stricter based on needs + // Injection matches warn by default; CLAUDE_HOOK_BLOCK_INJECTION upgrades them to blocks. if (process.env['CLAUDE_HOOK_BLOCK_INJECTION'] === 'true') { validationResults.blocked = true; validationResults.blockReason = `Security: Potential prompt injection detected. Please rephrase your request.`; diff --git a/src/lifecycle/utils.ts b/src/lifecycle/utils.ts index c99c574..6d4dc20 100644 --- a/src/lifecycle/utils.ts +++ b/src/lifecycle/utils.ts @@ -1,8 +1,5 @@ /** - * Shared utilities for lifecycle hooks - * - * Contains functions that were previously duplicated across - * pre-compact.ts, session-end.ts, and subagent-stop.ts. + * Lifecycle Hook Utilities — Shares transcript and tool extraction helpers. */ import { execFile } from 'node:child_process'; diff --git a/src/lifecycle/worktree-create.ts b/src/lifecycle/worktree-create.ts index cca79bd..8a0f642 100644 --- a/src/lifecycle/worktree-create.ts +++ b/src/lifecycle/worktree-create.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * WorktreeCreate Hook Handler - * - * Runs when Claude Code requests a worktree. This reference handler logs the - * request without creating a custom worktree path. + * WorktreeCreate Hook Handler — Logs worktree creation requests without overriding paths. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/lifecycle/worktree-remove.ts b/src/lifecycle/worktree-remove.ts index 84989cc..0ab75aa 100644 --- a/src/lifecycle/worktree-remove.ts +++ b/src/lifecycle/worktree-remove.ts @@ -1,10 +1,7 @@ #!/usr/bin/env tsx /** - * WorktreeRemove Hook Handler - * - * Runs when Claude Code removes a worktree. This reference handler logs the - * removal request. + * WorktreeRemove Hook Handler — Logs worktree removal requests. */ import { executeHook, logInfo, logDebug } from '../utils/index.js'; diff --git a/src/post-tool-use/format-code.ts b/src/post-tool-use/format-code.ts index 6843b80..45bfc7f 100644 --- a/src/post-tool-use/format-code.ts +++ b/src/post-tool-use/format-code.ts @@ -3,11 +3,7 @@ /** * Code Formatting Hook * - * This PostToolUse hook automatically formats code files after they are modified: - * - Runs prettier on supported file types - * - Applies ESLint fixes for JavaScript/TypeScript files - * - Validates formatting and provides feedback to Claude - * - Supports project-specific formatting configuration + * Formats modified files with Prettier and ESLint when configured. */ import { execFile } from 'node:child_process'; @@ -35,7 +31,7 @@ import { const execFileAsync = promisify(execFile); /** - * Configuration for code formatting + * Code formatting behavior. */ interface FormatConfig { /** Whether to run prettier */ @@ -51,7 +47,7 @@ interface FormatConfig { } /** - * Get formatting configuration + * Read code formatting configuration. */ function getFormatConfig(): FormatConfig { const config = getConfig(); @@ -76,12 +72,11 @@ function getFormatConfig(): FormatConfig { } /** - * Main code formatting logic + * Format modified code files and report formatting failures. */ async function formatCode(input: PostToolUseInput): Promise { validatePostToolUseInput(input); - // Only process file modification tools const fileModificationTools = ['Write', 'Edit', 'MultiEdit']; if (!fileModificationTools.includes(input.tool_name)) { return; @@ -89,7 +84,6 @@ async function formatCode(input: PostToolUseInput): Promise { let filePath: string; - // Extract file path from tool input try { switch (input.tool_name) { case 'Write': { @@ -111,7 +105,6 @@ async function formatCode(input: PostToolUseInput): Promise { return; } - // Check if file should be formatted if (!shouldAutoFormat(filePath)) { logDebug( `Skipping formatting for ${filePath} - not in auto-format extensions` @@ -126,7 +119,6 @@ async function formatCode(input: PostToolUseInput): Promise { const results: string[] = []; const errors: string[] = []; - // Check if file exists and is accessible try { await access(filePath, constants.F_OK); } catch (error) { @@ -134,7 +126,6 @@ async function formatCode(input: PostToolUseInput): Promise { return; } - // Run Prettier formatting if (config.prettier) { try { const prettierResult = await runPrettier( @@ -158,7 +149,6 @@ async function formatCode(input: PostToolUseInput): Promise { } } - // Run ESLint fixes for JavaScript/TypeScript files if (config.eslint && isLintableFile(filePath)) { try { const eslintResult = await runESLintFix( @@ -185,13 +175,11 @@ async function formatCode(input: PostToolUseInput): Promise { } } - // Provide feedback to Claude if there are results or errors if (results.length > 0 || errors.length > 0) { const allMessages = [...results, ...errors]; const hasErrors = errors.length > 0; if (hasErrors && config.failOnError) { - // Block and provide feedback to Claude about formatting failures outputJson( HookOutputBuilder.feedback( `Automatic formatting encountered issues for ${filePath}:\n\n${allMessages.join('\n')}\n\nPlease review and fix the formatting issues.`, @@ -199,12 +187,10 @@ async function formatCode(input: PostToolUseInput): Promise { ) ); } else { - // Provide informational feedback const message = `Code formatting completed for ${filePath}:\n\n${allMessages.join('\n')}`; logInfo(message); if (hasErrors) { - // Non-blocking feedback about formatting issues outputJson({ systemMessage: message, suppressOutput: false, @@ -215,7 +201,7 @@ async function formatCode(input: PostToolUseInput): Promise { } /** - * Run Prettier formatting on a file + * Run Prettier formatting on a file. */ async function runPrettier( filePath: string, @@ -223,7 +209,6 @@ async function runPrettier( timeout: number ): Promise<{ success: boolean; error?: string; fixesApplied?: boolean }> { try { - // Try to run prettier from project first, then global const prettierCmd = await findCommand( ['npx prettier', 'prettier'], projectDir @@ -238,8 +223,6 @@ async function runPrettier( } ); - // Prettier doesn't always indicate if changes were made via stdout - // We consider it successful if it doesn't error return { success: true, fixesApplied: true, // Assume fixes were applied since prettier ran @@ -247,7 +230,6 @@ async function runPrettier( } catch (error: unknown) { const errorObj = getExecError(error); - // Handle timeout if (errorObj.code === 'ETIMEDOUT') { return { success: false, @@ -255,7 +237,6 @@ async function runPrettier( }; } - // Handle prettier not found if (errorObj.code === 'ENOENT' || errorObj.message?.includes('not found')) { return { success: false, @@ -263,7 +244,6 @@ async function runPrettier( }; } - // Handle syntax errors or other prettier issues const errorMessage = errorObj.stderr ?? errorObj.message ?? 'Unknown prettier error'; return { success: false, error: errorMessage }; @@ -271,7 +251,7 @@ async function runPrettier( } /** - * Run ESLint fixes on a file + * Run ESLint fixes on a file. */ async function runESLintFix( filePath: string, @@ -279,7 +259,6 @@ async function runESLintFix( timeout: number ): Promise<{ success: boolean; error?: string; fixesApplied?: boolean }> { try { - // Try to run eslint from project first, then global const eslintCmd = await findCommand(['npx eslint', 'eslint'], projectDir); const { stdout: _stdout, stderr: _stderr } = await execFileAsync( @@ -291,8 +270,6 @@ async function runESLintFix( } ); - // ESLint exit code 0 = no issues, 1 = issues found (but potentially fixed) - // We'll consider both successful for auto-fixing return { success: true, fixesApplied: _stderr.includes('fixed') || _stdout.includes('fixed'), @@ -300,7 +277,6 @@ async function runESLintFix( } catch (error: unknown) { const errorObj = getExecError(error); - // Handle timeout if (errorObj.code === 'ETIMEDOUT') { return { success: false, @@ -308,7 +284,6 @@ async function runESLintFix( }; } - // Handle eslint not found if (errorObj.code === 'ENOENT' || errorObj.message?.includes('not found')) { return { success: false, @@ -323,7 +298,6 @@ async function runESLintFix( return { success: false, error: errorMessage }; } - // For exit code 1, it means linting errors but fixes may have been applied return { success: true, fixesApplied: true, // Assume some fixes were applied @@ -356,7 +330,7 @@ function getExecError(error: unknown): { } /** - * Find available command from a list of alternatives + * Find the first available command from a list of alternatives. */ async function findCommand(commands: string[], cwd: string): Promise { for (const cmd of commands) { @@ -372,21 +346,17 @@ async function findCommand(commands: string[], cwd: string): Promise { } } - // If none found, return the first one (will error appropriately) return commands[0] ?? 'echo "No command available"'; } /** - * Check if a file should be processed by ESLint + * Check whether a file can be processed by ESLint. */ function isLintableFile(filePath: string): boolean { const lintableExtensions = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']; return lintableExtensions.some(ext => filePath.endsWith(ext)); } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(formatCode).catch(error => { console.error('Failed to execute code formatting hook:', error); @@ -394,5 +364,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { formatCode, getFormatConfig, runPrettier, runESLintFix }; diff --git a/src/post-tool-use/index.ts b/src/post-tool-use/index.ts index 39b0a43..06ab234 100644 --- a/src/post-tool-use/index.ts +++ b/src/post-tool-use/index.ts @@ -1,13 +1,9 @@ #!/usr/bin/env tsx /** - * Combined PostToolUse Hook Handler + * Combined PostToolUse Hook * - * This hook combines multiple PostToolUse processors: - * - Automatic code formatting (Prettier, ESLint) - * - TypeScript validation and compilation checks - * - Convex schema validation and codegen - * - File content validation and feedback + * Coordinates PostToolUse formatting, validation, and analysis handlers. */ import { executeHook, logInfo, logDebug, getConfig } from '../utils/index.js'; @@ -19,7 +15,7 @@ import { handlePostToolBatch } from './post-tool-batch.js'; import { handlePostToolUseFailure } from './post-tool-use-failure.js'; /** - * Main PostToolUse hook handler that coordinates all post-processing + * Coordinate post-processing for completed tool calls. */ async function handlePostToolUse(input: PostToolUseInput): Promise { validatePostToolUseInput(input); @@ -40,7 +36,6 @@ async function handlePostToolUse(input: PostToolUseInput): Promise { }); } - // Process different tools based on their type switch (tool_name) { case 'Write': case 'Edit': @@ -79,29 +74,22 @@ async function handlePostToolUse(input: PostToolUseInput): Promise { } /** - * Handle file modification operations (Write, Edit, MultiEdit) + * Run formatting and validation after file modifications. */ async function handleFileModification(input: PostToolUseInput): Promise { - // Run formatting and validation in sequence (formatting first, then validation) - // This ensures that validation runs on properly formatted code - try { - // Step 1: Format code logDebug('Running code formatting'); await formatCode(input); - // Step 2: Validate TypeScript (if applicable) logDebug('Running TypeScript validation'); await validateTypeScript(input); - // Step 3: Additional file-specific validations await runAdditionalFileValidations(input); } catch (error) { logInfo( `Post-processing error (non-blocking): ${error instanceof Error ? error.message : String(error)}` ); - // Don't block execution for post-processing errors unless specifically configured if (process.env['CLAUDE_HOOK_STRICT_POST_VALIDATION'] === 'true') { throw error; } @@ -109,13 +97,12 @@ async function handleFileModification(input: PostToolUseInput): Promise { } /** - * Handle bash command execution + * Record Bash execution results and analyze failures. */ async function handleBashExecution(input: PostToolUseInput): Promise { const toolInput = input.tool_input; const toolResponse = input.tool_response; - // Log command execution for audit trail — safe property access const command = typeof toolInput['command'] === 'string' ? toolInput['command'] : 'unknown'; const success = toolResponse['success'] !== false; // Default to true if not specified @@ -124,7 +111,6 @@ async function handleBashExecution(input: PostToolUseInput): Promise { `Bash command ${success ? 'succeeded' : 'failed'}: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}` ); - // Check for specific command patterns that might require follow-up if (command.includes('convex codegen')) { logInfo('Convex codegen detected - types may have been updated'); } else if (command.includes('npm install') || command.includes('yarn add')) { @@ -136,7 +122,6 @@ async function handleBashExecution(input: PostToolUseInput): Promise { logInfo('Git operation detected - codebase may have changed'); } - // If command failed, we might want to provide suggestions const stderrValue = toolResponse['stderr']; if (!success && typeof stderrValue === 'string') { const stderr = stderrValue; @@ -145,14 +130,13 @@ async function handleBashExecution(input: PostToolUseInput): Promise { } /** - * Handle subagent (Task) completion + * Record subagent task completion. */ async function handleSubagentCompletion( input: PostToolUseInput ): Promise { const taskResponse = input.tool_response; - // Log subagent completion logInfo('Subagent task completed'); if (getConfig().debug) { @@ -161,51 +145,33 @@ async function handleSubagentCompletion( success: taskResponse['success'] !== false, }); } - - // Could add logic here to: - // - Analyze subagent results - // - Chain additional subagents based on results - // - Validate subagent outputs } /** - * Handle web operations + * Record completed web operations. */ async function handleWebOperation(input: PostToolUseInput): Promise { logInfo(`Web operation completed: ${input.tool_name}`); - - // Could add logic here to: - // - Cache web responses - // - Validate web content - // - Extract and process data from web responses } /** - * Handle generic tools + * Record completed generic tools. */ async function handleGenericTool(input: PostToolUseInput): Promise { - // Basic logging and analysis for unknown tools logDebug(`Generic tool ${input.tool_name} completed`); - - // Could add generic patterns like: - // - Response size analysis - // - Performance timing - // - Error pattern detection } /** - * Run additional file-specific validations + * Log file-specific follow-up guidance. */ async function runAdditionalFileValidations( input: PostToolUseInput ): Promise { - // Extract file path via safe property access const rawFilePath = input.tool_input['file_path']; const filePath = typeof rawFilePath === 'string' ? rawFilePath : undefined; if (!filePath) return; - // File-specific validations based on file type/path if (filePath.includes('package.json')) { logInfo('package.json modified - consider running npm install'); } else if (filePath.includes('convex/schema.ts')) { @@ -218,13 +184,12 @@ async function runAdditionalFileValidations( } /** - * Analyze bash command failures and provide suggestions + * Analyze bash command failures and log guidance. */ async function analyzeCommandFailure( _command: string, stderr: string ): Promise { - // Common error patterns and suggestions const errorPatterns = [ { pattern: /command not found/i, @@ -266,9 +231,6 @@ async function analyzeCommandFailure( } } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handlePostToolUse).catch(error => { console.error('Failed to execute PostToolUse hook:', error); @@ -276,7 +238,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for testing and composition export { handlePostToolUse, handleFileModification, diff --git a/src/post-tool-use/post-tool-batch.ts b/src/post-tool-use/post-tool-batch.ts index a33073b..f79b48c 100644 --- a/src/post-tool-use/post-tool-batch.ts +++ b/src/post-tool-use/post-tool-batch.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** - * PostToolBatch Hook Handler + * PostToolBatch Hook * * Runs after a batch of parallel tool calls resolves. This reference handler * logs the batch without blocking the next model call. diff --git a/src/post-tool-use/post-tool-use-failure.ts b/src/post-tool-use/post-tool-use-failure.ts index 075117a..67ca778 100644 --- a/src/post-tool-use/post-tool-use-failure.ts +++ b/src/post-tool-use/post-tool-use-failure.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** - * PostToolUseFailure Hook Handler + * PostToolUseFailure Hook * * Runs after a tool call fails. This reference handler records the failure * context without routing it through successful PostToolUse processors. diff --git a/src/post-tool-use/validate-typescript.ts b/src/post-tool-use/validate-typescript.ts index d1e7597..ce95ab8 100644 --- a/src/post-tool-use/validate-typescript.ts +++ b/src/post-tool-use/validate-typescript.ts @@ -3,11 +3,7 @@ /** * TypeScript Validation Hook * - * This PostToolUse hook validates TypeScript files after modification: - * - Runs TypeScript compiler checks - * - Validates Convex schema files specifically - * - Provides detailed error feedback to Claude - * - Integrates with project's TypeScript configuration + * Validates modified TypeScript and Convex files after tool use. */ import { execFile } from 'node:child_process'; @@ -34,7 +30,7 @@ import { const execFileAsync = promisify(execFile); /** - * Configuration for TypeScript validation + * TypeScript validation behavior. */ interface TypeScriptConfig { /** Whether to run full project typecheck */ @@ -50,7 +46,7 @@ interface TypeScriptConfig { } /** - * Get TypeScript validation configuration + * Read TypeScript validation configuration. */ function getTypeScriptConfig(): TypeScriptConfig { return { @@ -67,12 +63,11 @@ function getTypeScriptConfig(): TypeScriptConfig { } /** - * Main TypeScript validation logic + * Validate modified TypeScript files and report compiler errors. */ async function validateTypeScript(input: PostToolUseInput): Promise { validatePostToolUseInput(input); - // Only process file modification tools const fileModificationTools = ['Write', 'Edit', 'MultiEdit']; if (!fileModificationTools.includes(input.tool_name)) { return; @@ -80,7 +75,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { let filePath: string; - // Extract file path from tool input try { switch (input.tool_name) { case 'Write': { @@ -102,7 +96,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { return; } - // Only process TypeScript files if (!isTypeScriptFile(filePath)) { return; } @@ -114,7 +107,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { const results: string[] = []; const errors: string[] = []; - // Check if file exists and is accessible try { await access(filePath, constants.F_OK); } catch (error) { @@ -122,7 +114,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { return; } - // Run TypeScript validation try { const tsResult = await runTypeScriptCheck(filePath, projectDir, config); @@ -143,7 +134,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { logError(`TypeScript execution error for ${filePath}`, toError(error)); } - // Run Convex-specific validation for schema files if (config.convexValidation && isConvexFile(filePath)) { try { const convexResult = await runConvexValidation( @@ -168,12 +158,10 @@ async function validateTypeScript(input: PostToolUseInput): Promise { } } - // Provide feedback based on results if (errors.length > 0) { const errorMessage = `TypeScript validation failed for ${filePath}:\n\n${errors.join('\n\n')}`; if (config.blockOnErrors || isStrictFile(filePath, config.strictFiles)) { - // Block and provide feedback to Claude outputJson( HookOutputBuilder.feedback( errorMessage + @@ -182,7 +170,6 @@ async function validateTypeScript(input: PostToolUseInput): Promise { ) ); } else { - // Non-blocking feedback outputJson({ systemMessage: `TypeScript validation warnings for ${filePath}:\n\n${errors.join('\n\n')}`, suppressOutput: false, @@ -190,11 +177,9 @@ async function validateTypeScript(input: PostToolUseInput): Promise { logError('TypeScript validation failed but not blocking execution'); } } else if (results.length > 0) { - // Success message const successMessage = `TypeScript validation completed for ${filePath}:\n\n${results.join('\n')}`; logInfo(successMessage); - // Only show success message in debug mode to avoid noise if (getConfig().debug) { outputJson({ systemMessage: successMessage, @@ -205,7 +190,7 @@ async function validateTypeScript(input: PostToolUseInput): Promise { } /** - * Run TypeScript compiler check on a file + * Run the TypeScript compiler for a file. */ async function runTypeScriptCheck( filePath: string, @@ -213,10 +198,8 @@ async function runTypeScriptCheck( config: TypeScriptConfig ): Promise<{ success: boolean; error?: string; errors?: string[] }> { try { - // Determine the appropriate TypeScript command and config const { command, configFile } = getTypeScriptCommand(filePath, projectDir); - // Build the TypeScript command const tsCommand = config.fullProjectCheck ? `${command} --noEmit${configFile ? ` -p ${configFile}` : ''}` : `${command} --noEmit${configFile ? ` -p ${configFile}` : ''} "${filePath}"`; @@ -232,12 +215,10 @@ async function runTypeScriptCheck( } ); - // TypeScript exit code 0 = success, anything else = errors return { success: true }; } catch (error: unknown) { const errorObj = getExecError(error); - // Handle timeout if (errorObj.code === 'ETIMEDOUT') { return { success: false, @@ -245,7 +226,6 @@ async function runTypeScriptCheck( }; } - // Handle TypeScript not found if (errorObj.code === 'ENOENT' || errorObj.message?.includes('not found')) { return { success: false, @@ -253,7 +233,6 @@ async function runTypeScriptCheck( }; } - // Parse TypeScript errors from stderr const errorOutput = errorObj.stderr ?? errorObj.stdout ?? ''; const errors = parseTypeScriptErrors(errorOutput); @@ -289,7 +268,7 @@ function getExecError(error: unknown): { } /** - * Run Convex-specific validation + * Run Convex-specific validation. */ async function runConvexValidation( filePath: string, @@ -297,7 +276,6 @@ async function runConvexValidation( config: TypeScriptConfig ): Promise<{ success: boolean; error?: string }> { try { - // For Convex files, we might want to run codegen to ensure types are up to date if (filePath.includes('convex/schema.ts')) { logDebug('Schema file modified - running Convex codegen'); @@ -313,7 +291,6 @@ async function runConvexValidation( logInfo('Convex codegen completed successfully'); } - // Run TypeScript check on Convex directory specifically const convexTsCommand = `npx tsc --noEmit -p convex/tsconfig.json`; const { stdout: _stdout, stderr: _stderr } = await execFileAsync( @@ -329,7 +306,6 @@ async function runConvexValidation( } catch (error: unknown) { const errorObj = getExecError(error); - // Handle timeout if (errorObj.code === 'ETIMEDOUT') { return { success: false, @@ -337,7 +313,6 @@ async function runConvexValidation( }; } - // Handle Convex not found if (errorObj.code === 'ENOENT' || errorObj.message?.includes('not found')) { return { success: false, @@ -345,7 +320,6 @@ async function runConvexValidation( }; } - // Parse error output const errorOutput = errorObj.stderr ?? errorObj.stdout ?? ''; return { success: false, @@ -355,13 +329,12 @@ async function runConvexValidation( } /** - * Get appropriate TypeScript command and config for a file + * Get the TypeScript command and config for a file. */ function getTypeScriptCommand( filePath: string, _projectDir: string ): { command: string; configFile?: string } { - // Check if file is in Convex directory if (filePath.includes('/convex/')) { return { command: 'npx tsc', @@ -369,7 +342,6 @@ function getTypeScriptCommand( }; } - // Default to main TypeScript config return { command: 'npx tsc', configFile: 'tsconfig.json', @@ -377,35 +349,30 @@ function getTypeScriptCommand( } /** - * Parse TypeScript error messages into structured format + * Parse TypeScript error output into grouped messages. */ function parseTypeScriptErrors(errorOutput: string): string[] { if (!errorOutput) return []; - // Split by lines and filter out empty lines const lines = errorOutput.split('\n').filter(line => line.trim()); - // Group lines into error blocks (errors typically span multiple lines) const errors: string[] = []; let currentError: string[] = []; for (const line of lines) { - // New error typically starts with a file path + // A fresh error block typically starts with a file path. if (line.match(/^.*\(\d+,\d+\):/)) { if (currentError.length > 0) { errors.push(currentError.join('\n')); } currentError = [line]; } else if (line.trim() && currentError.length > 0) { - // Continuation of current error currentError.push(line); } else if (line.trim() && currentError.length === 0) { - // Standalone error line errors.push(line); } } - // Add the last error if any if (currentError.length > 0) { errors.push(currentError.join('\n')); } @@ -414,7 +381,7 @@ function parseTypeScriptErrors(errorOutput: string): string[] { } /** - * Check if a file is a TypeScript file + * Check whether a file is TypeScript. */ function isTypeScriptFile(filePath: string): boolean { const tsExtensions = ['.ts', '.tsx', '.d.ts']; @@ -422,7 +389,7 @@ function isTypeScriptFile(filePath: string): boolean { } /** - * Check if a file is a Convex-related file + * Check whether a file belongs to Convex. */ function isConvexFile(filePath: string): boolean { return ( @@ -433,15 +400,12 @@ function isConvexFile(filePath: string): boolean { } /** - * Check if a file requires strict validation + * Check whether a file requires strict validation. */ function isStrictFile(filePath: string, strictPatterns: string[]): boolean { return strictPatterns.some(pattern => filePath.includes(pattern)); } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(validateTypeScript).catch(error => { console.error('Failed to execute TypeScript validation hook:', error); @@ -449,7 +413,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { validateTypeScript, getTypeScriptConfig, diff --git a/src/pre-tool-use/bash-validator.ts b/src/pre-tool-use/bash-validator.ts index 6694f80..0a163de 100644 --- a/src/pre-tool-use/bash-validator.ts +++ b/src/pre-tool-use/bash-validator.ts @@ -3,11 +3,7 @@ /** * Bash Command Validator Hook * - * This PreToolUse hook validates bash commands before execution, providing: - * - Security warnings for dangerous commands - * - Performance suggestions (e.g., use ripgrep instead of grep) - * - Best practice recommendations - * - Automatic approval for safe commands + * Validates Bash commands before execution and auto-approves known safe reads. */ import { executeHook, logInfo, outputJson } from '../utils/index.js'; @@ -19,19 +15,15 @@ import { } from '../validation/index.js'; /** - * Main bash validation logic handler + * Validate Bash commands and emit permission decisions for issues. */ async function handleBashValidation(input: PreToolUseInput): Promise { - // Ensure this is a PreToolUse hook for Bash validatePreToolUseInput(input); - // Skip if not a Bash tool if (input.tool_name !== 'Bash') { - // Allow all non-Bash tools to proceed return; } - // Extract and validate bash command const bashInput = validateBashToolInput(input); const command = bashInput.command.trim(); @@ -39,12 +31,9 @@ async function handleBashValidation(input: PreToolUseInput): Promise { `Validating bash command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}` ); - // Run validation against rules const validation = validateBashCommand(command); - // Handle validation results if (validation.issues.length === 0) { - // Command is clean - auto-approve safe commands if (isSafeCommand(command)) { outputJson( HookOutputBuilder.permission( @@ -53,18 +42,15 @@ async function handleBashValidation(input: PreToolUseInput): Promise { ) ); } - // For other commands, let normal permission flow handle it return; } - // Process validation issues const errors = validation.issues.filter(issue => issue.severity === 'error'); const warnings = validation.issues.filter( issue => issue.severity === 'warning' ); const info = validation.issues.filter(issue => issue.severity === 'info'); - // Block commands with errors if (errors.length > 0) { const errorMessages = errors .map( @@ -82,7 +68,6 @@ async function handleBashValidation(input: PreToolUseInput): Promise { return; } - // For warnings, ask user to confirm if (warnings.length > 0) { const warningMessages = warnings .map( @@ -100,7 +85,6 @@ async function handleBashValidation(input: PreToolUseInput): Promise { return; } - // For info-only issues, auto-approve with information if (info.length > 0) { const infoMessages = info .map( @@ -120,10 +104,9 @@ async function handleBashValidation(input: PreToolUseInput): Promise { } /** - * Check if a command is considered safe for auto-approval + * Check whether a command is safe for auto-approval. */ function isSafeCommand(command: string): boolean { - // List of commands that are generally safe to run automatically const safeCommands = [ // File viewing 'ls', @@ -169,12 +152,10 @@ function isSafeCommand(command: string): boolean { 'npm audit', ]; - // Check if command starts with any safe command if (safeCommands.some(safe => command.startsWith(safe))) { return true; } - // Check for safe command patterns const safePatterns = [ /^echo\s+/, // Echo commands /^which\s+/, // Which commands @@ -187,9 +168,6 @@ function isSafeCommand(command: string): boolean { return safePatterns.some(pattern => pattern.test(command)); } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handleBashValidation).catch(error => { console.error('Failed to execute bash validator hook:', error); @@ -197,5 +175,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { handleBashValidation }; diff --git a/src/pre-tool-use/eslint-disable-blocker.ts b/src/pre-tool-use/eslint-disable-blocker.ts index 44f5345..4cf1068 100644 --- a/src/pre-tool-use/eslint-disable-blocker.ts +++ b/src/pre-tool-use/eslint-disable-blocker.ts @@ -3,14 +3,11 @@ /** * ESLint Disable Blocker Hook * - * This PreToolUse hook prevents the use of eslint-disable directives by: - * - Blocking Write/Edit/MultiEdit operations containing eslint-disable patterns - * - Providing educational feedback about architectural directives - * - Referencing project documentation for proper alternatives + * Blocks eslint-disable directives in Write, Edit, and MultiEdit payloads. * * WHY: eslint-disable creates redundant suppression violations and generates * more lint errors. Architectural directives are BOTH documentation AND - * suppression mechanism - they automatically suppress ESLint errors. + * suppression mechanism. */ import { executeHook, logInfo, outputJson } from '../utils/index.js'; @@ -18,7 +15,7 @@ import { HookOutputBuilder, type PreToolUseInput } from '../types/index.js'; import { validatePreToolUseInput } from '../validation/index.js'; /** - * Extract content to check based on tool type + * Extract editable content from file modification tool input. */ function extractContentToCheck(input: PreToolUseInput): string | null { const toolInput = input.tool_input; @@ -59,17 +56,17 @@ function extractContentToCheck(input: PreToolUseInput): string | null { } /** - * Check if content contains eslint-disable patterns + * Check whether content contains eslint-disable directives. */ function containsEslintDisable(content: string): boolean { - // Create a new regex instance for each test to avoid state issues + // Recreate the stateful global regex for each test. const pattern = /\/\/\s*eslint-disable(?:-next-line|-line)?|\/\*\s*eslint-disable(?:-next-line|-line)?/gi; return pattern.test(content); } /** - * Generate educational feedback message + * Generate feedback for blocked eslint-disable directives. */ function generateFeedbackMessage(): string { return `❌ ESLint disable directives are FORBIDDEN @@ -78,11 +75,11 @@ Usage of eslint-disable creates redundant suppression violations and generates m 🔧 USE ARCHITECTURAL DIRECTIVES INSTEAD: -Architectural directives automatically suppress ESLint errors without eslint-disable. +Architectural directives suppress ESLint errors without eslint-disable. They serve as BOTH documentation AND suppression mechanism. The prevent-unsafe-patterns ESLint rule scans 500 preceding characters and -automatically suppresses errors when it finds @architectural-directive: pattern. +suppresses errors when it finds an @architectural-directive: pattern. Example: // @architectural-directive: sequential-for-stability @@ -101,22 +98,19 @@ for (const id of ids) { } /** - * Main handler for ESLint disable blocker + * Block file edits that introduce eslint-disable directives. */ async function handleEslintDisableBlocker( input: PreToolUseInput ): Promise { - // Validate input validatePreToolUseInput(input); - // Only check Write, Edit, and MultiEdit tools if (!['Write', 'Edit', 'MultiEdit'].includes(input.tool_name)) { return; // Allow other tools to proceed } logInfo(`Checking ${input.tool_name} operation for eslint-disable patterns`); - // Extract content to check const content = extractContentToCheck(input); if (!content) { @@ -124,22 +118,16 @@ async function handleEslintDisableBlocker( return; } - // Check for eslint-disable patterns if (containsEslintDisable(content)) { logInfo('ESLint disable pattern detected - blocking operation'); - // Block the operation with educational feedback outputJson(HookOutputBuilder.permission('deny', generateFeedbackMessage())); return; } logInfo('No eslint-disable patterns found - allowing operation'); - // Allow the operation to proceed (no output needed) } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handleEslintDisableBlocker).catch(error => { console.error('Failed to execute eslint-disable-blocker hook:', error); @@ -147,7 +135,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for testing export { handleEslintDisableBlocker, containsEslintDisable, diff --git a/src/pre-tool-use/file-protector.ts b/src/pre-tool-use/file-protector.ts index b79061c..e0e11b1 100644 --- a/src/pre-tool-use/file-protector.ts +++ b/src/pre-tool-use/file-protector.ts @@ -3,11 +3,7 @@ /** * File Protection Hook * - * This PreToolUse hook protects sensitive files from being modified by: - * - Blocking writes to configuration files (.env, package.json, etc.) - * - Preventing edits to version control files (.git/*) - * - Protecting system files and directories - * - Allowing read-only operations on protected files + * Guards sensitive files before read and write tool calls run. */ import { @@ -27,7 +23,7 @@ import { } from '../validation/index.js'; /** - * Configuration for file protection behavior + * File protection behavior. */ interface FileProtectionConfig { /** Whether to block all operations on protected files */ @@ -41,7 +37,7 @@ interface FileProtectionConfig { } /** - * Get file protection configuration from environment + * Read file protection configuration from environment variables. */ function getProtectionConfig(): FileProtectionConfig { return { @@ -59,7 +55,7 @@ function getProtectionConfig(): FileProtectionConfig { } /** - * Main file protection logic + * Enforce file protection rules for file operation tools. */ async function protectFiles(input: PreToolUseInput): Promise { validatePreToolUseInput(input); @@ -67,7 +63,6 @@ async function protectFiles(input: PreToolUseInput): Promise { const config = getProtectionConfig(); const { tool_name } = input; - // Only process file operation tools const fileOperationTools = ['Write', 'Edit', 'MultiEdit', 'Read']; if (!fileOperationTools.includes(tool_name)) { return; // Allow non-file operations @@ -76,7 +71,6 @@ async function protectFiles(input: PreToolUseInput): Promise { let filePath: string; let operation: 'read' | 'write' | 'edit'; - // Extract file path and operation type from different tools try { switch (tool_name) { case 'Write': { @@ -99,11 +93,9 @@ async function protectFiles(input: PreToolUseInput): Promise { break; } default: - // Unknown tool, let it proceed return; } } catch (error) { - // If we can't extract file path, let the tool handle validation logWarning(`Could not extract file path from ${tool_name} tool: ${error}`); return; } @@ -117,7 +109,6 @@ async function protectFiles(input: PreToolUseInput): Promise { `Checking file protection for ${operation} operation on: ${filePath}` ); - // Validate file path safety const pathValidation = validateSafeFilePath(filePath); if (!pathValidation.isSafe) { outputJson( @@ -129,12 +120,10 @@ async function protectFiles(input: PreToolUseInput): Promise { return; } - // Check if file is protected const isProtected = isProtectedFile(filePath) || config.extraProtectedPatterns.some(pattern => filePath.includes(pattern)); - // Handle read operations if (operation === 'read') { if (config.autoApproveReads && !isProtected) { outputJson( @@ -156,11 +145,9 @@ async function protectFiles(input: PreToolUseInput): Promise { return; } - // Allow read operations by default return; } - // Handle write/edit operations on protected files if (isProtected) { const protectionMessage = getProtectionMessage(filePath, operation); @@ -183,7 +170,6 @@ async function protectFiles(input: PreToolUseInput): Promise { } } - // Check read-only files const isReadOnly = config.readOnlyFiles.some( pattern => filePath.includes(pattern) || filePath.endsWith(pattern) ); @@ -198,12 +184,11 @@ async function protectFiles(input: PreToolUseInput): Promise { return; } - // File is not protected, allow operation logInfo(`File operation allowed: ${operation} on ${filePath}`); } /** - * Get appropriate protection message based on file type + * Build a protection message for the file type and operation. */ function getProtectionMessage(filePath: string, operation: string): string { if (filePath.includes('.env')) { @@ -249,7 +234,7 @@ function getProtectionMessage(filePath: string, operation: string): string { } /** - * Check if a file extension indicates a configuration file + * Check whether a path points to a configuration file. */ function isConfigurationFile(filePath: string): boolean { const configExtensions = [ @@ -285,9 +270,6 @@ function isConfigurationFile(filePath: string): boolean { ); } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(protectFiles).catch(error => { console.error('Failed to execute file protection hook:', error); @@ -295,5 +277,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for use in other hooks export { protectFiles, getProtectionConfig, isConfigurationFile }; diff --git a/src/pre-tool-use/index.ts b/src/pre-tool-use/index.ts index 8677134..371ba5d 100644 --- a/src/pre-tool-use/index.ts +++ b/src/pre-tool-use/index.ts @@ -1,13 +1,9 @@ #!/usr/bin/env tsx /** - * Combined PreToolUse Hook Handler + * Combined PreToolUse Hook * - * This hook combines multiple PreToolUse validators and protections: - * - Bash command validation and security checking - * - File protection for sensitive files and directories - * - Path traversal and security validation - * - Automatic approval for safe operations + * Coordinates PreToolUse validators and protection handlers. */ import { executeHook, logInfo, logDebug, getConfig } from '../utils/index.js'; @@ -18,7 +14,7 @@ import { protectFiles } from './file-protector.js'; import { handleUserPromptExpansion } from './user-prompt-expansion.js'; /** - * Main PreToolUse hook handler that coordinates all validations + * Coordinate PreToolUse validators for tool-specific policies. */ async function handlePreToolUse(input: PreToolUseInput): Promise { validatePreToolUseInput(input); @@ -38,7 +34,6 @@ async function handlePreToolUse(input: PreToolUseInput): Promise { }); } - // Run different validators based on tool type switch (tool_name) { case 'Bash': logDebug('Running bash command validation'); @@ -54,40 +49,34 @@ async function handlePreToolUse(input: PreToolUseInput): Promise { break; case 'Task': - // For subagent tasks, we might want special handling logDebug('Task tool detected - applying subagent policies'); await handleSubagentTask(input); break; case 'WebFetch': case 'WebSearch': - // Web operations might need URL validation logDebug('Web operation detected - applying web policies'); await handleWebOperation(input); break; default: - // For unknown tools, apply general safety checks logDebug(`Unknown tool ${tool_name} - applying general safety checks`); await handleGenericTool(input); break; } - // If we reach here without outputting a decision, let the normal flow continue logDebug('PreToolUse hook completed - allowing normal permission flow'); } /** - * Handle Task tool (subagent) operations + * Inspect Task tool prompts for high-risk phrasing. */ async function handleSubagentTask(input: PreToolUseInput): Promise { const taskInput = input.tool_input; - // Check if the task description contains concerning patterns let description = ''; let prompt = ''; - // Safely extract description and prompt if they exist if (typeof taskInput === 'object' && taskInput !== null) { const obj = taskInput as Record; if (typeof obj['description'] === 'string') { @@ -118,7 +107,7 @@ async function handleSubagentTask(input: PreToolUseInput): Promise { } /** - * Handle web operations (WebFetch, WebSearch) + * Inspect WebFetch and WebSearch URLs for internal destinations. */ async function handleWebOperation(input: PreToolUseInput): Promise { const webInput = input.tool_input; @@ -129,7 +118,6 @@ async function handleWebOperation(input: PreToolUseInput): Promise { try { const parsedUrl = new URL(url); - // Check for potentially dangerous URLs const dangerousDomains = [ 'localhost', '127.0.0.1', @@ -154,13 +142,11 @@ async function handleWebOperation(input: PreToolUseInput): Promise { } /** - * Handle generic tools with basic safety checks + * Inspect generic tool string inputs for risky paths or shell fragments. */ async function handleGenericTool(input: PreToolUseInput): Promise { - // For tools we don't specifically handle, apply general safety patterns const toolInput = input.tool_input; - // Check for file paths in any tool input const possiblePaths = Object.values(toolInput) .filter((value): value is string => typeof value === 'string') .filter(value => value.includes('/') || value.includes('\\')); @@ -174,7 +160,6 @@ async function handleGenericTool(input: PreToolUseInput): Promise { } } - // Check for potentially dangerous content in any string fields const dangerousPatterns = [ /rm\s+-rf/, /sudo.*rm/, @@ -193,9 +178,6 @@ async function handleGenericTool(input: PreToolUseInput): Promise { } } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handlePreToolUse).catch(error => { console.error('Failed to execute PreToolUse hook:', error); @@ -203,7 +185,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for testing and composition export { handlePreToolUse, handleSubagentTask, diff --git a/src/pre-tool-use/long-running-commands.ts b/src/pre-tool-use/long-running-commands.ts index f9b7f03..fcd5a8c 100644 --- a/src/pre-tool-use/long-running-commands.ts +++ b/src/pre-tool-use/long-running-commands.ts @@ -3,21 +3,7 @@ /** * Long-Running Commands Hook * - * This PreToolUse hook intercepts critical validation commands that require - * extended timeouts beyond Claude Code's default 30-second limit. - * - * Intercepted Commands: - * - npm run check:fast (~75s) - * - npm run check (~90s) - * - npm run fix:file -- (~30-60s) - * - npm run typecheck:all (~60s) - * - npm run lint:focus (~30s) - * - * Strategy: - * - Auto-approve matching commands (no permission blocking) - * - Execute with extended timeouts (120-180s) - * - Stream progress feedback to Claude - * - Handle validation errors gracefully + * Extends timeouts for known validation commands before Bash execution. */ import { execFile } from 'node:child_process'; @@ -38,7 +24,7 @@ import { validateBashToolInput } from '../validation/index.js'; const execFileAsync = promisify(execFile); /** - * Configuration for command timeouts (in seconds) + * Command timeouts in seconds. */ const COMMAND_TIMEOUTS = { 'check:fast': 180, // TypeScript-only validation @@ -55,7 +41,7 @@ const COMMAND_TIMEOUTS = { } as const; /** - * Long-running validation command patterns + * Validation commands that need longer than Claude Code's default timeout. */ const LONG_RUNNING_PATTERNS = [ /^npm run check:fast$/, @@ -74,30 +60,27 @@ const LONG_RUNNING_PATTERNS = [ ]; /** - * Check if a command is a long-running validation command + * Check whether a command uses long-running validation behavior. */ function isLongRunningCommand(command: string): boolean { return LONG_RUNNING_PATTERNS.some(pattern => pattern.test(command.trim())); } /** - * Get timeout for a specific command + * Get the timeout for a validation command. */ function getCommandTimeout(command: string): number { - // Extract command name from npm run scripts const match = command.match(/npm run ([\w:]+)/); if (!match?.[1]) return 120; // Default 2 minutes const scriptName = match[1]; - // Check exact matches first for (const [key, timeout] of Object.entries(COMMAND_TIMEOUTS)) { if (scriptName === key || scriptName.startsWith(`${key}:`)) { return timeout; } } - // Default for validation-related commands if (scriptName.includes('validate') || scriptName.includes('check')) { return 180; } @@ -106,10 +89,9 @@ function getCommandTimeout(command: string): number { } /** - * Get friendly name for progress messages + * Get a readable command name for progress messages. */ function getCommandDisplayName(command: string): string { - // Handle npx commands if (command.startsWith('npx convex codegen')) { return 'Convex type generation'; } @@ -129,7 +111,6 @@ function getCommandDisplayName(command: string): string { sync: 'schema sync and validation', }; - // Handle lint:session:* dynamically if (scriptName.startsWith('lint:session:')) { const group = scriptName.replace('lint:session:', ''); return `lint session analysis (${group})`; @@ -139,7 +120,7 @@ function getCommandDisplayName(command: string): string { } /** - * Execute a long-running command with extended timeout + * Execute a long-running command with an extended timeout. */ async function executeLongRunningCommand( command: string, @@ -166,7 +147,6 @@ async function executeLongRunningCommand( } catch (error: unknown) { const execError = getExecError(error); - // Handle timeout if (execError.code === 'ETIMEDOUT' || execError.killed) { logError( `Command timed out after ${timeout}s: ${command}`, @@ -179,7 +159,6 @@ async function executeLongRunningCommand( }; } - // Handle validation failures (exit code != 0) logDebug(`Command failed with error: ${execError.message ?? 'Unknown'}`); return { success: false, @@ -219,19 +198,16 @@ function getExecError(error: unknown): { } /** - * Main hook logic + * Handle long-running validation Bash commands. */ async function handleLongRunningCommand(input: PreToolUseInput): Promise { - // Only process Bash tool calls if (input.tool_name !== 'Bash') { return; } - // Validate and extract command const bashInput = validateBashToolInput(input); const command = bashInput.command.trim(); - // Check if this is a long-running validation command if (!isLongRunningCommand(command)) { logDebug(`Command not matched for long-running handling: ${command}`); return; @@ -245,7 +221,6 @@ async function handleLongRunningCommand(input: PreToolUseInput): Promise { `Intercepting long-running command: ${command} (timeout: ${timeout}s)` ); - // Auto-approve and provide progress feedback outputJson( HookOutputBuilder.permission( 'allow', @@ -253,11 +228,9 @@ async function handleLongRunningCommand(input: PreToolUseInput): Promise { ) ); - // Execute the command with extended timeout const result = await executeLongRunningCommand(command, projectDir, timeout); if (result.success) { - // Success - provide feedback if there's meaningful output if (result.stdout.trim()) { const successMsg = `✅ ${displayName} completed successfully\n\n${result.stdout.substring(0, 500)}${result.stdout.length > 500 ? '\n... (output truncated)' : ''}`; logInfo(successMsg); @@ -265,11 +238,9 @@ async function handleLongRunningCommand(input: PreToolUseInput): Promise { logInfo(`✅ ${displayName} completed successfully (no output)`); } } else { - // Failure - provide error details to Claude const errorMsg = `❌ ${displayName} failed:\n\n${result.stderr}`; logError(`Command failed: ${command}`, new Error(result.stderr)); - // Don't block execution, but provide feedback outputJson({ systemMessage: errorMsg, suppressOutput: false, @@ -277,9 +248,6 @@ async function handleLongRunningCommand(input: PreToolUseInput): Promise { } } -/** - * Main execution entry point - */ if (import.meta.url === `file://${process.argv[1]}`) { executeHook(handleLongRunningCommand).catch(error => { console.error('Failed to execute long-running command hook:', error); @@ -287,7 +255,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { }); } -// Export for testing export { handleLongRunningCommand, isLongRunningCommand, diff --git a/src/pre-tool-use/user-prompt-expansion.ts b/src/pre-tool-use/user-prompt-expansion.ts index ef87ac0..8762944 100644 --- a/src/pre-tool-use/user-prompt-expansion.ts +++ b/src/pre-tool-use/user-prompt-expansion.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** - * UserPromptExpansion Hook Handler + * UserPromptExpansion Hook * * Runs before a slash command or MCP prompt expands. This reference handler * logs the expansion source without changing behavior. diff --git a/src/processing/block-decomposition.ts b/src/processing/block-decomposition.ts index 1e34bfe..07af61e 100644 --- a/src/processing/block-decomposition.ts +++ b/src/processing/block-decomposition.ts @@ -1,4 +1,9 @@ +/** + * Raw history-line decomposition into structured session blocks. + */ + import { summarizeToolCall, extractToolResultText } from './denoiser.js'; +import { imagePlaceholder } from './image-placeholder.js'; import type { ContentBlock, RawHistoryLine, SessionBlock } from './types.js'; import { MAX_TOOL_RESULT_LINES, @@ -101,6 +106,9 @@ function blockForContentBlock( if (!block.thinking.trim()) return undefined; return { ...meta, id, type: 'thinking', content: block.thinking }; + case 'image': + return makeTextBlock(id, meta, role, imagePlaceholder(block.source)); + case 'tool_use': return { ...meta, diff --git a/src/processing/blocks.ts b/src/processing/blocks.ts index 922c84c..0a0ae6a 100644 --- a/src/processing/blocks.ts +++ b/src/processing/blocks.ts @@ -25,9 +25,7 @@ import { buildToolNameMap } from './denoiser.js'; import { decomposeHistoryLine } from './block-decomposition.js'; import { compareStrings } from './ordering.js'; -// --------------------------------------------------------------------------- // Public API -// --------------------------------------------------------------------------- /** * Extract all `SessionBlock`s from a raw session. @@ -111,9 +109,7 @@ export function toJsonlBlocks(blocks: readonly SessionBlock[]): string { return blocks.map(b => JSON.stringify(b)).join('\n') + '\n'; } -// --------------------------------------------------------------------------- // Helpers -// --------------------------------------------------------------------------- function buildBoundary( sessionId: string, diff --git a/src/processing/denoiser.ts b/src/processing/denoiser.ts index 2665a41..cea141d 100644 --- a/src/processing/denoiser.ts +++ b/src/processing/denoiser.ts @@ -26,6 +26,7 @@ import { type TextBlock, type ToolUseBlock, type ThinkingBlock, + type ImageContentBlock, type CleanMessage, type ToolResultEntry, type ParsedSession, @@ -41,16 +42,12 @@ import { redactRetainedToolResultText, } from './tool-result-redaction.js'; import { compareStrings } from './ordering.js'; +import { imagePlaceholder } from './image-placeholder.js'; import { isRecord } from '../utils/index.js'; -// --------------------------------------------------------------------------- -// Tool call summarizer -// --------------------------------------------------------------------------- - type ToolCallSummarizer = (input: Readonly>) => string; const TOOL_CALL_SUMMARIZERS: Readonly> = { - // --- File operations --- Read: input => `Read(${String(input['file_path'] ?? '?')})`, Write: input => `Write(${String(input['file_path'] ?? '?')})`, Edit: input => `Edit(${String(input['file_path'] ?? '?')})`, @@ -58,17 +55,14 @@ const TOOL_CALL_SUMMARIZERS: Readonly> = { NotebookEdit: input => `NotebookEdit(${String(input['notebook_path'] ?? input['file_path'] ?? '?')})`, - // --- Shell / search --- Bash: input => `Bash(${truncate(String(input['command'] ?? '?'), 80)})`, Glob: input => `Glob(${String(input['pattern'] ?? '?')})`, Grep: input => `Grep(${String(input['pattern'] ?? '?')})`, - // --- Web --- WebFetch: input => `WebFetch(${String(input['url'] ?? '?')})`, WebSearch: input => `WebSearch(${truncate(String(input['query'] ?? '?'), 60)})`, - // --- Agent / Task --- Agent: input => { const desc = input['description'] ?? input['subagent_type'] ?? '?'; return `Agent(${truncate(String(desc), 60)})`; @@ -81,7 +75,6 @@ const TOOL_CALL_SUMMARIZERS: Readonly> = { : `Skill(${skill})`; }, - // --- TaskCreate/TaskUpdate family (replaces deprecated TodoWrite/Task) --- TaskCreate: input => `TaskCreate(${truncate(String(input['subject'] ?? input['description'] ?? '?'), 60)})`, TaskUpdate: input => { @@ -98,11 +91,9 @@ const TOOL_CALL_SUMMARIZERS: Readonly> = { TaskOutput: input => `TaskOutput(${String(input['taskId'] ?? '?')})`, TaskList: () => 'TaskList()', - // --- Tool/skill discovery --- ToolSearch: input => `ToolSearch(${truncate(String(input['query'] ?? '?'), 60)})`, - // --- Plan / interaction --- EnterPlanMode: () => 'EnterPlanMode()', ExitPlanMode: input => `ExitPlanMode(${truncate(String(input['plan'] ?? '?'), 60)})`, @@ -118,7 +109,6 @@ const TOOL_CALL_SUMMARIZERS: Readonly> = { return 'AskUserQuestion(?)'; }, - // --- Background / scheduling --- ScheduleWakeup: input => { const delay = input['delaySeconds']; const reason = input['reason']; @@ -134,19 +124,16 @@ const TOOL_CALL_SUMMARIZERS: Readonly> = { PushNotification: input => `PushNotification(${truncate(String(input['message'] ?? input['title'] ?? '?'), 60)})`, - // --- Worktree --- EnterWorktree: input => `EnterWorktree(${String(input['name'] ?? input['path'] ?? '?')})`, ExitWorktree: () => 'ExitWorktree()', - // --- Cron --- CronCreate: input => `CronCreate(${truncate(String(input['name'] ?? input['schedule'] ?? '?'), 60)})`, CronList: () => 'CronList()', CronDelete: input => `CronDelete(${String(input['id'] ?? input['name'] ?? '?')})`, - // --- IDE / LSP --- LSP: input => `LSP(${truncate(String(input['method'] ?? input['command'] ?? '?'), 60)})`, ListMcpResourcesTool: input => @@ -200,10 +187,6 @@ function summarizeGenericArgs( return ''; } -// --------------------------------------------------------------------------- -// Text extraction helpers -// --------------------------------------------------------------------------- - function isTextBlock(block: ContentBlock): block is TextBlock { return block.type === 'text'; } @@ -216,6 +199,10 @@ function isThinkingBlock(block: ContentBlock): block is ThinkingBlock { return block.type === 'thinking'; } +function isImageBlock(block: ContentBlock): block is ImageContentBlock { + return block.type === 'image'; +} + /** Extract user-facing text from a message's content field */ function extractText( content: string | readonly ContentBlock[], @@ -229,6 +216,8 @@ function extractText( for (const block of content) { if (isTextBlock(block) && block.text.trim()) { textParts.push(block.text); + } else if (isImageBlock(block)) { + textParts.push(imagePlaceholder(block.source)); } } @@ -288,27 +277,11 @@ interface ToolResultTextBlock { readonly text?: string | undefined; } -function hasTypeProperty( - item: Record -): item is Record & { type: unknown } { - return 'type' in item; -} - -function hasTextProperty( - item: Record -): item is Record & { text: unknown } { - return 'text' in item; -} - -function isTextResultBlock(item: unknown): item is ToolResultTextBlock { +function isTextResultBlock( + item: unknown +): item is ToolResultTextBlock & { readonly text: string } { if (!isRecord(item)) return false; - - return ( - hasTypeProperty(item) && - item.type === 'text' && - hasTextProperty(item) && - typeof item.text === 'string' - ); + return item['type'] === 'text' && typeof item['text'] === 'string'; } export function extractToolResultText( @@ -318,7 +291,7 @@ export function extractToolResultText( if (!Array.isArray(content)) return ''; const parts: string[] = []; for (const item of content) { - if (isTextResultBlock(item) && item.text !== undefined) { + if (isTextResultBlock(item)) { parts.push(item.text); } } @@ -372,10 +345,6 @@ function truncate(text: string, maxLength: number): string { const MAX_THINKING_PREVIEW_LENGTH = 1000; -// --------------------------------------------------------------------------- -// Denoiser — converts raw lines to clean messages -// --------------------------------------------------------------------------- - function denoiseLines( lines: readonly RawHistoryLine[], config: DenoiseConfig, @@ -385,7 +354,6 @@ function denoiseLines( const toolNameById = buildToolNameMap(lines); for (const line of lines) { - // Skip non-message lines (session summaries, system, progress indicators) if ( line.type === 'result' || line.type === 'system' || @@ -410,7 +378,6 @@ function denoiseLines( ? extractToolResults(msg.content, toolNameById) : []; - // Skip user messages with no text, errors, or tool results if (!text && errors.length === 0 && toolResults.length === 0) continue; messages.push({ @@ -425,7 +392,6 @@ function denoiseLines( } else if (msg.role === 'assistant') { const text = extractText(msg.content, config.maxAssistantBlockLength); - // Skip assistant messages that have no text (pure tool-call turns) if (!text && !config.includeToolSummaries) continue; const toolCalls = config.includeToolSummaries @@ -440,7 +406,6 @@ function denoiseLines( ? `[thinking]\n${truncate(thinking, MAX_THINKING_PREVIEW_LENGTH)}\n[/thinking]\n\n${text}` : text; - // Skip if there's truly nothing if (!fullText && toolCalls.length === 0) continue; messages.push({ @@ -480,6 +445,8 @@ function extractUserText(msg: RawMessage, config: DenoiseConfig): string { if (!isSystemNoise(block.text)) { textParts.push(block.text); } + } else if (isImageBlock(block)) { + textParts.push(imagePlaceholder(block.source)); } // tool_result blocks in user messages are Claude Code feeding back // tool output — this is noise (file contents, command output, etc.) @@ -489,10 +456,6 @@ function extractUserText(msg: RawMessage, config: DenoiseConfig): string { return truncate(joined, config.maxUserMessageLength); } -// --------------------------------------------------------------------------- -// Stats calculation -// --------------------------------------------------------------------------- - function calculateStats(raw: RawSession): SessionStats { const allLines = [ ...raw.mainLines, @@ -523,7 +486,6 @@ function calculateStats(raw: RawSession): SessionStats { if (msg.role === 'user') { userMessages++; - // Count tool_result errors in user messages if (typeof msg.content !== 'string') { for (const block of msg.content) { if ( @@ -564,10 +526,6 @@ function calculateStats(raw: RawSession): SessionStats { }; } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - /** * Denoise a raw session into clean, structured messages. * @@ -581,10 +539,8 @@ export function denoiseSession( ): ParsedSession { const cfg: DenoiseConfig = { ...DEFAULT_DENOISE_CONFIG, ...config }; - // Denoise main session const mainMessages = denoiseLines(raw.mainLines, cfg); - // Denoise subagent sessions const subagentSessions: ParsedSubagentSession[] = cfg.includeSubagents ? raw.subagentFiles.map(f => ({ agentFile: f.filename, @@ -592,7 +548,6 @@ export function denoiseSession( })) : []; - // Calculate timestamps from clean messages const allTimestamps = [ ...mainMessages.map(m => m.timestamp), ...subagentSessions.flatMap(s => s.messages.map(m => m.timestamp)), diff --git a/src/processing/discovery.ts b/src/processing/discovery.ts index 36ce902..7e1f5a3 100644 --- a/src/processing/discovery.ts +++ b/src/processing/discovery.ts @@ -2,8 +2,8 @@ * Session discovery — find and filter sessions in a Claude project directory. * * Scans the project dir for session JSONL files, extracts timestamps - * from their first few lines, and supports filtering by date range - * or "since last export" via a marker file. + * from their first few lines, and filters by date range or "since last + * export" via a marker file. */ import { readFile, readdir, writeFile, stat } from 'node:fs/promises'; @@ -279,9 +279,7 @@ async function dirExists(path: string): Promise { } } -// --------------------------------------------------------------------------- -// Export marker — tracks when sessions were last exported -// --------------------------------------------------------------------------- +// Export marker persistence. /** * Read the last export timestamp from the marker file. diff --git a/src/processing/formatter.ts b/src/processing/formatter.ts index 53e220c..0281dcc 100644 --- a/src/processing/formatter.ts +++ b/src/processing/formatter.ts @@ -1,7 +1,7 @@ /** * Markdown formatter — converts denoised sessions to clean markdown. * - * Output format is designed for: + * Output format serves: * 1. Human readability (review session insights) * 2. LLM ingestion (feed to memU memorize, Gemini analysis, etc.) * 3. Archive (searchable session history) @@ -16,9 +16,7 @@ import type { import type { SessionInfo } from './discovery.js'; import { compareStrings } from './ordering.js'; -// --------------------------------------------------------------------------- // Configuration -// --------------------------------------------------------------------------- export interface FormatConfig { /** Include session stats header (default: true) */ @@ -41,9 +39,7 @@ const DEFAULT_FORMAT_CONFIG: FormatConfig = { messageSeparator: '\n', }; -// --------------------------------------------------------------------------- // Formatters -// --------------------------------------------------------------------------- function formatStats( stats: SessionStats, @@ -140,9 +136,7 @@ function formatSubagentSession( return parts.join(config.messageSeparator); } -// --------------------------------------------------------------------------- // Public API -// --------------------------------------------------------------------------- /** * Format a denoised session as clean markdown. @@ -194,9 +188,7 @@ export function toMarkdown( return parts.join(cfg.messageSeparator) + '\n'; } -// --------------------------------------------------------------------------- // Merged timeline — interleaves subagent messages inline with main session -// --------------------------------------------------------------------------- /** * Merge main session messages and subagent messages into a single timeline, @@ -214,9 +206,7 @@ export function mergeTimeline(session: ParsedSession): CleanMessage[] { return all; } -// --------------------------------------------------------------------------- // Export formatter — inline agents, thinking blocks, full detail -// --------------------------------------------------------------------------- export interface ExportConfig { /** Include tool call annotations (default: true) */ @@ -248,7 +238,7 @@ const DEFAULT_EXPORT_CONFIG: ExportConfig = { * - Subagent messages are merged into the main timeline by timestamp * - Agent messages get attributed headers: "### Agent: Explore memU-server repo" * - Thinking blocks rendered in
tags - * - Designed for archival and memU ingestion + * - Suitable for archival and memU ingestion */ export function toExportMarkdown( session: ParsedSession, @@ -449,9 +439,7 @@ function formatDate(timestamp: string): string { }); } -// --------------------------------------------------------------------------- -// Original formatters (unchanged) -// --------------------------------------------------------------------------- +// Compact summary formatter. /** * Format a session as a compact summary (for context injection or quick review). diff --git a/src/processing/image-placeholder.ts b/src/processing/image-placeholder.ts new file mode 100644 index 0000000..9e35557 --- /dev/null +++ b/src/processing/image-placeholder.ts @@ -0,0 +1,26 @@ +/** + * Safe placeholders for image content blocks. + */ + +import { isRecord } from '../utils/index.js'; + +/** + * Return a safe placeholder for image content blocks. + * + * Arrays are normalized to the generic placeholder even though the shared + * `isRecord()` helper accepts them, because image `source` is expected to be a + * plain object with an optional safe `media_type` field. + */ +export function imagePlaceholder(source: unknown): string { + if (!isRecord(source) || Array.isArray(source)) return '[Image]'; + const mediaType = source['media_type']; + if (!isSafeMediaType(mediaType)) return '[Image]'; + return `[Image: ${mediaType}]`; +} + +function isSafeMediaType(value: unknown): value is string { + return ( + typeof value === 'string' && + /^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(value) + ); +} diff --git a/src/processing/index.ts b/src/processing/index.ts index ed385ff..3af7769 100644 --- a/src/processing/index.ts +++ b/src/processing/index.ts @@ -1,20 +1,7 @@ /** - * Session processing module — parse, denoise, and format Claude Code sessions. - * - * Pipeline: Raw JSONL → Parse → Denoise → Format (markdown) - * - * Usage: - * import { readSessionFiles, denoiseSession, toMarkdown } from './processing'; - * - * const raw = await readSessionFiles(projectDir, sessionId); - * const clean = denoiseSession(raw); - * const markdown = toMarkdown(clean); + * Public processing barrel and high-level session pipelines. */ -// --------------------------------------------------------------------------- -// Imports (single import per module to satisfy no-duplicate-imports) -// --------------------------------------------------------------------------- - import { type RawHistoryLine, type RawMessage, @@ -23,6 +10,7 @@ import { type ToolUseBlock, type ToolResultBlock, type ThinkingBlock, + type ImageContentBlock, type CleanMessage, type ToolResultEntry, type ParsedSession, @@ -84,12 +72,7 @@ import { writeExportMarker, } from './discovery.js'; -// --------------------------------------------------------------------------- -// Re-exports -// --------------------------------------------------------------------------- - export type { - // types.ts — raw + denoised RawHistoryLine, RawMessage, ContentBlock, @@ -97,13 +80,13 @@ export type { ToolUseBlock, ToolResultBlock, ThinkingBlock, + ImageContentBlock, CleanMessage, ToolResultEntry, ParsedSession, ParsedSubagentSession, SessionStats, DenoiseConfig, - // types.ts — structured blocks (live-ingest / DB / AI consumers) SessionBlock, SessionBlockBase, SessionHeaderBlock, @@ -117,15 +100,11 @@ export type { RawTranscriptRedactionMode, RawTranscriptSession, RawTranscriptTailResult, - // parser.ts RawSession, - // formatter.ts FormatConfig, ExportConfig, - // discovery.ts SessionInfo, DiscoverOptions, - // tail.ts — incremental block emission for live consumers TailMarker, TailOptions, RawTranscriptTailOptions, @@ -135,25 +114,18 @@ export type { }; export { - // types.ts DEFAULT_DENOISE_CONFIG, - // parser.ts readSessionFiles, - // denoiser.ts denoiseSession, - // blocks.ts — structured (JSONL) export extractBlocks, toJsonlBlocks, - // tail.ts — incremental block emission tailBlocks, tailRawTranscriptRecords, watchRawTranscriptRecords, readRawSessionFiles, - // formatter.ts toMarkdown, toCompactSummary, toExportMarkdown, - // discovery.ts discoverSessions, projectDirFromCwd, listProjects, @@ -163,10 +135,6 @@ export { writeExportMarker, }; -// --------------------------------------------------------------------------- -// Convenience: full pipeline in one call -// --------------------------------------------------------------------------- - /** * Full pipeline: read JSONL from disk → denoise → format as markdown. * diff --git a/src/processing/parser.ts b/src/processing/parser.ts index f9fc8e8..444a2d5 100644 --- a/src/processing/parser.ts +++ b/src/processing/parser.ts @@ -3,7 +3,7 @@ * * Handles both the main session file and subagent logs: * .jsonl — main session - * /subagents/agent-*.jsonl — subagent logs (new format) + * /subagents/agent-*.jsonl — subagent logs */ import { readFile, readdir } from 'node:fs/promises'; diff --git a/src/processing/tail.ts b/src/processing/tail.ts index c033535..1a239c1 100644 --- a/src/processing/tail.ts +++ b/src/processing/tail.ts @@ -1,8 +1,8 @@ /** - * Tail mode — incremental `SessionBlock` emission for live consumers. + * Tail mode — appended-block `SessionBlock` emission for live consumers. * - * Designed for live-ingest consumers that need to stream new blocks - * into a database as Claude Code appends to its session JSONL files. + * Live-ingest consumers can stream new blocks into a database as Claude Code + * appends to its session JSONL files. * * Workflow: * 1. Consumer (e.g., a Rust backend with a notify watcher) detects @@ -10,7 +10,7 @@ * 2. Consumer calls `tailBlocks(jsonlPath)` — gets back only blocks added * since the last successful tail call. * 3. Consumer upserts blocks into DB (idempotent via stable `id` field). - * 4. Marker is automatically advanced to the new file size so the next + * 4. Marker advances to the new file size so the next * call returns only newer blocks. * * Resilience properties: @@ -59,9 +59,7 @@ import type { TailProcessingCounts, } from './types.js'; -// --------------------------------------------------------------------------- // Marker — persists last-emitted byte offset per session -// --------------------------------------------------------------------------- export interface TailMarker { /** Byte offset in the JSONL file up to which blocks have been emitted. */ @@ -132,9 +130,7 @@ export async function writeMarker( } } -// --------------------------------------------------------------------------- // Tail -// --------------------------------------------------------------------------- export interface TailOptions { /** Override marker storage directory (default: `/.tail-markers/`) */ @@ -205,15 +201,15 @@ const tailFileCache = new Map(); * Upper bound on distinct session files tracked in `tailFileCache`. Long-running * watch consumers can tail many sessions over their lifetime; without a bound * the cache would grow unbounded. Maps preserve insertion order, so once the - * cap is reached we evict the oldest (least-recently inserted) entry before - * adding a new key. This is a soft LRU-by-insertion, not a hot-path concern. + * cap is reached we evict the oldest entry before inserting another key. This + * is a soft LRU-by-insertion, not a hot-path concern. */ const TAIL_FILE_CACHE_MAX_ENTRIES = 1024; /** - * Set a cache entry, evicting the oldest entry first when adding a brand-new - * key would exceed `TAIL_FILE_CACHE_MAX_ENTRIES`. Updating an existing key never - * evicts (it does not grow the map). + * Set a cache entry, evicting the oldest entry first when an unseen key would + * exceed `TAIL_FILE_CACHE_MAX_ENTRIES`. Updating an existing key never evicts + * because it does not grow the map. */ function setTailFileCache(cacheKey: string, value: TailFileCache): void { if ( @@ -255,7 +251,7 @@ export async function tailBlocks( ); } // Deliberate divergence from extractBlocks (blocks.ts), which keeps physical - // line order: tail emits incrementally as the file grows, so it sorts each + // line order: tail emits as the file grows, so it sorts each // emitted batch by timestamp to stay robust against out-of-order appends in // live sessions. Under the normal time-ordered append pattern this produces // the same ordering as extractBlocks, so parity holds for completed sessions. @@ -463,9 +459,7 @@ async function tailTranscriptRecordsInternal( }; } -// --------------------------------------------------------------------------- // Internals -// --------------------------------------------------------------------------- /** * Walk the file content tracking byte offsets and return parsed lines whose diff --git a/src/processing/tool-result-redaction.ts b/src/processing/tool-result-redaction.ts index 59db946..f5766b3 100644 --- a/src/processing/tool-result-redaction.ts +++ b/src/processing/tool-result-redaction.ts @@ -35,7 +35,7 @@ const BARE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/g; // Standalone token patterns. Each uses a single bounded char-class repetition // (no nested quantifiers) so matching stays linear / non-backtracking. -// OpenAI: catches classic `sk-<32+ alnum>` and modern project keys +// OpenAI: catches classic `sk-<32+ alnum>` and project-scoped keys // (`sk-proj-...`), whose embedded `-` broke the old `[A-Za-z0-9]{32,}` form. const OPENAI_API_KEY_PATTERN = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g; const GITHUB_TOKEN_PATTERN = /\bghp_[A-Za-z0-9]{36,}\b/g; diff --git a/src/processing/types.ts b/src/processing/types.ts index 2172ac6..9362b43 100644 --- a/src/processing/types.ts +++ b/src/processing/types.ts @@ -6,10 +6,6 @@ * ~/.claude/projects///subagents/agent-*.jsonl */ -// --------------------------------------------------------------------------- -// Raw JSONL types (matches Claude Code session storage format) -// --------------------------------------------------------------------------- - /** Content block inside an assistant message */ export interface TextBlock { readonly type: 'text'; @@ -37,11 +33,17 @@ export interface ThinkingBlock { readonly thinking: string; } +export interface ImageContentBlock { + readonly type: 'image'; + readonly source?: unknown; +} + export type ContentBlock = | TextBlock | ToolUseBlock | ToolResultBlock - | ThinkingBlock; + | ThinkingBlock + | ImageContentBlock; export interface RawMessage { readonly role: 'user' | 'assistant'; @@ -80,10 +82,6 @@ export interface RawHistoryLine { readonly subagentId?: string | undefined; } -// --------------------------------------------------------------------------- -// Raw transcript records — unsafe exact access requires explicit opt-in -// --------------------------------------------------------------------------- - export type RawTranscriptRedactionMode = 'unsafe-unredacted'; export interface RawTranscriptRecord { @@ -124,10 +122,6 @@ export interface RawTranscriptSession { readonly records: readonly RawTranscriptRecord[]; } -// --------------------------------------------------------------------------- -// Denoised output types -// --------------------------------------------------------------------------- - /** A cleaned message with only the valuable signal */ export interface CleanMessage { readonly role: 'user' | 'assistant'; @@ -175,19 +169,15 @@ export interface ParsedSubagentSession { readonly messages: readonly CleanMessage[]; } -// --------------------------------------------------------------------------- -// SessionBlock — discriminated union for structured (JSONL) export -// --------------------------------------------------------------------------- -// -// One record per atomic conversation event. Designed for downstream consumers -// (live-ingest consumers, vector DB ingestion, AI processing) that need typed blocks -// rather than rendered markdown. +// One record per atomic conversation event. Downstream consumers such as +// live-ingest services, vector DB ingestion, and AI processing can use typed +// blocks rather than rendered markdown. // // IDs are stable (`${messageUuid}:${blockIndex}` for message blocks, // `${sessionId}:agent-${direction}:${agentFile}` for synthetic boundaries) so // re-running the parser on the same JSONL produces identical IDs — making DB -// upserts idempotent and enabling tail-mode incremental ingestion of growing -// session files. +// upserts idempotent and enabling tail-mode ingestion of growing session +// files. export interface SessionBlockBase { /** Stable upsert key: `${messageUuid}:${blockIndex}`, sessionId, or namespaced synthetic boundary ID */ @@ -285,10 +275,6 @@ export interface SessionStats { readonly costUSD: number; } -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- - export interface DenoiseConfig { /** Include tool call summaries (default: true) */ readonly includeToolSummaries: boolean; diff --git a/src/types/index.ts b/src/types/index.ts index 32755d6..32f4ab7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,17 +1,9 @@ /** - * TypeScript type definitions for Claude Code hooks - * - * This file contains comprehensive type definitions for all Claude Code hook events, - * their input data, and expected output formats. + * Type contracts for Claude Code hook inputs, outputs, tool inputs, and settings. + * These declarations mirror the JSON read from stdin and written to stdout by hook handlers. */ -// ============================================================================= -// Base Hook Interfaces -// ============================================================================= - -/** - * Common fields present in all hook inputs - */ +/** Common fields present in all hook inputs. */ export interface BaseHookInput { /** Unique identifier for the current Claude Code session */ session_id: string; @@ -27,6 +19,12 @@ export interface BaseHookInput { agent_id?: string | undefined; /** Agent name when running under --agent or inside a subagent */ agent_type?: string | undefined; + /** Effort metadata for the current turn, when provided by Claude Code */ + effort?: + | { + level: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + } + | undefined; } /** @@ -53,12 +51,10 @@ export interface BaseHookOutput { suppressOutput?: boolean; /** Optional warning message shown to the user */ systemMessage?: string; + /** ANSI escape sequences or similar terminal control output */ + terminalSequence?: string; } -// ============================================================================= -// Tool-Related Hook Interfaces -// ============================================================================= - /** * Input for PreToolUse hooks - runs before tool execution */ @@ -66,7 +62,7 @@ export interface PreToolUseInput extends BaseHookInput { hook_event_name: 'PreToolUse'; /** Name of the tool about to be executed */ tool_name: string; - /** Parameters that will be passed to the tool */ + /** Parameters passed to the tool. */ tool_input: Record; /** Unique identifier for this tool use */ tool_use_id: string; @@ -85,6 +81,8 @@ export interface PostToolUseInput extends BaseHookInput { tool_response: Record; /** Unique identifier for this tool use */ tool_use_id: string; + /** Tool execution duration in milliseconds */ + duration_ms?: number | undefined; } /** @@ -95,7 +93,7 @@ export interface PreToolUseOutput extends BaseHookOutput { decision?: 'approve' | 'block'; // Deprecated: use hookSpecificOutput instead /** Reason for the decision */ reason?: string; // Deprecated: use hookSpecificOutput instead - /** Modern hook-specific output format */ + /** Structured hook-specific output. */ hookSpecificOutput?: { hookEventName: 'PreToolUse'; /** Permission decision: allow bypasses permission system, deny blocks, ask prompts user */ @@ -123,14 +121,12 @@ export interface PostToolUseOutput extends BaseHookOutput { /** Additional information for Claude to consider */ additionalContext?: string; /** For MCP tools only: replaces the tool's output with the provided value */ - updatedMCPToolOutput?: Record; + updatedMCPToolOutput?: unknown; + /** Replaces the tool output with the provided value */ + updatedToolOutput?: unknown; }; } -// ============================================================================= -// Permission Request Hook Interfaces -// ============================================================================= - /** * Input for PermissionRequest hooks - runs when a permission dialog appears * Unlike PreToolUse, does NOT include tool_use_id @@ -169,10 +165,6 @@ export interface PermissionRequestOutput extends BaseHookOutput { }; } -// ============================================================================= -// Post Tool Use Failure Hook Interfaces -// ============================================================================= - /** * Input for PostToolUseFailure hooks - runs when tool execution fails */ @@ -188,6 +180,8 @@ export interface PostToolUseFailureInput extends BaseHookInput { error: string; /** Whether the failure was caused by user interruption */ is_interrupt?: boolean | undefined; + /** Tool execution duration in milliseconds */ + duration_ms?: number | undefined; } /** @@ -275,10 +269,6 @@ export interface PostToolBatchOutput extends BaseHookOutput { }; } -// ============================================================================= -// Subagent Start Hook Interfaces -// ============================================================================= - /** * Input for SubagentStart hooks - runs when a subagent is spawned */ @@ -301,10 +291,6 @@ export interface SubagentStartOutput extends BaseHookOutput { }; } -// ============================================================================= -// Agent Teams Hook Interfaces -// ============================================================================= - /** * Input for TeammateIdle hooks - runs when a teammate is about to go idle * Decision control: exit code only (no JSON decision control) @@ -352,10 +338,6 @@ export interface TaskCompletedInput extends BaseHookInput { team_name?: string | undefined; } -// ============================================================================= -// Lifecycle Hook Interfaces -// ============================================================================= - /** * Input for UserPromptSubmit hooks - runs when user submits a prompt */ @@ -429,7 +411,37 @@ export interface NotificationInput extends BaseHookInput { | 'permission_prompt' | 'idle_prompt' | 'auth_success' - | 'elicitation_dialog'; + | 'elicitation_dialog' + | 'elicitation_complete' + | 'elicitation_response'; +} + +/** + * Input for MessageDisplay hooks - runs while assistant text is streaming + */ +export interface MessageDisplayInput extends BaseHookInput { + hook_event_name: 'MessageDisplay'; + /** Unique identifier for the current turn */ + turn_id: string; + /** Unique identifier for the message being displayed */ + message_id: string; + /** Zero-based chunk index for this display delta */ + index: number; + /** Whether this is the final chunk */ + final: boolean; + /** Delta text being displayed */ + delta: string; +} + +/** + * MessageDisplay-specific output for overriding rendered content + */ +export interface MessageDisplayOutput extends BaseHookOutput { + hookSpecificOutput?: { + hookEventName: 'MessageDisplay'; + /** Optional replacement content for display */ + displayContent?: string; + }; } /** @@ -515,7 +527,9 @@ export interface SessionStartInput extends BaseHookInput { /** How the session was started: 'startup', 'resume', 'clear', 'compact' */ source: 'startup' | 'resume' | 'clear' | 'compact'; /** The model identifier */ - model: string; + model?: string | undefined; + /** Session title when one is already known */ + session_title?: string | undefined; /** Agent name if started with --agent */ agent_type?: string | undefined; } @@ -528,6 +542,34 @@ export interface SessionStartOutput extends BaseHookOutput { hookEventName: 'SessionStart'; /** String added to the context at session start */ additionalContext?: string; + /** Initial user-visible message to seed the session */ + initialUserMessage?: string; + /** Sets the session title */ + sessionTitle?: string; + /** Dynamic absolute paths to watch */ + watchPaths?: string[]; + /** Reload active skills after session setup */ + reloadSkills?: boolean; + }; +} + +/** + * Input for Setup hooks - runs during init-only or maintenance mode + */ +export interface SetupInput extends BaseHookInput { + hook_event_name: 'Setup'; + /** How setup was triggered */ + trigger: 'init' | 'maintenance'; +} + +/** + * Setup-specific output for context injection + */ +export interface SetupOutput extends BaseHookOutput { + hookSpecificOutput?: { + hookEventName: 'Setup'; + /** String added to setup context */ + additionalContext?: string; }; } @@ -554,9 +596,12 @@ export interface StopFailureInput extends BaseHookInput { /** API error type */ error: | 'rate_limit' + | 'overloaded' | 'authentication_failed' + | 'oauth_org_not_allowed' | 'billing_error' | 'invalid_request' + | 'model_not_found' | 'server_error' | 'max_output_tokens' | 'unknown'; @@ -623,7 +668,7 @@ export interface CwdChangedInput extends BaseHookInput { hook_event_name: 'CwdChanged'; /** Previous working directory */ old_cwd: string; - /** New working directory */ + /** Working directory after the change. */ new_cwd: string; } @@ -651,7 +696,7 @@ export interface WatchPathsOutput extends BaseHookOutput { */ export interface WorktreeCreateInput extends BaseHookInput { hook_event_name: 'WorktreeCreate'; - /** Slug identifier for the new worktree */ + /** Slug identifier for the worktree being created. */ name: string; } @@ -738,14 +783,11 @@ export interface ElicitationOutput extends BaseHookOutput { }; } -// ============================================================================= -// Union Types for Type Guards -// ============================================================================= - /** * Union of all possible hook input types */ export type HookInput = + | SetupInput | PreToolUseInput | PostToolUseInput | PermissionRequestInput @@ -755,6 +797,7 @@ export type HookInput = | UserPromptSubmitInput | UserPromptExpansionInput | NotificationInput + | MessageDisplayInput | StopInput | StopFailureInput | SubagentStartInput @@ -779,6 +822,7 @@ export type HookInput = * Union of all possible hook output types */ export type HookOutput = + | SetupOutput | PreToolUseOutput | PostToolUseOutput | PermissionRequestOutput @@ -786,6 +830,7 @@ export type HookOutput = | PostToolUseFailureOutput | PostToolBatchOutput | SubagentStartOutput + | MessageDisplayOutput | NotificationOutput | UserPromptSubmitOutput | UserPromptExpansionOutput @@ -798,10 +843,6 @@ export type HookOutput = | ElicitationOutput | BaseHookOutput; // For hooks that don't have specific output requirements (TeammateIdle, TaskCompleted, etc.) -// ============================================================================= -// Common Tool Input Types -// ============================================================================= - /** * Common tool input patterns for frequently used tools */ @@ -909,15 +950,12 @@ export interface TaskToolInput { model?: string; } -// ============================================================================= -// Hook Configuration Types (settings.json schema) -// ============================================================================= - /** - * All 28 hook event names + * All supported hook event names */ export type HookEventName = | 'SessionStart' + | 'Setup' | 'UserPromptSubmit' | 'UserPromptExpansion' | 'PreToolUse' @@ -927,6 +965,7 @@ export type HookEventName = | 'PostToolUseFailure' | 'PostToolBatch' | 'Notification' + | 'MessageDisplay' | 'SubagentStart' | 'SubagentStop' | 'TaskCreated' @@ -967,6 +1006,7 @@ export interface CommandHookHandler extends HookHandlerBase { type: 'command'; /** Shell command to execute */ command: string; + args?: string[]; /** If true, runs in the background without blocking. Only for command hooks */ async?: boolean; /** If true, runs in the background and wakes Claude on exit code 2 */ @@ -1056,10 +1096,6 @@ export interface HooksConfig { httpHookAllowedEnvVars?: string[]; } -// ============================================================================= -// Utility Types and Helpers -// ============================================================================= - /** * Type guard to check if input is a specific hook type */ @@ -1070,13 +1106,13 @@ export function isHookType( return input.hook_event_name === eventName; } -// HookOutputBuilder moved to src/utils/output-builder.ts — re-export for compatibility +// Compatibility export for HookOutputBuilder. export { HookOutputBuilder } from '../utils/output-builder.js'; /** * Environment variables provided by Claude Code to hook processes. * - * These are set in the hook's execution environment automatically. + * Claude Code sets these variables in the hook's execution environment. * Not all variables are available for all event types. */ export interface HookEnvironmentVars { diff --git a/src/utils/index.ts b/src/utils/index.ts index 8ae89f1..f1aba87 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,11 +1,5 @@ /** - * Core utility functions for Claude Code hooks - * - * This module provides essential utilities that all hooks can use: - * - Input/output handling - * - Error management - * - JSON formatting - * - Environment detection + * Utilities for hook stdin/stdout I/O, logging, config, and path checks. */ import { stdin, stdout, stderr, env, exit } from 'node:process'; @@ -13,21 +7,14 @@ import type { HookInput, HookOutput, HookConfig } from '../types/index.js'; import { validateHookInput as zodValidateHookInput } from '../validation/validators.js'; import type { HookInputSchema } from '../validation/schemas.js'; -// ============================================================================= -// Input/Output Utilities -// ============================================================================= - /** - * Read and parse JSON input from stdin - * This is the primary way hooks receive data from Claude Code. - * All input is validated at this boundary using Zod schemas. + * Read JSON from stdin and validate it at the hook boundary. */ export async function readStdinJson(): Promise { try { const input = await readStdin(); const parsed: unknown = JSON.parse(input); - // Validate at boundary using Zod schemas — the primary defense const validated = zodValidateHookInput(parsed); if (getConfig().debug) { @@ -43,17 +30,15 @@ export async function readStdinJson(): Promise { } /** - * Read raw text from stdin - * Used internally by readStdinJson, but also available for custom parsing + * Read raw stdin text with a 30-second timeout. */ export async function readStdin(): Promise { const chunks: Buffer[] = []; - // Set a timeout to prevent hanging const timeout = setTimeout(() => { logError('Timeout waiting for stdin input'); exit(1); - }, 30000); // 30 second timeout + }, 30000); try { for await (const chunk of stdin as AsyncIterable) { @@ -74,8 +59,7 @@ export async function readStdin(): Promise { } /** - * Output JSON response to Claude Code - * This is the primary way hooks send structured responses + * Write structured hook output to stdout. */ export function outputJson(data: HookOutput): void { const jsonString = JSON.stringify(data, null, 2); @@ -88,8 +72,7 @@ export function outputJson(data: HookOutput): void { } /** - * Output plain text (for simple success messages) - * Used when hooks want to send simple text responses + * Write plain text to stdout. */ export function outputText(message: string): void { if (getConfig().debug) { @@ -99,13 +82,8 @@ export function outputText(message: string): void { stdout.write(message); } -// ============================================================================= -// Logging Utilities -// ============================================================================= - /** - * Log error message to stderr - * Claude Code shows stderr to the user when hooks fail + * Log an error to stderr. */ export function logError(message: string, error?: Error): void { const timestamp = new Date().toISOString(); @@ -117,8 +95,7 @@ export function logError(message: string, error?: Error): void { } /** - * Log warning message to stderr - * Used for non-fatal issues that users should be aware of + * Log a warning to stderr when debug mode is enabled. */ export function logWarning(message: string): void { if (getConfig().debug) { @@ -128,8 +105,7 @@ export function logWarning(message: string): void { } /** - * Log debug information to stderr - * Only shown when debug mode is enabled + * Log debug data to stderr when debug mode is enabled. */ export function logDebug(message: string, data?: unknown): void { if (getConfig().debug) { @@ -145,30 +121,36 @@ export function logDebug(message: string, data?: unknown): void { } /** - * Log info message to stderr - * General informational messages for the user + * Log an info message to stderr. */ export function logInfo(message: string): void { const timestamp = new Date().toISOString(); stderr.write(`[${timestamp}] INFO: ${message}\n`); } +/** + * Normalize an unknown value to an Error instance. + * + * @param error - Value to normalize. + * @returns The error if it is an Error, otherwise a new Error wrapping the value. + */ export function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +/** + * Check whether a value is a non-null object record. + * + * @param value - Value to check. + * @returns true when the value is a non-null object. + */ export function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } -// ============================================================================= -// Environment and Configuration -// ============================================================================= - /** - * Get hook configuration from environment variables - * Allows hooks to be customized without code changes. - * Env is read once per process; tests can call resetConfigCache() to reload. + * Read hook configuration from environment variables and cache it per process. + * Tests can call resetConfigCache() after mutating env vars. */ let cachedConfig: HookConfig | undefined; @@ -217,8 +199,7 @@ export function getConfig(): HookConfig { } /** - * Clear the cached HookConfig. Exposed for tests that mutate env vars - * between cases; not intended for runtime use. + * Clear the cached hook config for tests that mutate env vars. */ export function resetConfigCache(): void { cachedConfig = undefined; @@ -234,8 +215,7 @@ function parseSessionEndTimeoutMs(value: string | undefined): number { } /** - * Get the project root directory - * Uses CLAUDE_PROJECT_DIR environment variable set by Claude Code + * Return CLAUDE_PROJECT_DIR and throw if it is unset. */ export function getProjectDir(): string { const projectDir = env['CLAUDE_PROJECT_DIR']; @@ -246,16 +226,14 @@ export function getProjectDir(): string { } /** - * Check if we're running in a development environment - * Useful for enabling different behavior during development vs production + * Return true in development or when NODE_ENV is unset. */ export function isDevelopment(): boolean { return env['NODE_ENV'] === 'development' || env['NODE_ENV'] === undefined; } /** - * Check if we're running in CI - * Useful for disabling interactive features in automated environments + * Return true in common CI environments. */ export function isCI(): boolean { return ( @@ -265,13 +243,9 @@ export function isCI(): boolean { ); } -// ============================================================================= -// Error Handling Utilities -// ============================================================================= - /** - * Wrap hook execution with proper error handling - * Ensures consistent error reporting and exit codes + * Run a hook handler with standard stdin parsing, logging, and exit codes. + * Blocking errors exit 2; non-blocking errors exit 1. */ export function executeHook( handler: (input: T) => Promise | void @@ -287,8 +261,6 @@ export async function executeHook( if (error instanceof Error) { logError('Hook execution failed', error); - // Exit code 2 = blocking error (Claude Code will show this to Claude) - // Exit code 1 = non-blocking error (shown to user only) const isBlockingError = error.message.includes('BLOCK') || error.message.includes('DENY'); exit(isBlockingError ? 2 : 1); @@ -300,8 +272,7 @@ export async function executeHook( } /** - * Create a standardized error for blocking operations - * Use this when you want Claude Code to show the error to Claude for processing + * Create an error tagged for blocking operations. */ export function createBlockingError(message: string): Error { const error = new Error(`BLOCK: ${message}`); @@ -310,8 +281,7 @@ export function createBlockingError(message: string): Error { } /** - * Create a standardized error for non-blocking warnings - * Use this for issues that don't prevent operation but should be noted + * Create an error tagged for non-blocking warnings. */ export function createWarningError(message: string): Error { const error = new Error(`WARNING: ${message}`); @@ -319,44 +289,13 @@ export function createWarningError(message: string): Error { return error; } -// ============================================================================= -// File and Path Utilities -// ============================================================================= - -/** - * Normalize file path for consistent comparison across platforms - * - * This function handles cross-platform path differences to ensure - * consistent behavior across Windows, macOS, and Linux systems. - * - * Transformations performed: - * 1. Convert Windows backslashes (\) to forward slashes (/) - * 2. Remove trailing slashes except for root directory - * 3. Apply case-insensitive normalization on Windows platforms - * - * Examples: - * - Windows: "src\\file.txt" -> "src/file.txt" - * - Trailing: "path/to/dir/" -> "path/to/dir" - * - Root: "/" remains "/" - * - Windows case: "FILE.TXT" -> "file.txt" (on win32) - * - * @param path The file path to normalize - * @returns Normalized path string for consistent comparison - */ function normalizeFilePath(path: string): string { - // Step 1: Convert Windows backslashes to forward slashes for consistent comparison - // This ensures paths work the same way regardless of platform let normalized = path.replace(/\\/g, '/'); - // Step 2: Remove trailing slashes except for root directory - // This prevents "/path/" and "/path" from being treated differently if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } - // Step 3: Convert to lowercase for case-insensitive comparison on Windows-like systems - // Windows file systems are case-insensitive, so "FILE.txt" === "file.txt" - // Unix-like systems are case-sensitive, so we preserve case there if (process.platform === 'win32') { normalized = normalized.toLowerCase(); } @@ -364,207 +303,72 @@ function normalizeFilePath(path: string): string { return normalized; } -// Cache for compiled regex patterns to avoid recompilation -// Using Map to allow for proper cleanup and garbage collection const globRegexCache = new Map(); -/** - * Convert glob pattern to regex with proper cross-platform handling - * - * This function transforms glob patterns (like those used in .gitignore) - * into JavaScript RegExp objects for efficient pattern matching. - * - * SUPPORTED GLOB PATTERNS: - * - `*` : Matches any characters except path separators (single directory level) - * - `**` : Matches any characters including path separators (recursive/multi-level) - * - `.` : Literal dot (escaped in output regex) - * - Other special regex chars are automatically escaped - * - * EXAMPLES: - * - `*.txt` -> Matches: file.txt, test.txt | No match: dir/file.txt - * - `**​/*.txt` -> Matches: file.txt, dir/file.txt, deep/nested/file.txt - * - `.git/**​` -> Matches: .git/config, .git/objects/abc123 - * - `node_modules` -> Matches: node_modules (exact) - * - * PERFORMANCE FEATURES: - * - Compiled regex patterns are cached to avoid recompilation - * - Cache keys include platform information for cross-platform consistency - * - Map-based cache allows for proper garbage collection - * - * CROSS-PLATFORM HANDLING: - * - Normalizes paths before processing (handles Windows backslashes) - * - Uses case-insensitive matching on Windows platforms - * - Anchors patterns for exact matching (^ and $ boundaries) - * - * IMPLEMENTATION DETAILS: - * The function uses a placeholder technique to safely handle both `*` and `**`: - * 1. Replace `**` with unique placeholder to avoid conflicts - * 2. Replace remaining `*` with `[^/]*` (non-slash characters) - * 3. Replace placeholders with `.*` (any characters including slashes) - * - * This prevents the scenario where `**` -> `.*` -> `[^/]*[^/]*` (incorrect) - * and ensures `**` properly becomes `.*` (correct recursive match). - * - * @param pattern Glob pattern string to convert (e.g., "*.js", "src/**") - * @returns Compiled RegExp object ready for testing file paths - */ function globToRegex(pattern: string): RegExp { - // Step 1: Create a cache key that includes platform-specific flags - // This ensures Windows (case-insensitive) and Unix (case-sensitive) - // patterns are cached separately const cacheKey = `${pattern}:${process.platform}`; - // Step 2: Return cached regex if available (performance optimization) const cached = globRegexCache.get(cacheKey); if (cached) { return cached; } - // Step 3: Normalize the pattern for cross-platform consistency - // This handles Windows backslashes, trailing slashes, and case sensitivity const normalizedPattern = normalizeFilePath(pattern); - // Step 4: Escape special regex characters except for our glob wildcards - // Characters like ., +, ^, $, (, ), |, [, ], {, }, \ have special meaning in regex - // We escape them to treat them as literal characters, but preserve * for glob processing - let regexPattern = normalizedPattern.replace(/[.+^$()|[\]{}\\]/g, '\\$&'); // Escape regex special chars - - // Step 5: Handle glob patterns using placeholder technique to avoid conflicts - // CRITICAL: This order prevents `**` -> `.*` -> `[^/]*[^/]*` transformation + let regexPattern = normalizedPattern.replace(/[.+^$()|[\]{}\\]/g, '\\$&'); - // 5a: Replace `**` with a unique placeholder first - // This ensures recursive wildcards are processed separately from single wildcards regexPattern = regexPattern.replace(/\*\*/g, '___RECURSIVE_WILDCARD___'); - // 5b: Replace single `*` with character class (anything except path separator) - // `[^/]*` matches any characters except forward slash, restricting to single directory level regexPattern = regexPattern.replace(/\*/g, '[^/]*'); - // 5c: Replace the placeholder with proper recursive regex - // `.*` matches any characters including slashes, enabling multi-level directory matching regexPattern = regexPattern.replace(/___RECURSIVE_WILDCARD___/g, '.*'); - // Step 6: Anchor the pattern for exact matching - // Without anchors, pattern "test" would match "testing" (partial match) - // With anchors, pattern "test" only matches "test" exactly const anchoredPattern = '^' + regexPattern + '$'; - // Step 7: Create regex with appropriate flags based on platform - // Windows file systems are case-insensitive, Unix-like systems are case-sensitive - const flags = process.platform === 'win32' ? 'i' : ''; // Case-insensitive on Windows + const flags = process.platform === 'win32' ? 'i' : ''; const regex = new RegExp(anchoredPattern, flags); - // Step 8: Cache the compiled regex for future use - // Subsequent calls with the same pattern will return the cached version globRegexCache.set(cacheKey, regex); return regex; } /** - * Clear the glob regex cache - useful for testing or memory management - * Exported for testing purposes and potential memory cleanup + * Clear the glob regex cache for tests. */ export function clearGlobRegexCache(): void { globRegexCache.clear(); } /** - * Check if a file path matches any of the protected patterns - * - * This function is the core of the file protection system, determining whether - * a file should be protected from modification based on configured patterns. - * It's used by file protection hooks to prevent accidental edits to sensitive files. - * - * PROTECTION STRATEGY: - * The function implements a multi-layered protection approach: - * 1. Exact path matching (fastest, most precise) - * 2. Glob pattern matching (flexible, supports wildcards) - * 3. Directory prefix matching (protects entire directories) - * - * SUPPORTED PATTERN TYPES: - * - * 1. EXACT MATCHES: - * - Pattern: ".env" matches only ".env" exactly - * - Use for: Specific critical files - * - * 2. GLOB PATTERNS (contain * or **): - * - Pattern: "*.log" matches "app.log", "error.log" - * - Pattern: ".git/**​" matches all files in .git directory - * - Pattern: "**​/node_modules" matches node_modules at any depth - * - Use for: File extensions, recursive directory protection + * Return true when a path matches a protected pattern. * - * 3. DIRECTORY PREFIX MATCHING: - * - Pattern: ".git" matches ".git/config", ".git/objects/abc123" - * - Pattern: "node_modules" matches "node_modules/package/file.js" - * - Use for: Protecting entire directory trees without glob syntax + * Exact matches, glob patterns, and directory prefixes are checked in that + * order after path normalization. + * This is a string-pattern check, not filesystem access control, and it does + * not by itself prevent escapes from project bounds. * - * CROSS-PLATFORM FEATURES: - * - Handles Windows backslashes: "src\\file.txt" treated same as "src/file.txt" - * - Case sensitivity: Respects platform conventions (Windows=insensitive, Unix=sensitive) - * - Path normalization: Removes trailing slashes, handles path variations - * - * PERFORMANCE OPTIMIZATIONS: - * - Exact matches checked first (O(1) string comparison) - * - Regex compilation is cached to avoid recompilation overhead - * - Short-circuit evaluation stops at first match - * - * CONFIGURATION: - * Protected patterns come from environment variables or default configuration: - * - Default patterns: .env, .env.local, .git/**​, package-lock.json, yarn.lock - * - Configurable via: CLAUDE_HOOK_PROTECTED_FILES environment variable - * - * EXAMPLES: - * ```typescript - * // Exact matches - * isProtectedFile('.env') // true (matches exactly) - * isProtectedFile('.env.backup') // false (different file) - * - * // Glob patterns - * isProtectedFile('.git/config') // true (matches .git/**) - * isProtectedFile('logs/app.log') // depends on patterns - * - * // Directory prefix - * isProtectedFile('node_modules/pkg/file') // true (if node_modules protected) - * isProtectedFile('my_modules/file') // false (different prefix) - * - * // Cross-platform - * isProtectedFile('.git\\config') // true (Windows path normalized) - * isProtectedFile('.GIT/config') // true on Windows, false on Unix - * ``` - * - * @param filePath The file path to check for protection (can be relative or absolute) - * @returns true if the file matches any protected pattern, false otherwise + * @param filePath - Path to check against the configured protected patterns. + * @returns true when the path matches a protected pattern. */ export function isProtectedFile(filePath: string): boolean { const config = getConfig(); const protectedPatterns = config.rules?.protectedFiles ?? []; - // Step 1: Normalize the input file path for consistent cross-platform comparison - // This handles Windows backslashes, trailing slashes, and case sensitivity const normalizedFilePath = normalizeFilePath(filePath); - // Step 2: Check each protection pattern using short-circuit evaluation - // The function returns true immediately when the first match is found return protectedPatterns.some(pattern => { const normalizedPattern = normalizeFilePath(pattern); - // Strategy 1: Handle exact matches first (fastest check) - // This catches specific files like ".env", "package-lock.json" exactly if (normalizedPattern === normalizedFilePath) { return true; } - // Strategy 2: Handle glob patterns (contains wildcards) - // This processes patterns like "*.log", ".git/**", "**/node_modules" if (pattern.includes('*')) { const regex = globToRegex(pattern); return regex.test(normalizedFilePath); } - // Strategy 3: Handle directory prefix matching for non-glob patterns - // This protects entire directories: pattern ".git" protects ".git/config" - // Uses startsWith with '/' suffix to avoid false matches like ".gitignore" return ( normalizedFilePath === normalizedPattern || normalizedFilePath.startsWith(normalizedPattern + '/') @@ -573,8 +377,7 @@ export function isProtectedFile(filePath: string): boolean { } /** - * Check if a command contains dangerous patterns - * Used by bash validation hooks to warn about risky commands + * Return true when a command contains a dangerous pattern. */ export function isDangerousCommand(command: string): boolean { const config = getConfig(); @@ -586,8 +389,7 @@ export function isDangerousCommand(command: string): boolean { } /** - * Check if a file should be auto-formatted - * Based on file extension and configuration + * Return true when a file extension is configured for auto-formatting. */ export function shouldAutoFormat(filePath: string): boolean { const config = getConfig(); @@ -596,13 +398,13 @@ export function shouldAutoFormat(filePath: string): boolean { return formatExtensions.some(ext => filePath.endsWith(ext)); } -// ============================================================================= -// Validation Utilities -// ============================================================================= - /** - * Validate that required fields are present in hook input - * Throws an error if validation fails + * Validate that required fields are present in hook input. + * + * @param input - Hook input object to validate. + * @param requiredFields - Field names that must be present and non-null. + * @throws Error when any required field is missing, undefined, or null. + * @returns void */ export function validateRequiredFields( input: Record, @@ -620,16 +422,18 @@ export function validateRequiredFields( } /** - * Validate that a file path is safe and within project bounds - * Prevents path traversal attacks and operations outside project + * Validate that a file path is safe and within project bounds. + * + * @param filePath - File path to validate. + * @throws BlockingError when path traversal (`..`) is detected. + * Absolute paths outside `CLAUDE_PROJECT_DIR` are logged as a warning. + * @returns void */ export function validateFilePath(filePath: string): void { - // Check for path traversal attempts if (filePath.includes('..')) { throw createBlockingError('Path traversal detected in file path'); } - // Check for absolute paths outside project (optional safety check) const projectDir = getProjectDir(); if (filePath.startsWith('/') && !filePath.startsWith(projectDir)) { logWarning( @@ -639,14 +443,16 @@ export function validateFilePath(filePath: string): void { } /** - * Sanitize user input for shell commands - * Basic protection against command injection + * Strip shell metacharacters used by simple hooks. + * + * @param command - Command string to sanitize. + * @returns command with shell metacharacters stripped. + * Removes backticks, `$`, and parentheses, collapses repeated semicolons, and + * trims whitespace. This is not a full shell-injection sanitizer. */ export function sanitizeCommand(command: string): string { - // Remove or escape potentially dangerous characters - // This is basic sanitization - for production use, consider more robust solutions return command - .replace(/[`$()]/g, '') // Remove backticks, dollar signs, parentheses - .replace(/;+/g, ';') // Collapse multiple semicolons + .replace(/[`$()]/g, '') + .replace(/;+/g, ';') .trim(); } diff --git a/src/utils/output-builder.ts b/src/utils/output-builder.ts index faf23bf..492b889 100644 --- a/src/utils/output-builder.ts +++ b/src/utils/output-builder.ts @@ -1,14 +1,13 @@ /** - * Standardized hook output builder utilities - * - * Provides convenience methods for creating properly structured - * hook output objects for all hook event types. + * Builders for hook output JSON returned on stdout by Claude Code hook handlers. + * Each helper returns an event-specific output shape without performing I/O. */ import type { BaseHookOutput, ElicitationAction, ElicitationOutput, + MessageDisplayOutput, PermissionDeniedOutput, PermissionMode, PermissionUpdateEntry, @@ -17,6 +16,7 @@ import type { PostToolBatchOutput, PermissionRequestOutput, SubagentStartOutput, + SetupOutput, SessionStartOutput, StopOutput, UserPromptSubmitOutput, @@ -24,6 +24,47 @@ import type { WorktreeCreateOutput, } from '../types/index.js'; +type SessionStartContextOptions = { + context?: string; + initialUserMessage?: string; + sessionTitle?: string; + watchPaths?: string[]; + reloadSkills?: boolean; +}; + +function buildSessionStartContext(context: string): SessionStartOutput; +function buildSessionStartContext( + options: SessionStartContextOptions +): SessionStartOutput; +function buildSessionStartContext( + contextOrOptions: string | SessionStartContextOptions +): SessionStartOutput { + return { + hookSpecificOutput: { + hookEventName: 'SessionStart', + ...(typeof contextOrOptions === 'string' + ? { additionalContext: contextOrOptions } + : { + ...(contextOrOptions.context && { + additionalContext: contextOrOptions.context, + }), + ...(contextOrOptions.initialUserMessage && { + initialUserMessage: contextOrOptions.initialUserMessage, + }), + ...(contextOrOptions.sessionTitle && { + sessionTitle: contextOrOptions.sessionTitle, + }), + ...(contextOrOptions.watchPaths && { + watchPaths: contextOrOptions.watchPaths, + }), + ...(contextOrOptions.reloadSkills !== undefined && { + reloadSkills: contextOrOptions.reloadSkills, + }), + }), + }, + }; +} + type LifecycleStopOutput = BaseHookOutput & { hookSpecificOutput: { hookEventName: 'TaskCreated' | 'TaskCompleted' | 'TeammateIdle'; @@ -31,17 +72,20 @@ type LifecycleStopOutput = BaseHookOutput & { }; export const HookOutputBuilder = { + /** Build a successful generic hook output with optional user-visible text. */ success: (message?: string): BaseHookOutput => ({ suppressOutput: !message, ...(message && { systemMessage: message }), }), + /** Build a generic error output, optionally stopping execution. */ error: (reason: string, stopExecution = false): BaseHookOutput => ({ continue: !stopExecution, ...(stopExecution && { stopReason: reason }), systemMessage: reason, }), + /** Build a PreToolUse permission decision. */ permission: ( decision: 'allow' | 'deny' | 'ask' | 'defer', reason: string, @@ -61,10 +105,12 @@ export const HookOutputBuilder = { }, }), + /** Build PostToolUse feedback for Claude and optional tool-output replacements. */ feedback: ( reason: string, additionalContext?: string, - updatedMCPToolOutput?: Record + updatedMCPToolOutput?: Record, + updatedToolOutput?: unknown ): PostToolUseOutput => ({ decision: 'block', reason, @@ -72,9 +118,11 @@ export const HookOutputBuilder = { hookEventName: 'PostToolUse', ...(additionalContext && { additionalContext }), ...(updatedMCPToolOutput && { updatedMCPToolOutput }), + ...(updatedToolOutput !== undefined && { updatedToolOutput }), }, }), + /** Build a PermissionRequest allow decision. */ allowPermission: (options?: { updatedInput?: Record; updatedPermissions?: PermissionUpdateEntry[]; @@ -91,6 +139,7 @@ export const HookOutputBuilder = { }, }), + /** Build a PermissionRequest deny decision. */ denyPermission: (options?: { message?: string; interrupt?: boolean; @@ -107,6 +156,7 @@ export const HookOutputBuilder = { }, }), + /** Build a PermissionRequest allow decision that changes the permission mode. */ permissionRequestSetMode: ( mode: PermissionMode, destination: @@ -119,6 +169,7 @@ export const HookOutputBuilder = { updatedPermissions: [{ type: 'setMode', mode, destination }], }), + /** Build PermissionDenied retry guidance. */ permissionDeniedRetry: (retry: boolean): PermissionDeniedOutput => ({ hookSpecificOutput: { hookEventName: 'PermissionDenied', @@ -126,6 +177,7 @@ export const HookOutputBuilder = { }, }), + /** Build an Elicitation or ElicitationResult action response. */ elicitation: ( action: ElicitationAction, content?: Record, @@ -138,10 +190,12 @@ export const HookOutputBuilder = { }, }), + /** Build watched-path updates for CwdChanged or FileChanged hooks. */ watchPaths: (paths: string[]): WatchPathsOutput => ({ watchPaths: paths, }), + /** Build a WorktreeCreate output with the created worktree path. */ worktreePath: (absolutePath: string): WorktreeCreateOutput => ({ hookSpecificOutput: { hookEventName: 'WorktreeCreate', @@ -149,6 +203,7 @@ export const HookOutputBuilder = { }, }), + /** Build a task lifecycle block response. */ taskBlock: ( reason: string, hookEventName: 'TaskCreated' | 'TaskCompleted' = 'TaskCompleted' @@ -160,6 +215,7 @@ export const HookOutputBuilder = { }, }), + /** Build a TeammateIdle stop response. */ teammateStop: (reason: string): LifecycleStopOutput => ({ continue: false, stopReason: reason, @@ -168,6 +224,7 @@ export const HookOutputBuilder = { }, }), + /** Build a PostToolBatch block response. */ batchBlock: (reason: string): PostToolBatchOutput => ({ decision: 'block', reason, @@ -177,6 +234,7 @@ export const HookOutputBuilder = { }, }), + /** Build SubagentStart context injection. */ subagentContext: (context: string): SubagentStartOutput => ({ hookSpecificOutput: { hookEventName: 'SubagentStart', @@ -184,13 +242,26 @@ export const HookOutputBuilder = { }, }), - sessionStartContext: (context: string): SessionStartOutput => ({ + /** Build Setup context injection. */ + setupContext: (context: string): SetupOutput => ({ hookSpecificOutput: { - hookEventName: 'SessionStart', + hookEventName: 'Setup', additionalContext: context, }, }), + /** Build MessageDisplay replacement content. */ + messageDisplayContent: (content: string): MessageDisplayOutput => ({ + hookSpecificOutput: { + hookEventName: 'MessageDisplay', + displayContent: content, + }, + }), + + /** Build SessionStart context, title, initial message, watch paths, or skill reload output. */ + sessionStartContext: buildSessionStartContext, + + /** Build UserPromptSubmit context injection. */ addContext: (context: string): UserPromptSubmitOutput => ({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', @@ -198,6 +269,7 @@ export const HookOutputBuilder = { }, }), + /** Build a UserPromptSubmit session-title update. */ sessionTitle: (title: string): UserPromptSubmitOutput => ({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', @@ -205,16 +277,19 @@ export const HookOutputBuilder = { }, }), + /** Build a UserPromptSubmit prompt block. */ blockPrompt: (reason: string): UserPromptSubmitOutput => ({ decision: 'block', reason, }), + /** Build a SubagentStop block with continuation context. */ subagentStopContext: (reason: string): StopOutput => ({ decision: 'block', reason, }), + /** Build StopFailure observability output. */ stopFailureLog: (systemMessage?: string): BaseHookOutput => HookOutputBuilder.success(systemMessage), }; diff --git a/src/validation/index.ts b/src/validation/index.ts index 003f4d9..9820f34 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,11 +1,6 @@ /** - * Zod-based validation schemas for Claude Code hooks - * - * Following a schema-first approach: - * 1. Define Zod schema - * 2. Infer TypeScript types with z.infer - * 3. Use .safeParse() at all system boundaries - * 4. Never bypass runtime validation + * Public validation barrel for hook schemas, validators, type guards, and inferred schema types. + * Consumers import from this module to validate hook JSON and tool inputs at runtime boundaries. */ export { @@ -85,6 +80,7 @@ export { toolUseContentBlockSchema, toolResultContentBlockSchema, thinkingContentBlockSchema, + imageContentBlockSchema, contentBlockSchema, rawUserMessageSchema, rawAssistantMessageSchema, @@ -145,9 +141,11 @@ export { isPostToolBatchInput, isUserPromptSubmitInput, isUserPromptExpansionInput, + isSetupInput, isSessionStartInput, isSessionEndInput, isNotificationInput, + isMessageDisplayInput, isStopInput, isStopFailureInput, isSubagentStartInput, @@ -169,6 +167,7 @@ export { // Hook-type-specific validators validatePreToolUseInput, validatePostToolUseInput, + validateSetupInput, validateUserPromptExpansionInput, validatePermissionDeniedInput, validatePostToolBatchInput, @@ -181,6 +180,7 @@ export { validateWorktreeCreateInput, validateWorktreeRemoveInput, validatePostCompactInput, + validateMessageDisplayInput, validateElicitationInput, validateElicitationResultInput, // Content validators @@ -206,9 +206,11 @@ export type { PostToolBatchInputSchema, UserPromptSubmitInputSchema, UserPromptExpansionInputSchema, + SetupInputSchema, SessionStartInputSchema, SessionEndInputSchema, NotificationInputSchema, + MessageDisplayInputSchema, StopInputSchema, StopFailureInputSchema, SubagentStartInputSchema, @@ -230,6 +232,7 @@ export type { BaseHookOutputSchema, PreToolUseOutputSchema, PostToolUseOutputSchema, + SetupOutputSchema, PermissionRequestOutputSchema, PermissionDeniedOutputSchema, PostToolUseFailureOutputSchema, @@ -240,6 +243,7 @@ export type { SubagentStartOutputSchema, SessionStartOutputSchema, NotificationOutputSchema, + MessageDisplayOutputSchema, PreCompactOutputSchema, ConfigChangeOutputSchema, WatchPathsOutputSchema, @@ -269,6 +273,7 @@ export type { ToolUseContentBlockSchema, ToolResultContentBlockSchema, ThinkingContentBlockSchema, + ImageContentBlockSchema, ContentBlockSchema, RawUserMessageSchema, RawAssistantMessageSchema, diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index c0e1934..00f3d46 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -1,23 +1,10 @@ /** - * Zod schemas for Claude Code hooks - * - * These schemas provide runtime validation for hook data contracts. - * The library maintains a dual type system: manual TypeScript interfaces - * in `src/types/index.ts` AND Zod-inferred types here. Both must stay in sync. - * - * Principles: - * - Runtime validation at all boundaries - * - No `any` types ever - * - Use .safeParse() for validation - * - Infer TypeScript types from schemas via z.infer + * Zod schemas for Claude Code hook input, output, settings, and transcript contracts. + * These schemas validate JSON boundaries and stay aligned with `src/types/index.ts`. */ import { z } from 'zod'; -// ============================================================================= -// Shared Schemas -// ============================================================================= - export const permissionModeSchema = z.enum([ 'default', 'plan', @@ -35,9 +22,12 @@ export const elicitationModeSchema = z.enum(['form', 'url']); export const stopFailureErrorSchema = z.enum([ 'rate_limit', + 'overloaded', 'authentication_failed', + 'oauth_org_not_allowed', 'billing_error', 'invalid_request', + 'model_not_found', 'server_error', 'max_output_tokens', 'unknown', @@ -82,10 +72,6 @@ const taskLifecycleFields = { team_name: z.string().optional(), }; -// ============================================================================= -// Base Hook Schemas -// ============================================================================= - /** * Base schema for all hook inputs - common fields present in every hook */ @@ -104,10 +90,16 @@ export const baseHookInputSchema = z.object({ agent_id: z.string().optional(), /** Agent name when running under --agent or inside a subagent */ agent_type: z.string().optional(), + /** Effort metadata for the current turn, when provided by Claude Code */ + effort: z + .object({ + level: z.enum(['low', 'medium', 'high', 'xhigh', 'max']), + }) + .optional(), }); /** - * Base schema for all hook outputs - common fields any hook can return + * Base schema for all hook outputs - common fields hooks can return */ export const baseHookOutputSchema = z.object({ /** Whether Claude should continue after hook execution (default: true) */ @@ -118,11 +110,31 @@ export const baseHookOutputSchema = z.object({ suppressOutput: z.boolean().optional(), /** Optional warning message shown to the user */ systemMessage: z.string().optional(), + /** ANSI escape sequences or similar terminal control output */ + terminalSequence: z.string().optional(), +}); + +/** + * Schema for Setup hook inputs + */ +export const setupInputSchema = baseHookInputSchema.extend({ + hook_event_name: z.literal('Setup'), + /** How setup was triggered */ + trigger: z.enum(['init', 'maintenance']), }); -// ============================================================================= -// Hook Event Specific Schemas -// ============================================================================= +/** + * Schema for Setup hook outputs + */ +export const setupOutputSchema = baseHookOutputSchema.extend({ + hookSpecificOutput: z + .object({ + hookEventName: z.literal('Setup'), + /** String added to setup context */ + additionalContext: z.string().optional(), + }) + .optional(), +}); /** * Schema for PreToolUse hook inputs @@ -131,7 +143,7 @@ export const preToolUseInputSchema = baseHookInputSchema.extend({ hook_event_name: z.literal('PreToolUse'), /** Name of the tool about to be executed */ tool_name: z.string().min(1), - /** Parameters that will be passed to the tool - kept as Record for flexibility */ + /** Parameters passed to the tool; kept as Record for flexibility. */ tool_input: z.record(z.string(), z.unknown()), /** Unique identifier for this tool use */ tool_use_id: z.string().min(1), @@ -150,6 +162,8 @@ export const postToolUseInputSchema = baseHookInputSchema.extend({ tool_response: z.record(z.string(), z.unknown()), /** Unique identifier for this tool use */ tool_use_id: z.string().min(1), + /** Tool execution duration in milliseconds */ + duration_ms: z.number().optional(), }); /** @@ -186,7 +200,9 @@ export const sessionStartInputSchema = baseHookInputSchema.extend({ /** How the session was started */ source: z.enum(['startup', 'resume', 'clear', 'compact']), /** The model identifier */ - model: z.string(), + model: z.string().optional(), + /** Session title when one is already known */ + session_title: z.string().optional(), /** Agent name if started with --agent */ agent_type: z.string().optional(), }); @@ -222,9 +238,41 @@ export const notificationInputSchema = baseHookInputSchema.extend({ 'idle_prompt', 'auth_success', 'elicitation_dialog', + 'elicitation_complete', + 'elicitation_response', ]), }); +/** + * Schema for MessageDisplay hook inputs + */ +export const messageDisplayInputSchema = baseHookInputSchema.extend({ + hook_event_name: z.literal('MessageDisplay'), + /** Unique identifier for the current turn */ + turn_id: z.string().uuid(), + /** Unique identifier for the message being displayed */ + message_id: z.string().uuid(), + /** Zero-based chunk index for this display delta */ + index: z.number().int().nonnegative(), + /** Whether this is the final chunk */ + final: z.boolean(), + /** Delta text being displayed */ + delta: z.string(), +}); + +/** + * Schema for MessageDisplay hook outputs + */ +export const messageDisplayOutputSchema = baseHookOutputSchema.extend({ + hookSpecificOutput: z + .object({ + hookEventName: z.literal('MessageDisplay'), + /** Optional replacement content for display */ + displayContent: z.string().optional(), + }) + .optional(), +}); + /** * Schema for Stop hook inputs */ @@ -319,6 +367,8 @@ export const postToolUseFailureInputSchema = baseHookInputSchema.extend({ error: z.string(), /** Whether the failure was caused by user interruption */ is_interrupt: z.boolean().optional(), + /** Tool execution duration in milliseconds */ + duration_ms: z.number().optional(), }); const postToolBatchResponseSchema = z.union([ @@ -434,7 +484,7 @@ export const cwdChangedInputSchema = baseHookInputSchema.extend({ hook_event_name: z.literal('CwdChanged'), /** Previous working directory */ old_cwd: z.string().min(1), - /** New working directory */ + /** Working directory after the change. */ new_cwd: z.string().min(1), }); @@ -454,7 +504,7 @@ export const fileChangedInputSchema = baseHookInputSchema.extend({ */ export const worktreeCreateInputSchema = baseHookInputSchema.extend({ hook_event_name: z.literal('WorktreeCreate'), - /** Slug identifier for the new worktree */ + /** Slug identifier for the worktree being created. */ name: z.string().min(1), }); @@ -519,19 +569,15 @@ export const elicitationResultInputSchema = baseHookInputSchema.extend({ elicitation_id: z.string().optional(), }); -// ============================================================================= -// Hook Output Schemas -// ============================================================================= - /** * Schema for PreToolUse hook outputs - controls permission */ export const preToolUseOutputSchema = baseHookOutputSchema.extend({ - /** Legacy fields - deprecated but maintained for compatibility */ + /** Deprecated compatibility fields. */ decision: z.enum(['approve', 'block']).optional(), reason: z.string().optional(), - /** Modern hook-specific output format */ + /** Structured hook-specific output. */ hookSpecificOutput: z .object({ hookEventName: z.literal('PreToolUse'), @@ -551,19 +597,21 @@ export const preToolUseOutputSchema = baseHookOutputSchema.extend({ * Schema for PostToolUse hook outputs - provides feedback */ export const postToolUseOutputSchema = baseHookOutputSchema.extend({ - /** Legacy decision field */ + /** Deprecated compatibility decision field. */ decision: z.enum(['block']).optional(), /** Explanation for the decision */ reason: z.string().optional(), - /** Modern hook-specific output */ + /** Structured hook-specific output. */ hookSpecificOutput: z .object({ hookEventName: z.literal('PostToolUse'), /** Additional information for Claude to consider */ additionalContext: z.string().optional(), /** For MCP tools only: replaces the tool's output with the provided value */ - updatedMCPToolOutput: z.record(z.string(), z.unknown()).optional(), + updatedMCPToolOutput: z.unknown().optional(), + /** Replaces the tool output with the provided value */ + updatedToolOutput: z.unknown().optional(), }) .optional(), }); @@ -624,6 +672,14 @@ export const sessionStartOutputSchema = baseHookOutputSchema.extend({ hookEventName: z.literal('SessionStart'), /** String added to the context at session start */ additionalContext: z.string().optional(), + /** Initial user-visible message to seed the session */ + initialUserMessage: z.string().optional(), + /** Sets the session title */ + sessionTitle: z.string().optional(), + /** Dynamic absolute paths to watch */ + watchPaths: z.array(z.string()).optional(), + /** Reload active skills after session setup */ + reloadSkills: z.boolean().optional(), }) .optional(), }); @@ -794,10 +850,6 @@ export const elicitationResultOutputSchema = baseHookOutputSchema.extend({ .optional(), }); -// ============================================================================= -// Tool Input Schemas -// ============================================================================= - /** * Schema for Bash tool inputs */ @@ -995,15 +1047,12 @@ export const multiEditToolInputSchema = z.object({ ), }); -// ============================================================================= -// Schema Collections & Type Exports -// ============================================================================= - /** * Collection of all hook input schemas by event type */ export const hookInputSchemas = { SessionStart: sessionStartInputSchema, + Setup: setupInputSchema, UserPromptSubmit: userPromptSubmitInputSchema, UserPromptExpansion: userPromptExpansionInputSchema, PreToolUse: preToolUseInputSchema, @@ -1013,6 +1062,7 @@ export const hookInputSchemas = { PostToolUseFailure: postToolUseFailureInputSchema, PostToolBatch: postToolBatchInputSchema, Notification: notificationInputSchema, + MessageDisplay: messageDisplayInputSchema, SubagentStart: subagentStartInputSchema, SubagentStop: subagentStopInputSchema, TaskCreated: taskCreatedInputSchema, @@ -1038,6 +1088,7 @@ export const hookInputSchemas = { */ export const hookOutputSchemas = { SessionStart: sessionStartOutputSchema, + Setup: setupOutputSchema, UserPromptSubmit: userPromptSubmitOutputSchema, UserPromptExpansion: userPromptExpansionOutputSchema, PreToolUse: preToolUseOutputSchema, @@ -1047,6 +1098,7 @@ export const hookOutputSchemas = { PostToolUseFailure: postToolUseFailureOutputSchema, PostToolBatch: postToolBatchOutputSchema, Notification: notificationOutputSchema, + MessageDisplay: messageDisplayOutputSchema, SubagentStart: subagentStartOutputSchema, SubagentStop: stopOutputSchema, TaskCreated: baseHookOutputSchema, @@ -1087,10 +1139,6 @@ export const toolInputSchemas = { Task: taskToolInputSchema, } as const; -// ============================================================================= -// Hook Configuration Schemas (settings.json) -// ============================================================================= - /** * Common fields shared by all hook handler types. * Spread into each handler schema (not a base schema, since @@ -1114,6 +1162,7 @@ export const commandHookHandlerSchema = z.object({ type: z.literal('command'), /** Shell command to execute */ command: z.string().min(1), + args: z.array(z.string()).optional(), /** If true, runs in the background without blocking. Only for command hooks */ async: z.boolean().optional(), /** If true, runs in the background and wakes Claude on exit code 2 */ @@ -1176,7 +1225,7 @@ export const agentHookHandlerSchema = z.object({ }); /** - * Union schema for any hook handler, discriminated on the `type` field + * Union schema for hook handlers, discriminated on the `type` field */ export const hookHandlerSchema = z.discriminatedUnion('type', [ commandHookHandlerSchema, @@ -1197,10 +1246,11 @@ export const matcherGroupSchema = z.object({ }); /** - * All 28 hook event names as a Zod enum + * All supported hook event names as a Zod enum */ export const hookEventNameSchema = z.enum([ 'SessionStart', + 'Setup', 'UserPromptSubmit', 'UserPromptExpansion', 'PreToolUse', @@ -1210,6 +1260,7 @@ export const hookEventNameSchema = z.enum([ 'PostToolUseFailure', 'PostToolBatch', 'Notification', + 'MessageDisplay', 'SubagentStart', 'SubagentStop', 'TaskCreated', @@ -1248,10 +1299,6 @@ export const hooksConfigSchema = z.object({ httpHookAllowedEnvVars: z.array(z.string()).optional(), }); -// ============================================================================= -// Transcript Parsing Schemas -// ============================================================================= - export const rawTranscriptPayloadMetadataSchema = z.looseObject({ type: z.string().optional(), sessionId: z.string().optional(), @@ -1292,11 +1339,17 @@ export const thinkingContentBlockSchema = z.looseObject({ thinking: z.string(), }); +export const imageContentBlockSchema = z.looseObject({ + type: z.literal('image'), + source: z.unknown().optional(), +}); + export const contentBlockSchema = z.discriminatedUnion('type', [ textContentBlockSchema, toolUseContentBlockSchema, toolResultContentBlockSchema, thinkingContentBlockSchema, + imageContentBlockSchema, ]); const rawMessageFields = { @@ -1397,10 +1450,6 @@ export const transcriptParseDiagnosticsSchema = z.looseObject({ issues: z.array(transcriptParseIssueSchema), }); -// ============================================================================= -// Inferred TypeScript Types (Schema-First Approach) -// ============================================================================= - // Base types export type BaseHookInputSchema = z.infer; export type BaseHookOutputSchema = z.infer; @@ -1408,6 +1457,7 @@ export type BaseHookOutputSchema = z.infer; // Hook event types export type PreToolUseInputSchema = z.infer; export type PostToolUseInputSchema = z.infer; +export type SetupInputSchema = z.infer; export type UserPromptSubmitInputSchema = z.infer< typeof userPromptSubmitInputSchema >; @@ -1417,6 +1467,9 @@ export type UserPromptExpansionInputSchema = z.infer< export type SessionStartInputSchema = z.infer; export type SessionEndInputSchema = z.infer; export type NotificationInputSchema = z.infer; +export type MessageDisplayInputSchema = z.infer< + typeof messageDisplayInputSchema +>; export type StopInputSchema = z.infer; export type StopFailureInputSchema = z.infer; export type SubagentStopInputSchema = z.infer; @@ -1456,6 +1509,7 @@ export type ElicitationResultInputSchema = z.infer< // Hook output types export type PreToolUseOutputSchema = z.infer; export type PostToolUseOutputSchema = z.infer; +export type SetupOutputSchema = z.infer; export type UserPromptSubmitOutputSchema = z.infer< typeof userPromptSubmitOutputSchema >; @@ -1465,6 +1519,9 @@ export type UserPromptExpansionOutputSchema = z.infer< export type StopOutputSchema = z.infer; export type SessionStartOutputSchema = z.infer; export type NotificationOutputSchema = z.infer; +export type MessageDisplayOutputSchema = z.infer< + typeof messageDisplayOutputSchema +>; export type PermissionRequestOutputSchema = z.infer< typeof permissionRequestOutputSchema >; @@ -1529,6 +1586,7 @@ export type ToolResultContentBlockSchema = z.infer< export type ThinkingContentBlockSchema = z.infer< typeof thinkingContentBlockSchema >; +export type ImageContentBlockSchema = z.infer; export type ContentBlockSchema = z.infer; export type RawUserMessageSchema = z.infer; export type RawAssistantMessageSchema = z.infer< @@ -1563,6 +1621,7 @@ export type TranscriptParseDiagnosticsSchema = z.infer< export type HookInputSchema = | PreToolUseInputSchema | PostToolUseInputSchema + | SetupInputSchema | PermissionRequestInputSchema | PermissionDeniedInputSchema | PostToolUseFailureInputSchema @@ -1572,6 +1631,7 @@ export type HookInputSchema = | SessionStartInputSchema | SessionEndInputSchema | NotificationInputSchema + | MessageDisplayInputSchema | StopInputSchema | StopFailureInputSchema | SubagentStartInputSchema diff --git a/src/validation/validators.ts b/src/validation/validators.ts index 38bdf2c..1a7a4d0 100644 --- a/src/validation/validators.ts +++ b/src/validation/validators.ts @@ -1,13 +1,6 @@ /** - * Zod-based validators for Claude Code hooks - * - * CRITICAL: Always use .safeParse() at system boundaries - * Never bypass runtime validation - this prevents silent data corruption - * - * Following established patterns: - * - Two-step type assertion: unknown → validate → assert - * - Custom error types for better debugging - * - Never use 'any' types + * Runtime validators and type guards for hook inputs, tool inputs, settings, and transcript records. + * Public validators accept unknown JSON boundary data and return typed values or HookValidationError. */ import type { z } from 'zod'; @@ -30,9 +23,11 @@ import { type PostToolBatchInputSchema, type UserPromptSubmitInputSchema, type UserPromptExpansionInputSchema, + type SetupInputSchema, type SessionStartInputSchema, type SessionEndInputSchema, type NotificationInputSchema, + type MessageDisplayInputSchema, type StopInputSchema, type StopFailureInputSchema, type SubagentStartInputSchema, @@ -68,14 +63,7 @@ type ToolBearingHookInput = const MCP_TOOL_NAME_PATTERN = /^mcp__[^_]+__[^_]+/; -// ============================================================================= -// Custom Error Types -// ============================================================================= - -/** - * Custom error class for hook validation failures - * Provides structured error information for debugging - */ +/** Validation error with a stable code, context object, and optional Zod error details. */ export class HookValidationError extends Error { public readonly code: string; public readonly context: Record; @@ -101,9 +89,7 @@ export class HookValidationError extends Error { } } - /** - * Create a detailed error message including Zod validation details - */ + /** Return a detailed message with Zod issues and validation context. */ public getDetailedMessage(): string { let message = `${this.message} (Code: ${this.code})`; @@ -126,10 +112,6 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } -// ============================================================================= -// Hook Input Validation -// ============================================================================= - /** * Validate hook input using the two-step type assertion pattern * @@ -138,7 +120,6 @@ function isRecord(value: unknown): value is Record { * @throws HookValidationError if validation fails */ export function validateHookInput(input: unknown): HookInputSchema { - // Step 1: Basic structure validation if (!isRecord(input)) { throw new HookValidationError( 'Hook input must be a non-null object', @@ -147,7 +128,6 @@ export function validateHookInput(input: unknown): HookInputSchema { ); } - // Step 2: Extract hook event name const hookEventName = input['hook_event_name']; if ( hookEventName === null || @@ -161,7 +141,13 @@ export function validateHookInput(input: unknown): HookInputSchema { ); } - // Step 3: Get appropriate schema + return validateHookInputByEventName(input, hookEventName); +} + +function validateHookInputByEventName( + input: unknown, + hookEventName: string +): HookInputSchema { const schema = ( hookInputSchemas as Record< string, @@ -176,7 +162,6 @@ export function validateHookInput(input: unknown): HookInputSchema { ); } - // Step 4: Validate using Zod schema const result = schema.safeParse(input); if (!result.success) { throw new HookValidationError( @@ -202,7 +187,6 @@ export function validateToolInput( ): ToolInputSchema { const toolName = hookInput.tool_name; - // Get appropriate schema for the tool const schema = ( toolInputSchemas as Record< string, @@ -231,7 +215,6 @@ export function validateToolInput( ); } - // Validate tool input using Zod schema const result = schema.safeParse(hookInput.tool_input); if (!result.success) { throw new HookValidationError( @@ -245,13 +228,7 @@ export function validateToolInput( return result.data; } -// ============================================================================= -// Specific Tool Validators (Convenience Functions) -// ============================================================================= - -/** - * Validate and extract Bash tool input with proper typing - */ +/** Validate and extract Bash tool input. */ export function validateBashToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -276,9 +253,7 @@ export function validateBashToolInput( return result.data; } -/** - * Validate and extract Write tool input with proper typing - */ +/** Validate and extract Write tool input. */ export function validateWriteToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -303,9 +278,7 @@ export function validateWriteToolInput( return result.data; } -/** - * Validate and extract Edit tool input with proper typing - */ +/** Validate and extract Edit tool input. */ export function validateEditToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -330,9 +303,7 @@ export function validateEditToolInput( return result.data; } -/** - * Validate and extract Read tool input with proper typing - */ +/** Validate and extract Read tool input. */ export function validateReadToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -357,9 +328,7 @@ export function validateReadToolInput( return result.data; } -/** - * Validate and extract WebFetch tool input with proper typing - */ +/** Validate and extract WebFetch tool input. */ export function validateWebFetchToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -384,9 +353,7 @@ export function validateWebFetchToolInput( return result.data; } -/** - * Validate and extract WebSearch tool input with proper typing - */ +/** Validate and extract WebSearch tool input. */ export function validateWebSearchToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -411,9 +378,7 @@ export function validateWebSearchToolInput( return result.data; } -/** - * Validate and extract Glob tool input with proper typing - */ +/** Validate and extract Glob tool input. */ export function validateGlobToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -438,9 +403,7 @@ export function validateGlobToolInput( return result.data; } -/** - * Validate and extract Grep tool input with proper typing - */ +/** Validate and extract Grep tool input. */ export function validateGrepToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -465,9 +428,7 @@ export function validateGrepToolInput( return result.data; } -/** - * Validate and extract MultiEdit tool input with proper typing - */ +/** Validate and extract MultiEdit tool input. */ export function validateMultiEditToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -492,9 +453,7 @@ export function validateMultiEditToolInput( return result.data; } -/** - * Validate and extract Task tool input with proper typing - */ +/** Validate and extract Task tool input. */ export function validateTaskToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -519,9 +478,7 @@ export function validateTaskToolInput( return result.data; } -/** - * Validate and extract Agent tool input with proper typing - */ +/** Validate and extract Agent tool input. */ export function validateAgentToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -546,9 +503,7 @@ export function validateAgentToolInput( return result.data; } -/** - * Validate and extract AskUserQuestion tool input with proper typing - */ +/** Validate and extract AskUserQuestion tool input. */ export function validateAskUserQuestionToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -575,9 +530,7 @@ export function validateAskUserQuestionToolInput( return result.data; } -/** - * Validate and extract ExitPlanMode tool input with proper typing - */ +/** Validate and extract ExitPlanMode tool input. */ export function validateExitPlanModeToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -602,9 +555,7 @@ export function validateExitPlanModeToolInput( return result.data; } -/** - * Validate and extract TodoWrite tool input with proper typing - */ +/** Validate and extract TodoWrite tool input. */ export function validateTodoWriteToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -629,9 +580,7 @@ export function validateTodoWriteToolInput( return result.data; } -/** - * Validate and extract generic MCP tool input with proper typing - */ +/** Validate and extract generic MCP tool input. */ export function validateMCPToolInput( hookInput: ToolBearingHookInput ): z.infer { @@ -656,10 +605,6 @@ export function validateMCPToolInput( return result.data; } -// ============================================================================= -// Transcript Validators -// ============================================================================= - export type SafeTranscriptValidationResult = | { success: true; data: T } | { success: false; diagnostics: TranscriptParseDiagnosticsSchema }; @@ -698,7 +643,7 @@ function flattenTranscriptIssues( ) { if (issue.errors.length === 0) { // No nested branch errors to recurse into (e.g. a discriminated-union - // drop where the discriminator value matched no member). Fold any + // drop where the discriminator value matched no member). Fold the // `discriminator`/`note` the issue carries into the message so the // diagnostic explains *why* an unknown line type was dropped instead // of emitting a bare 'Invalid input'. @@ -737,16 +682,14 @@ function readIssueString( issue: z.ZodIssue, key: 'discriminator' | 'note' ): string | undefined { - if (!(key in issue)) return undefined; - const view = issue as unknown; - if (!isRecord(view)) return undefined; - const value = view[key]; + if (!isRecord(issue)) return undefined; + const value = issue[key]; return typeof value === 'string' && value.length > 0 ? value : undefined; } /** * Build a self-explanatory message for an `invalid_union` issue that carries no - * nested branch errors, folding in any `discriminator` and/or `note` so the + * nested branch errors, folding in optional `discriminator` and/or `note` so the * diagnostic names the offending value instead of a bare 'Invalid input'. */ function augmentUnionMessage(issue: z.ZodIssue): string { @@ -827,10 +770,6 @@ export function validateRawHistoryLine(input: unknown): RawHistoryLineSchema { return result.data; } -// ============================================================================= -// Utility Functions -// ============================================================================= - /** * Safe validation that returns success/error result instead of throwing * Useful for hooks that want to handle validation errors gracefully @@ -915,6 +854,12 @@ export function isUserPromptExpansionInput( return input.hook_event_name === 'UserPromptExpansion'; } +export function isSetupInput( + input: HookInputSchema +): input is SetupInputSchema { + return input.hook_event_name === 'Setup'; +} + /** * Type guard for SessionStart hook input */ @@ -942,6 +887,12 @@ export function isNotificationInput( return input.hook_event_name === 'Notification'; } +export function isMessageDisplayInput( + input: HookInputSchema +): input is MessageDisplayInputSchema { + return input.hook_event_name === 'MessageDisplay'; +} + /** * Type guard for Stop hook input */ @@ -1111,10 +1062,6 @@ export function isElicitationResultInput( return input.hook_event_name === 'ElicitationResult'; } -// ============================================================================= -// Hook-Type-Specific Validators -// ============================================================================= - /** * Validate that the input is a valid PreToolUse hook * Uses Zod validation internally with better error messages @@ -1153,6 +1100,10 @@ export function validatePostToolUseInput( return validated; } +function validateHookInputType( + input: unknown, + eventName: 'Setup' +): SetupInputSchema; function validateHookInputType( input: unknown, eventName: 'UserPromptExpansion' @@ -1205,6 +1156,10 @@ function validateHookInputType( input: unknown, eventName: 'Elicitation' ): ElicitationInputSchema; +function validateHookInputType( + input: unknown, + eventName: 'MessageDisplay' +): MessageDisplayInputSchema; function validateHookInputType( input: unknown, eventName: 'ElicitationResult' @@ -1213,7 +1168,7 @@ function validateHookInputType( input: unknown, eventName: string ): HookInputSchema { - const validated = validateHookInput(input); + const validated = validateHookInputByEventName(input, eventName); if (validated.hook_event_name !== eventName) { throw new HookValidationError( @@ -1226,6 +1181,10 @@ function validateHookInputType( return validated; } +export function validateSetupInput(input: unknown): SetupInputSchema { + return validateHookInputType(input, 'Setup'); +} + export function validateUserPromptExpansionInput( input: unknown ): UserPromptExpansionInputSchema { @@ -1296,6 +1255,12 @@ export function validatePostCompactInput( return validateHookInputType(input, 'PostCompact'); } +export function validateMessageDisplayInput( + input: unknown +): MessageDisplayInputSchema { + return validateHookInputType(input, 'MessageDisplay'); +} + export function validateElicitationInput( input: unknown ): ElicitationInputSchema { @@ -1308,10 +1273,6 @@ export function validateElicitationResultInput( return validateHookInputType(input, 'ElicitationResult'); } -// ============================================================================= -// Hook Configuration Validators -// ============================================================================= - /** * Validate a full hooks configuration block (e.g., parsed from settings.json) */ @@ -1371,10 +1332,6 @@ export function validateMatcherGroup(data: unknown): MatcherGroupSchema { return result.data; } -// ============================================================================= -// Content Validators -// ============================================================================= - /** * Validation rules for bash commands */ @@ -1460,10 +1417,6 @@ export function validateBashCommand( }; } -// ============================================================================= -// File Content Validators -// ============================================================================= - /** * Check if file content contains potential secrets */ @@ -1542,10 +1495,6 @@ export function validateFileSyntax( }; } -// ============================================================================= -// Path Validators -// ============================================================================= - /** * Normalize file paths for consistent processing * Handles redundant slashes, trailing slashes, and relative path components @@ -1574,7 +1523,7 @@ export function normalizeFilePath(filePath: string): string { } } - return resolved.join('/') ?? '/'; + return resolved.join('/'); } /** diff --git a/tests/content-validators.test.ts b/tests/content-validators.test.ts index c383d65..2bccde8 100644 --- a/tests/content-validators.test.ts +++ b/tests/content-validators.test.ts @@ -1,6 +1,6 @@ /** - * Tests for content validators moved to src/validation/validators.ts - * Phase 2D.6: containsSecrets, validateFileSyntax, validateSafeFilePath, validateBashCommand + * Tests for content validators. + * Covers secret detection, syntax checks, safe paths, path normalization, and Bash command validation. */ import { describe, it, expect } from 'vitest'; diff --git a/tests/docs-round-trip.test.ts b/tests/docs-round-trip.test.ts index 108f025..5c233ca 100644 --- a/tests/docs-round-trip.test.ts +++ b/tests/docs-round-trip.test.ts @@ -11,33 +11,46 @@ const DOC_FILES = [ 'docs/upstream/hooks-guide.md', ] as const; -const EXPECTED_PARSE_SKIPS = new Map([ - [ - 'docs/upstream/hooks-reference.md#1', - 'Abbreviated JSON example uses ellipsis.', - ], - [ - 'docs/upstream/hooks-reference.md#62', - 'Prompt hook response schema documents a boolean union using prose syntax.', - ], - [ - 'docs/upstream/hooks-guide.md#13', - 'Annotated JSON example includes comments.', - ], -]); - -const EXPECTED_VALIDATION_SKIPS = new Map([ - [ - 'docs/upstream/hooks-reference.md#9', - 'Generic PreToolUse example omits tool_use_id, while the PreToolUse section documents it as required.', - ], -]); +interface SkipRule { + description: string; + matches: (source: string) => boolean; +} + +const EXPECTED_PARSE_SKIPS: SkipRule[] = [ + { + description: 'Abbreviated JSON example uses ellipsis.', + matches: source => source.includes('...'), + }, + { + description: + 'Prompt hook response schema documents a boolean union using prose syntax.', + matches: source => source.includes('true | false'), + }, + { + description: 'Annotated JSON example includes comments.', + matches: source => source.includes('// unique ID for this session'), + }, +]; + +const EXPECTED_VALIDATION_SKIPS: SkipRule[] = [ + { + description: + 'Generic PreToolUse example omits tool_use_id, while the PreToolUse section documents it as required.', + matches: source => + source.includes('"hook_event_name": "PreToolUse"') && + !source.includes('"tool_use_id"'), + }, +]; interface JsonBlock { key: string; source: string; } +interface ClassifiedJsonBlock extends JsonBlock { + description: string; +} + interface ParsedJsonBlock extends JsonBlock { value: unknown; } @@ -52,6 +65,13 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function classifyBlock( + source: string, + rules: readonly SkipRule[] +): string | undefined { + return rules.find(rule => rule.matches(source))?.description; +} + function collectJsonBlocks(): { parsedBlocks: ParsedJsonBlock[]; parseSkips: JsonBlock[]; @@ -83,44 +103,54 @@ function collectJsonBlocks(): { const { parsedBlocks, parseSkips } = collectJsonBlocks(); -const validationCandidates = parsedBlocks.filter(block => { - if (!isRecord(block.value)) { - return false; - } +const classifiedParseSkips = parseSkips.flatMap(block => { + const description = classifyBlock(block.source, EXPECTED_PARSE_SKIPS); - return 'hook_event_name' in block.value || 'hooks' in block.value; + return description ? [{ ...block, description }] : []; }); -const validationSkips = validationCandidates.filter(block => - EXPECTED_VALIDATION_SKIPS.has(block.key) +const unexpectedParseSkips = parseSkips.filter( + block => !classifyBlock(block.source, EXPECTED_PARSE_SKIPS) ); +const validationCandidates = parsedBlocks.filter( + (block): block is ParsedJsonBlock & { value: Record } => { + if (!isRecord(block.value)) { + return false; + } + + return 'hook_event_name' in block.value || 'hooks' in block.value; + } +); + +const classifiedValidationSkips: ClassifiedJsonBlock[] = + validationCandidates.flatMap(block => { + const description = classifyBlock(block.source, EXPECTED_VALIDATION_SKIPS); + + return description ? [{ ...block, description }] : []; + }); + const validationBlocks = validationCandidates.filter( - block => !EXPECTED_VALIDATION_SKIPS.has(block.key) + block => !classifyBlock(block.source, EXPECTED_VALIDATION_SKIPS) ); describe('official docs JSON examples', () => { it('only skips known non-JSON documentation snippets', () => { - expect(parseSkips.map(block => block.key).sort()).toEqual( - [...EXPECTED_PARSE_SKIPS.keys()].sort() + expect(unexpectedParseSkips).toEqual([]); + expect(classifiedParseSkips.map(block => block.description).sort()).toEqual( + EXPECTED_PARSE_SKIPS.map(rule => rule.description).sort() ); }); it('only skips known schema-inconsistent snippets', () => { - expect(validationSkips.map(block => block.key).sort()).toEqual( - [...EXPECTED_VALIDATION_SKIPS.keys()].sort() - ); + expect( + classifiedValidationSkips.map(block => block.description).sort() + ).toEqual(EXPECTED_VALIDATION_SKIPS.map(rule => rule.description).sort()); }); it.each(validationBlocks)( 'validates hook input or config example $key', block => { - expect(isRecord(block.value)).toBe(true); - - if (!isRecord(block.value)) { - throw new Error(`Expected ${block.key} to parse to an object`); - } - if ('hook_event_name' in block.value) { expect(() => validateHookInput(block.value)).not.toThrow(); return; diff --git a/tests/eslint-disable-blocker.test.ts b/tests/eslint-disable-blocker.test.ts index 88ea10e..428e5e7 100644 --- a/tests/eslint-disable-blocker.test.ts +++ b/tests/eslint-disable-blocker.test.ts @@ -2,7 +2,6 @@ * Test suite for ESLint Disable Blocker Hook * * Tests pattern detection, tool type handling, and feedback messages - * Following parent project's incremental testing pattern (one test at a time) */ import assert from 'node:assert'; diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 0000000..8350359 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; + +interface HookCliResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +interface BaseHookPayload { + readonly session_id: string; + readonly transcript_path: string; + readonly cwd: string; +} + +function createBaseHookPayload(): BaseHookPayload { + return { + session_id: 'session-1', + transcript_path: '/tmp/transcript.jsonl', + cwd: process.cwd(), + }; +} + +function runLifecycleHook( + script: string, + input: Record +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--import', 'tsx', script], { + cwd: process.cwd(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`Timed out waiting for ${script} to exit`)); + }, 5000); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { + stdout += chunk; + }); + child.stderr.on('data', chunk => { + stderr += chunk; + }); + + child.on('error', error => { + clearTimeout(timeout); + reject(error); + }); + + child.on('close', code => { + clearTimeout(timeout); + resolve({ + code: code ?? 1, + stdout, + stderr, + }); + }); + + child.stdin.end(JSON.stringify(input)); + }); +} + +describe('setup handler smoke test', () => { + it('exits 0 and keeps stdout empty for valid input', async () => { + const result = await runLifecycleHook('src/lifecycle/setup.ts', { + ...createBaseHookPayload(), + hook_event_name: 'Setup', + trigger: 'init', + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Setup hook triggered (init)'); + }); + + it('exits non-zero when trigger is missing', async () => { + const result = await runLifecycleHook('src/lifecycle/setup.ts', { + ...createBaseHookPayload(), + hook_event_name: 'Setup', + }); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('Failed to parse hook input JSON'); + }); +}); + +describe('message-display handler smoke test', () => { + it('exits 0 and echoes the display delta for valid input', async () => { + const result = await runLifecycleHook('src/lifecycle/message-display.ts', { + ...createBaseHookPayload(), + hook_event_name: 'MessageDisplay', + turn_id: '11111111-1111-4111-8111-111111111111', + message_id: '22222222-2222-4222-8222-222222222222', + index: 0, + final: false, + delta: 'Streaming chunk', + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe( + `{\n "hookSpecificOutput": {\n "hookEventName": "MessageDisplay",\n "displayContent": "Streaming chunk"\n }\n}` + ); + expect(result.stderr).toBe(''); + }); + + it('exits non-zero when message-display UUID input is invalid', async () => { + const result = await runLifecycleHook('src/lifecycle/message-display.ts', { + ...createBaseHookPayload(), + hook_event_name: 'MessageDisplay', + turn_id: 'not-a-uuid', + message_id: '22222222-2222-4222-8222-222222222222', + index: 0, + final: true, + delta: 'Streaming chunk', + }); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('Failed to parse hook input JSON'); + }); +}); diff --git a/tests/output-builder.test.ts b/tests/output-builder.test.ts new file mode 100644 index 0000000..b403b7e --- /dev/null +++ b/tests/output-builder.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + postToolUseOutputSchema, + sessionStartOutputSchema, +} from '../src/validation/index.js'; +import { + messageDisplayOutputSchema, + setupOutputSchema, +} from '../src/validation/schemas.js'; +import { HookOutputBuilder } from '../src/utils/output-builder.js'; + +describe('HookOutputBuilder parity helpers', () => { + it('setupContext returns valid SetupOutput', () => { + const output = HookOutputBuilder.setupContext('x'); + + expect(output.hookSpecificOutput?.hookEventName).toBe('Setup'); + expect(output.hookSpecificOutput?.additionalContext).toBe('x'); + expect(setupOutputSchema.safeParse(output).success).toBe(true); + }); + + it('messageDisplayContent returns valid MessageDisplayOutput', () => { + const output = HookOutputBuilder.messageDisplayContent('y'); + + expect(output.hookSpecificOutput?.hookEventName).toBe('MessageDisplay'); + expect(output.hookSpecificOutput?.displayContent).toBe('y'); + expect(messageDisplayOutputSchema.safeParse(output).success).toBe(true); + }); + + it('sessionStartContext returns valid SessionStartOutput with metadata fields', () => { + const output = HookOutputBuilder.sessionStartContext({ + sessionTitle: 't', + watchPaths: ['/a'], + reloadSkills: true, + initialUserMessage: 'hi', + }); + + expect(output.hookSpecificOutput?.hookEventName).toBe('SessionStart'); + expect(output.hookSpecificOutput?.sessionTitle).toBe('t'); + expect(output.hookSpecificOutput?.watchPaths).toEqual(['/a']); + expect(output.hookSpecificOutput?.reloadSkills).toBe(true); + expect(output.hookSpecificOutput?.initialUserMessage).toBe('hi'); + expect(sessionStartOutputSchema.safeParse(output).success).toBe(true); + }); + + it('sessionStartContext preserves context and explicit reloadSkills false', () => { + const output = HookOutputBuilder.sessionStartContext({ + context: 'Loaded project state', + reloadSkills: false, + }); + + expect(output.hookSpecificOutput).toEqual({ + hookEventName: 'SessionStart', + additionalContext: 'Loaded project state', + reloadSkills: false, + }); + expect(sessionStartOutputSchema.safeParse(output).success).toBe(true); + }); + + it('taskBlock can target TaskCreated output explicitly', () => { + const output = HookOutputBuilder.taskBlock( + 'Task needs more detail', + 'TaskCreated' + ); + + expect(output.continue).toBe(false); + expect(output.stopReason).toBe('Task needs more detail'); + expect(output.hookSpecificOutput.hookEventName).toBe('TaskCreated'); + }); + + it('feedback includes updatedToolOutput when provided', () => { + const output = HookOutputBuilder.feedback('r', 'ctx', undefined, { + replaced: true, + }); + + expect(output.reason).toBe('r'); + expect(output.hookSpecificOutput?.additionalContext).toBe('ctx'); + expect(output.hookSpecificOutput?.updatedToolOutput).toEqual({ + replaced: true, + }); + expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); + }); + + it('feedback still works without updatedToolOutput', () => { + const output = HookOutputBuilder.feedback('r', 'ctx'); + + expect(output.reason).toBe('r'); + expect(output.hookSpecificOutput?.additionalContext).toBe('ctx'); + expect(output.hookSpecificOutput?.updatedToolOutput).toBeUndefined(); + expect(postToolUseOutputSchema.safeParse(output).success).toBe(true); + }); +}); diff --git a/tests/package-exports.test.ts b/tests/package-exports.test.ts index c6faf96..b80f82c 100644 --- a/tests/package-exports.test.ts +++ b/tests/package-exports.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import * as rootExports from '../src/index.js'; +import * as lifecycleExports from '../src/lifecycle/index.js'; import * as processingExports from '../src/processing/index.js'; const repoRoot = process.cwd(); @@ -57,6 +58,9 @@ interface PackageJsonShape { readonly name: string; readonly bin?: Record; readonly scripts?: Record; + readonly repository?: { + readonly url: string; + }; readonly exports: PackageExports; } @@ -80,6 +84,10 @@ function isPackageJsonShape(value: unknown): value is PackageJsonShape { if (value['bin'] !== undefined && !isRecord(value['bin'])) return false; if (value['scripts'] !== undefined && !isRecord(value['scripts'])) return false; + if (value['repository'] !== undefined) { + if (!isRecord(value['repository'])) return false; + if (typeof value['repository']['url'] !== 'string') return false; + } return true; } @@ -130,6 +138,11 @@ const removedImplementationExports = [ 'writeMarker', ] as const; +const expectedLifecycleHandlerExports = [ + 'handleSetup', + 'handleMessageDisplay', +] as const; + describe('package export contract', () => { it('defines the documented root and barrel exports', async () => { const pkg = await readPackageJson(); @@ -247,13 +260,23 @@ describe('package export contract', () => { } }); + it('exposes lifecycle handlers from the lifecycle barrel', () => { + for (const exportName of expectedLifecycleHandlerExports) { + expect(lifecycleExports).toHaveProperty(exportName); + expect(typeof lifecycleExports[exportName]).toBe('function'); + } + }); + it('maps package binaries to built CLI entrypoints with matching source files', async () => { const pkg = await readPackageJson(); expect(pkg.bin).toEqual({ - 'claude-session-export': './dist/cli/export-sessions.js', - 'claude-session-tail': './dist/cli/tail-session.js', + 'claude-session-export': 'dist/cli/export-sessions.js', + 'claude-session-tail': 'dist/cli/tail-session.js', }); + expect(pkg.repository?.url).toBe( + 'git+https://github.com/libar-dev/agent-harness-kit.git' + ); await expect( access(join(repoRoot, 'src/cli/export-sessions.ts')) ).resolves.toBeUndefined(); diff --git a/tests/processing-core-hardening.test.ts b/tests/processing-core-hardening.test.ts index acd0b67..fb1f458 100644 --- a/tests/processing-core-hardening.test.ts +++ b/tests/processing-core-hardening.test.ts @@ -17,9 +17,7 @@ import { parseJsonlContent } from '../src/processing/internal.js'; import type { ToolUseBlock } from '../src/processing/types.js'; import { must } from './test-utils.js'; -// --------------------------------------------------------------------------- // summarizeToolCall — prototype-chain safety -// --------------------------------------------------------------------------- function toolUse( name: string, @@ -74,9 +72,7 @@ describe('summarizeToolCall — Object.prototype name collisions', () => { }); }); -// --------------------------------------------------------------------------- // compareStrings — ordering basics -// --------------------------------------------------------------------------- describe('compareStrings', () => { it('returns -1 / 0 / 1 for less / equal / greater', () => { @@ -97,9 +93,7 @@ describe('compareStrings', () => { }); }); -// --------------------------------------------------------------------------- // withDefaultSessionId — pinned via parseJsonlContent (parser.ts path) -// --------------------------------------------------------------------------- describe('parseJsonlContent — default sessionId backfill (parser.ts)', () => { const DEFAULT_SESSION_ID = 'backfilled-session'; diff --git a/tests/processing.test.ts b/tests/processing.test.ts index 6994265..e740138 100644 --- a/tests/processing.test.ts +++ b/tests/processing.test.ts @@ -10,11 +10,13 @@ import { toJsonlBlocks, exportSession, type SessionBlock, + type UserTextBlock, } from '../src/processing/index.js'; import { parseJsonlContent, parseSessionContent, } from '../src/processing/internal.js'; +import { imagePlaceholder } from '../src/processing/image-placeholder.js'; import { redactRetainedToolResultText } from '../src/processing/tool-result-redaction.js'; import { must } from './test-utils.js'; @@ -30,10 +32,6 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } -// --------------------------------------------------------------------------- -// Fixtures — realistic JSONL lines from Claude Code sessions -// --------------------------------------------------------------------------- - const USER_TEXT_LINE = JSON.stringify({ type: 'user', message: { @@ -191,7 +189,43 @@ const THINKING_LINE = JSON.stringify({ uuid: 'a-004', }); -// Build full JSONL content +const ASSISTANT_IMAGE_LINE = JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'raw-base64-image-data', + }, + }, + ], + }, + sessionId: 'test-session-001', + timestamp: '2026-02-16T20:00:11.000Z', + uuid: 'a-image', +}); + +function makeImageLine( + role: 'user' | 'assistant', + source: unknown, + uuid: string +): string { + return JSON.stringify({ + type: role, + message: { + role, + content: [{ type: 'image', source }], + }, + sessionId: 'test-session-001', + timestamp: '2026-02-16T20:00:15.000Z', + uuid, + }); +} + const FULL_SESSION_JSONL = [ USER_TEXT_LINE, ASSISTANT_TOOL_USE_LINE, @@ -204,10 +238,6 @@ const FULL_SESSION_JSONL = [ RESULT_LINE, ].join('\n'); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('Processing Pipeline', () => { describe('parseJsonlContent', () => { it('should parse valid JSONL lines', () => { @@ -232,6 +262,16 @@ describe('Processing Pipeline', () => { expect(diagnostics).toHaveLength(0); }); + it('parses JSONL with image blocks without invalid-shape diagnostics', () => { + const diagnostics: Parameters[1] = []; + const lines = parseJsonlContent(ASSISTANT_IMAGE_LINE, diagnostics); + + expect(lines).toHaveLength(1); + expect( + diagnostics.filter(diagnostic => diagnostic.kind === 'invalid_shape') + ).toHaveLength(0); + }); + it('should parse BOM-prefixed JSONL content', () => { const diagnostics: Parameters[1] = []; const lines = parseJsonlContent(`\uFEFF${USER_TEXT_LINE}`, diagnostics); @@ -341,6 +381,16 @@ describe('Processing Pipeline', () => { }); describe('denoiseSession', () => { + it('imagePlaceholder only includes safe media_type metadata', () => { + expect( + imagePlaceholder({ media_type: 'image/png', data: 'secret' }) + ).toBe('[Image: image/png]'); + expect(imagePlaceholder({ media_type: 'image/png;data=secret' })).toBe( + '[Image]' + ); + expect(imagePlaceholder([{ media_type: 'image/png' }])).toBe('[Image]'); + }); + it('should extract user text messages', () => { const raw = parseSessionContent('test', FULL_SESSION_JSONL); const clean = denoiseSession(raw); @@ -359,6 +409,93 @@ describe('Processing Pipeline', () => { expect(textMsg).toBeDefined(); }); + it('extracts image placeholders from assistant image blocks', () => { + const clean = denoiseSession( + parseSessionContent('test', ASSISTANT_IMAGE_LINE) + ); + + expect(clean.messages[0]?.text).toBe('[Image: image/png]'); + expect(clean.messages[0]?.text).not.toContain('raw-base64-image-data'); + }); + + it('preserves mixed text and image order in denoised text', () => { + const session = JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Before' }, + { + type: 'image', + source: { media_type: 'image/jpeg', data: 'secret-image-data' }, + }, + { type: 'text', text: 'After' }, + ], + }, + sessionId: 'test-session-001', + timestamp: '2026-02-16T20:00:12.000Z', + uuid: 'a-image-mixed', + }); + + const clean = denoiseSession(parseSessionContent('test', session)); + + expect(clean.messages[0]?.text).toBe( + 'Before\n\n[Image: image/jpeg]\n\nAfter' + ); + expect(clean.messages[0]?.text).not.toContain('secret-image-data'); + }); + + it('extracts generic image placeholders when source is missing', () => { + const session = JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'image' }] }, + sessionId: 'test-session-001', + timestamp: '2026-02-16T20:00:13.000Z', + uuid: 'u-image-missing-source', + }); + + const clean = denoiseSession(parseSessionContent('test', session)); + + expect(clean.messages[0]?.text).toBe('[Image]'); + }); + + it('denoiseSession image source as array', () => { + const clean = denoiseSession( + parseSessionContent( + 'test', + makeImageLine( + 'assistant', + [{ media_type: 'image/png' }], + 'a-image-array' + ) + ) + ); + + expect(clean.messages[0]?.text).toBe('[Image]'); + }); + + it('denoiseSession image source as null', () => { + const clean = denoiseSession( + parseSessionContent( + 'test', + makeImageLine('assistant', null, 'a-image-null') + ) + ); + + expect(clean.messages[0]?.text).toBe('[Image]'); + }); + + it('denoiseSession image source as primitive', () => { + const clean = denoiseSession( + parseSessionContent( + 'test', + makeImageLine('assistant', 'image/png', 'a-image-primitive') + ) + ); + + expect(clean.messages[0]?.text).toBe('[Image]'); + }); + it('should summarize tool calls', () => { const raw = parseSessionContent('test', FULL_SESSION_JSONL); const clean = denoiseSession(raw); @@ -369,7 +506,6 @@ describe('Processing Pipeline', () => { ); expect(withTools.length).toBeGreaterThanOrEqual(1); - // Check Read tool summary format const readCall = withTools.find(m => m.toolCalls?.some(tc => tc.includes('Read(')) ); @@ -382,7 +518,6 @@ describe('Processing Pipeline', () => { const raw = parseSessionContent('test', FULL_SESSION_JSONL); const clean = denoiseSession(raw); - // The user message with tool_result (file content) should be filtered const allText = clean.messages.map(m => m.text).join(' '); expect(allText).not.toContain('Router()'); expect(allText).not.toContain('getUsers'); @@ -430,7 +565,7 @@ describe('Processing Pipeline', () => { const allText = clean.messages.map(m => m.text).join(' '); expect(allText).not.toContain('[thinking]'); - expect(allText).toContain('recommendation'); // text block preserved + expect(allText).toContain('recommendation'); }); it('should include thinking blocks when configured', () => { @@ -482,10 +617,6 @@ describe('Processing Pipeline', () => { expect(toolCalls[0]).toContain('pnpm run test'); }); - // ----------------------------------------------------------------------- - // Coverage for tools added after the original webui-derived implementation - // ----------------------------------------------------------------------- - function makeToolUseLine( name: string, input: Record @@ -601,10 +732,6 @@ describe('Processing Pipeline', () => { ); }); - // ----------------------------------------------------------------------- - // Tool result body preservation (ported from claude-code-webui) - // ----------------------------------------------------------------------- - it('captures tool_result content with resolved tool name', () => { const session = [ JSON.stringify({ @@ -875,10 +1002,6 @@ describe('Processing Pipeline', () => { expect(result.content).toContain('50 more lines truncated'); }); - // ----------------------------------------------------------------------- - // Structured block extraction (live-ingest / DB ingestion path) - // ----------------------------------------------------------------------- - it('extractBlocks emits typed blocks with stable IDs', () => { const session = [ JSON.stringify({ @@ -927,7 +1050,6 @@ describe('Processing Pipeline', () => { const raw = parseSessionContent('s1', session); const blocks = extractBlocks(raw); - // Expect: user_text, assistant_text, tool_use, tool_result (4 blocks) expect(blocks.map(b => b.type)).toEqual([ 'user_text', 'assistant_text', @@ -935,13 +1057,11 @@ describe('Processing Pipeline', () => { 'tool_result', ]); - // Stable IDs: messageUuid:blockIndex expect(must(blocks[0]).id).toBe('msg-1:0'); - expect(must(blocks[1]).id).toBe('msg-2:0'); // text is index 0 - expect(must(blocks[2]).id).toBe('msg-2:1'); // tool_use is index 1 + expect(must(blocks[1]).id).toBe('msg-2:0'); + expect(must(blocks[2]).id).toBe('msg-2:1'); expect(must(blocks[3]).id).toBe('msg-3:0'); - // Re-running produces identical IDs (idempotent for upsert) const blocks2 = extractBlocks(raw); expect(blocks2.map(b => b.id)).toEqual(blocks.map(b => b.id)); }); @@ -1118,7 +1238,6 @@ describe('Processing Pipeline', () => { expect(tu.summary).toBe( 'mcp:claude-in-chrome.navigate(url: https://x.test)' ); - // Original raw name preserved on toolName for filtering expect(tu.toolName).toBe('mcp__claude-in-chrome__navigate'); } }); @@ -1225,6 +1344,101 @@ describe('Processing Pipeline', () => { expect(blocks).toHaveLength(0); }); + it('extractBlocks emits image placeholders as text blocks', () => { + const blocks = extractBlocks( + parseSessionContent('test-session-001', ASSISTANT_IMAGE_LINE) + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'a-image:0', + type: 'assistant_text', + content: '[Image: image/png]', + }); + expect(JSON.stringify(blocks)).not.toContain('raw-base64-image-data'); + }); + + it('extractBlocks emits one placeholder per image block', () => { + const session = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [ + { type: 'image', source: { media_type: 'image/png', data: 'one' } }, + { + type: 'image', + source: { media_type: 'image/webp', data: 'two' }, + }, + ], + }, + sessionId: 'test-session-001', + timestamp: '2026-02-16T20:00:14.000Z', + uuid: 'u-image-multiple', + }); + + const blocks = extractBlocks(parseSessionContent('test', session)); + + const imageBlocks = blocks.filter( + (block): block is UserTextBlock => block.type === 'user_text' + ); + + expect(blocks.map(block => block.type)).toEqual([ + 'user_text', + 'user_text', + ]); + expect(imageBlocks.map(block => block.content)).toEqual([ + '[Image: image/png]', + '[Image: image/webp]', + ]); + expect(JSON.stringify(blocks)).not.toContain('one'); + expect(JSON.stringify(blocks)).not.toContain('two'); + }); + + it('extractBlocks image source as array', () => { + const blocks = extractBlocks( + parseSessionContent( + 'test', + makeImageLine('user', [{ media_type: 'image/png' }], 'u-image-array') + ) + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'u-image-array:0', + type: 'user_text', + content: '[Image]', + }); + }); + + it('extractBlocks image source as null', () => { + const blocks = extractBlocks( + parseSessionContent('test', makeImageLine('user', null, 'u-image-null')) + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'u-image-null:0', + type: 'user_text', + content: '[Image]', + }); + }); + + it('extractBlocks image source as primitive', () => { + const blocks = extractBlocks( + parseSessionContent( + 'test', + makeImageLine('user', 42, 'u-image-primitive') + ) + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + id: 'u-image-primitive:0', + type: 'user_text', + content: '[Image]', + }); + }); + it('extractBlocks emits agent_boundary blocks bracketing subagents', () => { const main = JSON.stringify({ type: 'assistant', @@ -1405,7 +1619,6 @@ describe('Processing Pipeline', () => { ]; const jsonl = toJsonlBlocks(blocks); const lines = jsonl.split('\n'); - // Two records + trailing empty (from final \n) expect(lines).toHaveLength(3); expect(lines[2]).toBe(''); const parsed = parseJsonObject(must(lines[0])); @@ -1452,7 +1665,6 @@ describe('Processing Pipeline', () => { const raw = parseSessionContent('t', session); const clean = denoiseSession(raw, { includeToolResults: false }); const userMsg = clean.messages.find(m => m.role === 'user'); - // With no text and no results, the user message gets filtered entirely expect(userMsg).toBeUndefined(); }); }); @@ -1495,6 +1707,23 @@ describe('Processing Pipeline', () => { expect(md).not.toContain('*Tools:*'); }); + + it('includes image placeholders without raw image data', () => { + const raw = parseSessionContent('test', ASSISTANT_IMAGE_LINE); + const clean = denoiseSession(raw); + const md = toMarkdown(clean); + + expect(md).toContain('[Image: image/png]'); + expect(md).not.toContain('raw-base64-image-data'); + expect(md).not.toContain('data:image'); + }); + + it('renders image-only messages with an image placeholder', () => { + const raw = parseSessionContent('test', ASSISTANT_IMAGE_LINE); + const md = toMarkdown(denoiseSession(raw)); + + expect(md).toContain('[Image: image/png]'); + }); }); describe('toCompactSummary', () => { diff --git a/tests/tail.test.ts b/tests/tail.test.ts index a8e23e0..3cbe470 100644 --- a/tests/tail.test.ts +++ b/tests/tail.test.ts @@ -148,6 +148,29 @@ function makeUserTextLine(uuid: string, text: string, ts: string): string { })}\n`; } +function makeAssistantImageLine( + uuid: string, + mediaType: string, + data: string, + ts: string +): string { + return `${JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'image', + source: { type: 'base64', media_type: mediaType, data }, + }, + ], + }, + sessionId: 's1', + timestamp: ts, + uuid, + })}\n`; +} + function makeUnknownLine( type: string, uuid: string | undefined, @@ -286,6 +309,54 @@ describe('Tail mode', () => { expect(result.newByteOffset).toBe(Buffer.byteLength(content)); }); + it('emits image transcript lines as structured tail blocks', async () => { + const content = makeAssistantImageLine( + 'a-image', + 'image/png', + 'raw-base64-image-data', + '2026-02-16T20:00:00.000Z' + ); + await writeFile(jsonlPath, content); + + const result = await tailBlocks(jsonlPath, { + dryRun: true, + fromStart: true, + }); + + expect(result.blocks.length).toBeGreaterThan(0); + expect(result.blocks[0]).toMatchObject({ + id: 'a-image:0', + type: 'assistant_text', + content: '[Image: image/png]', + }); + expect(JSON.stringify(result.blocks)).not.toContain( + 'raw-base64-image-data' + ); + expect(result.invalidShapeLineCount).toBe(0); + }); + + it('emits image transcript lines in raw record tail mode', async () => { + const content = makeAssistantImageLine( + 'a-image-raw', + 'image/png', + 'raw-base64-image-data', + '2026-02-16T20:00:00.000Z' + ); + await writeFile(jsonlPath, content); + + const result = await tailRawTranscriptRecords(jsonlPath, { + dryRun: true, + fromStart: true, + }); + + expect(result.records.length).toBeGreaterThan(0); + expect(result.records[0]).toMatchObject({ + id: 's1:main:a-image-raw', + type: 'assistant', + }); + expect(result.invalidShapeLineCount).toBe(0); + }); + it('redacts structured tail tool_result content and keeps replay output byte-identical', async () => { const content = makeAssistantToolUseLine('a-1', 'tu-1', '2026-02-16T20:00:00.000Z') + @@ -399,7 +470,6 @@ describe('Tail mode', () => { const first = await tailBlocks(jsonlPath); expect(first.blocks).toHaveLength(1); - // Append a second message const appended = makeUserTextLine( 'u-2', 'second', @@ -425,7 +495,6 @@ describe('Tail mode', () => { }); it('resolves toolName on tool_result added in a later tail call', async () => { - // Initial: just the tool_use const initial = makeAssistantToolUseLine( 'a-1', 'tu-X', @@ -435,7 +504,6 @@ describe('Tail mode', () => { const first = await tailBlocks(jsonlPath); expect(first.blocks).toHaveLength(1); - // Append the matching tool_result later const appended = makeUserToolResultLine( 'u-1', 'tu-X', @@ -448,7 +516,6 @@ describe('Tail mode', () => { expect(second.blocks).toHaveLength(1); const block = must(second.blocks[0]); if (block.type === 'tool_result') { - // toolName resolved from PREVIOUS scan's tool_use expect(block.toolName).toBe('Bash'); expect(block.content).toBe('output line'); } else { @@ -462,12 +529,10 @@ describe('Tail mode', () => { 'done', '2026-02-16T20:00:00.000Z' ); - // Append a partial second line (no terminating newline) const partial = `{"type":"user","message":{"role":"user","content":"part`; await writeFile(jsonlPath, complete + partial); const result = await tailBlocks(jsonlPath); - // Only the complete first line is parsed expect(result.blocks).toHaveLength(1); expect(must(result.blocks[0]).id).toBe('u-1:0'); expect(result.invalidJsonLineCount).toBe(0); @@ -740,7 +805,6 @@ describe('Tail mode', () => { await writeFile(jsonlPath, big); await tailBlocks(jsonlPath); - // Truncate / rewrite the file shorter const shorter = makeUserTextLine( 'u-new', 'fresh', @@ -788,7 +852,6 @@ describe('Tail mode', () => { const stats = await stat(expected); expect(stats.isFile()).toBe(true); - // Default location should NOT have a marker const defaultMarker = await readMarker(getMarkerPath(jsonlPath)); expect(defaultMarker).toBeNull(); }); @@ -1316,7 +1379,6 @@ describe('Tail mode', () => { '2026-02-16T20:00:00.000Z' ); const later = makeUserTextLine('u-2', 'second', '2026-02-16T20:00:05.000Z'); - // Write them in reverse await writeFile(jsonlPath, later + earlier); const result = await tailBlocks(jsonlPath); @@ -1326,7 +1388,6 @@ describe('Tail mode', () => { }); it('end-to-end: simulates a live-ingest consumer incremental ingest pattern', async () => { - // Round 1: Claude Code creates session, consumer tails first time await writeFile( jsonlPath, makeUserTextLine('u-1', 'analyze repo', '2026-02-16T20:00:00.000Z') @@ -1334,7 +1395,6 @@ describe('Tail mode', () => { const round1 = await tailBlocks(jsonlPath); expect(round1.blocks).toHaveLength(1); - // Round 2: Claude responds with tool call, consumer tails await appendFile( jsonlPath, makeAssistantToolUseLine('a-1', 'tu-1', '2026-02-16T20:00:01.000Z') @@ -1343,7 +1403,6 @@ describe('Tail mode', () => { expect(round2.blocks).toHaveLength(1); expect(must(round2.blocks[0]).type).toBe('tool_use'); - // Round 3: Tool result lands, consumer tails — toolName must be resolved await appendFile( jsonlPath, makeUserToolResultLine( @@ -1360,13 +1419,11 @@ describe('Tail mode', () => { expect(tr.toolName).toBe('Bash'); } - // Total blocks across all rounds = unique IDs (no duplicates from re-emission) const allIds = [...round1.blocks, ...round2.blocks, ...round3.blocks].map( b => b.id ); expect(new Set(allIds).size).toBe(allIds.length); - // The final marker should point to file size const finalSize = (await readFile(jsonlPath)).length; const marker = must(await readMarker(getMarkerPath(jsonlPath))); expect(marker.byteOffset).toBe(finalSize); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index cb776e1..c28edfd 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,16 +1,3 @@ -/** - * Test utilities for Claude Code hooks - * - * Following established testing patterns: - * - Two-step type assertion for handling unknown types - * - Incremental test development (one test at a time) - * - Pre-built test helpers for common patterns - * - Mock handling patterns - * - * CRITICAL: Write only ONE test at a time during development. - * This prevents API timeouts and complex debugging. - */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -50,15 +37,13 @@ import { type WorktreeRemoveInputSchema, type PreCompactInputSchema, type PostCompactInputSchema, + type SetupInputSchema, + type MessageDisplayInputSchema, type ElicitationInputSchema, type ElicitationResultInputSchema, } from '../src/validation/index.js'; import { type HookInputSchema } from '../src/validation/schemas.js'; -// ============================================================================= -// Test Data Factories -// ============================================================================= - export function must(value: T | undefined | null, msg?: string): T { if (value === undefined || value === null) { throw new Error(msg ?? 'expected value to be defined'); @@ -66,9 +51,6 @@ export function must(value: T | undefined | null, msg?: string): T { return value; } -/** - * Create a minimal valid hook base input for testing - */ type PermissionMode = | 'default' | 'plan' @@ -102,9 +84,6 @@ export function createTestHookBase( }; } -/** - * Create a complete PreToolUse hook input for testing - */ export function createPreToolUseInput( toolName: string, toolInput: Record, @@ -119,9 +98,6 @@ export function createPreToolUseInput( }; } -/** - * Create a complete PostToolUse hook input for testing - */ export function createPostToolUseInput( toolName: string, toolInput: Record, @@ -138,13 +114,6 @@ export function createPostToolUseInput( }; } -// ============================================================================= -// Lifecycle Event Factories -// ============================================================================= - -/** - * Create a UserPromptSubmit hook input for testing - */ export function createUserPromptSubmitInput( prompt: string = 'test prompt', overrides: Partial = {} @@ -157,9 +126,6 @@ export function createUserPromptSubmitInput( }; } -/** - * Create a UserPromptExpansion hook input for testing - */ export function createUserPromptExpansionInput( overrides: Partial = {} ): UserPromptExpansionInputSchema { @@ -175,9 +141,6 @@ export function createUserPromptExpansionInput( }; } -/** - * Create a SessionStart hook input for testing - */ export function createSessionStartInput( overrides: Partial = {} ): SessionStartInputSchema { @@ -190,9 +153,32 @@ export function createSessionStartInput( }; } -/** - * Create a SessionEnd hook input for testing - */ +export function createSetupInput( + overrides: Partial = {} +): SetupInputSchema { + return { + ...createTestHookBase({ hook_event_name: 'Setup' }), + hook_event_name: 'Setup' as const, + trigger: 'init' as const, + ...overrides, + }; +} + +export function createMessageDisplayInput( + overrides: Partial = {} +): MessageDisplayInputSchema { + return { + ...createTestHookBase({ hook_event_name: 'MessageDisplay' }), + hook_event_name: 'MessageDisplay' as const, + turn_id: '123e4567-e89b-12d3-a456-426614174000', + message_id: '123e4567-e89b-12d3-a456-426614174001', + index: 0, + final: false, + delta: 'test delta', + ...overrides, + }; +} + export function createSessionEndInput( reason: SessionEndInputSchema['reason'] = 'other', overrides: Partial = {} @@ -205,9 +191,6 @@ export function createSessionEndInput( }; } -/** - * Create a Notification hook input for testing - */ export function createNotificationInput( message: string, notificationType: NotificationInputSchema['notification_type'] = 'idle_prompt', @@ -222,9 +205,6 @@ export function createNotificationInput( }; } -/** - * Create a Stop hook input for testing - */ export function createStopInput( overrides: Partial = {} ): StopInputSchema { @@ -237,9 +217,6 @@ export function createStopInput( }; } -/** - * Create a StopFailure hook input for testing - */ export function createStopFailureInput( overrides: Partial = {} ): StopFailureInputSchema { @@ -253,9 +230,6 @@ export function createStopFailureInput( }; } -/** - * Create a SubagentStop hook input for testing - */ export function createSubagentStopInput( overrides: Partial = {} ): SubagentStopInputSchema { @@ -271,9 +245,6 @@ export function createSubagentStopInput( }; } -/** - * Create a PreCompact hook input for testing - */ export function createPreCompactInput( overrides: Partial = {} ): PreCompactInputSchema { @@ -286,9 +257,6 @@ export function createPreCompactInput( }; } -/** - * Create a PostCompact hook input for testing - */ export function createPostCompactInput( overrides: Partial = {} ): PostCompactInputSchema { @@ -301,9 +269,6 @@ export function createPostCompactInput( }; } -/** - * Create a PermissionRequest hook input for testing - */ export function createPermissionRequestInput( toolName: string, toolInput: Record, @@ -318,9 +283,6 @@ export function createPermissionRequestInput( }; } -/** - * Create a PermissionDenied hook input for testing - */ export function createPermissionDeniedInput( toolName: string = 'Bash', toolInput: Record = { command: 'rm -rf /tmp/build' }, @@ -340,9 +302,6 @@ export function createPermissionDeniedInput( }; } -/** - * Create a PostToolUseFailure hook input for testing - */ export function createPostToolUseFailureInput( toolName: string, toolInput: Record, @@ -360,9 +319,6 @@ export function createPostToolUseFailureInput( }; } -/** - * Create a PostToolBatch hook input for testing - */ export function createPostToolBatchInput( overrides: Partial = {} ): PostToolBatchInputSchema { @@ -381,9 +337,6 @@ export function createPostToolBatchInput( }; } -/** - * Create a SubagentStart hook input for testing - */ export function createSubagentStartInput( overrides: Partial = {} ): SubagentStartInputSchema { @@ -396,9 +349,6 @@ export function createSubagentStartInput( }; } -/** - * Create a TeammateIdle hook input for testing - */ export function createTeammateIdleInput( overrides: Partial = {} ): TeammateIdleInputSchema { @@ -411,9 +361,6 @@ export function createTeammateIdleInput( }; } -/** - * Create a TaskCreated hook input for testing - */ export function createTaskCreatedInput( overrides: Partial = {} ): TaskCreatedInputSchema { @@ -426,9 +373,6 @@ export function createTaskCreatedInput( }; } -/** - * Create a TaskCompleted hook input for testing - */ export function createTaskCompletedInput( overrides: Partial = {} ): TaskCompletedInputSchema { @@ -441,9 +385,6 @@ export function createTaskCompletedInput( }; } -/** - * Create an InstructionsLoaded hook input for testing - */ export function createInstructionsLoadedInput( overrides: Partial = {} ): InstructionsLoadedInputSchema { @@ -457,9 +398,6 @@ export function createInstructionsLoadedInput( }; } -/** - * Create a ConfigChange hook input for testing - */ export function createConfigChangeInput( overrides: Partial = {} ): ConfigChangeInputSchema { @@ -472,9 +410,6 @@ export function createConfigChangeInput( }; } -/** - * Create a CwdChanged hook input for testing - */ export function createCwdChangedInput( overrides: Partial = {} ): CwdChangedInputSchema { @@ -487,9 +422,6 @@ export function createCwdChangedInput( }; } -/** - * Create a FileChanged hook input for testing - */ export function createFileChangedInput( overrides: Partial = {} ): FileChangedInputSchema { @@ -502,9 +434,6 @@ export function createFileChangedInput( }; } -/** - * Create a WorktreeCreate hook input for testing - */ export function createWorktreeCreateInput( overrides: Partial = {} ): WorktreeCreateInputSchema { @@ -516,9 +445,6 @@ export function createWorktreeCreateInput( }; } -/** - * Create a WorktreeRemove hook input for testing - */ export function createWorktreeRemoveInput( overrides: Partial = {} ): WorktreeRemoveInputSchema { @@ -530,9 +456,6 @@ export function createWorktreeRemoveInput( }; } -/** - * Create an Elicitation hook input for testing - */ export function createElicitationInput( overrides: Partial = {} ): ElicitationInputSchema { @@ -552,9 +475,6 @@ export function createElicitationInput( }; } -/** - * Create an ElicitationResult hook input for testing - */ export function createElicitationResultInput( overrides: Partial = {} ): ElicitationResultInputSchema { @@ -570,13 +490,6 @@ export function createElicitationResultInput( }; } -// ============================================================================= -// Tool-Specific Test Factories -// ============================================================================= - -/** - * Create a valid Bash tool input for testing - */ export function createBashToolInput( command: string, overrides: Partial = {} @@ -587,9 +500,6 @@ export function createBashToolInput( }; } -/** - * Create a complete PreToolUse input for Bash tool testing - */ export function createBashPreToolUseInput( command: string, bashOverrides: Partial = {}, @@ -602,9 +512,6 @@ export function createBashPreToolUseInput( ); } -/** - * Create a valid Write tool input for testing - */ export function createWriteToolInput( filePath: string, content: string, @@ -617,9 +524,6 @@ export function createWriteToolInput( }; } -/** - * Create a complete PreToolUse input for Write tool testing - */ export function createWritePreToolUseInput( filePath: string, content: string, @@ -633,9 +537,6 @@ export function createWritePreToolUseInput( ); } -/** - * Create a valid Edit tool input for testing - */ export function createEditToolInput( filePath: string, oldString: string, @@ -651,9 +552,6 @@ export function createEditToolInput( }; } -/** - * Create a valid Read tool input for testing - */ export function createReadToolInput( filePath: string, overrides: Partial = {} @@ -664,9 +562,6 @@ export function createReadToolInput( }; } -/** - * Create a valid WebFetch tool input for testing - */ export function createWebFetchToolInput( url: string, prompt: string, @@ -675,9 +570,6 @@ export function createWebFetchToolInput( return { url, prompt, ...overrides }; } -/** - * Create a valid WebSearch tool input for testing - */ export function createWebSearchToolInput( query: string, overrides: Partial = {} @@ -685,9 +577,6 @@ export function createWebSearchToolInput( return { query, ...overrides }; } -/** - * Create a valid Task tool input for testing - */ export function createTaskToolInput( prompt: string, overrides: Partial = {} @@ -695,14 +584,6 @@ export function createTaskToolInput( return { prompt, ...overrides }; } -// ============================================================================= -// Mock Helpers -// ============================================================================= - -/** - * Create a mock for stdin input in hook testing - * Simulates JSON input via stdin - */ export function createStdinMock(input: HookInputSchema): { mockStdin: () => void; restoreStdin: () => void; @@ -733,10 +614,6 @@ export function createStdinMock(input: HookInputSchema): { return { mockStdin, restoreStdin }; } -/** - * Create a mock for stdout output in hook testing - * Captures JSON output written to stdout - */ function invokeCallback(callback: unknown, ...args: unknown[]): void { if (typeof callback === 'function') { Reflect.apply(callback, undefined, args); @@ -790,14 +667,6 @@ export function createStdoutMock(): { return { mockStdout, restoreStdout, getOutput, getOutputAsJson }; } -// ============================================================================= -// Validation Test Helpers -// ============================================================================= - -/** - * Test helper for validating that a function throws a specific error - * Following parent project's error testing patterns - */ export function expectValidationError( testFn: () => void, expectedCode: string, @@ -826,10 +695,6 @@ export function expectValidationError( } } -/** - * Create test cases for common validation scenarios - * Helps with systematic testing of validation rules - */ export function createValidationTestCases( validInput: T, invalidCases: Array<{ @@ -868,19 +733,12 @@ export function createValidationTestCases( return testCases; } -// ============================================================================= -// File System Test Helpers -// ============================================================================= - -/** - * Create temporary test files for file-related hook testing - */ export function createTempTestFile(content: string = 'test content'): { filePath: string; cleanup: () => void; } { const tempDir = os.tmpdir(); - const fileName = `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`; + const fileName = `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}.txt`; const filePath = path.join(tempDir, fileName); fs.writeFileSync(filePath, content); @@ -896,22 +754,10 @@ export function createTempTestFile(content: string = 'test content'): { return { filePath, cleanup }; } -// ============================================================================= -// Async Test Helpers -// ============================================================================= - -/** - * Wait for a specified amount of time - * Useful for testing timeouts and async behavior - */ export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -/** - * Create a promise that can be resolved externally - * Useful for testing async coordination patterns - */ export function createDeferred(): { promise: Promise; resolve: (value: T) => void; @@ -928,26 +774,17 @@ export function createDeferred(): { return { promise, resolve, reject }; } -// ============================================================================= -// Environment Test Helpers -// ============================================================================= - -/** - * Temporarily set environment variables for testing - */ export function withEnvVars( envVars: Record, testFn: () => T | Promise ): Promise { const originalEnv = { ...process.env }; - // Set test env vars for (const [key, value] of Object.entries(envVars)) { process.env[key] = value; } const cleanup = (): void => { - // Restore original env process.env = originalEnv; }; @@ -966,9 +803,6 @@ export function withEnvVars( } } -/** - * Set debug mode for testing hook behavior - */ export function withDebugMode(testFn: () => T | Promise): Promise { return withEnvVars({ CLAUDE_HOOK_DEBUG: 'true' }, testFn); } diff --git a/tests/tool-result-redaction.test.ts b/tests/tool-result-redaction.test.ts index 5cf18f9..f9a26d5 100644 --- a/tests/tool-result-redaction.test.ts +++ b/tests/tool-result-redaction.test.ts @@ -6,11 +6,11 @@ describe('redactRetainedToolResultText', () => { describe('ReDoS regression', () => { // The secret-key regex previously had two unbounded `*` quantifiers, which // backtracked quadratically on adversarial `token_token_token...` / - // `session_session_...` inputs. Both are now bounded ({0,12}); these inputs + // `session_session_...` inputs. Both are bounded ({0,12}); these inputs // must process in linear time. for (const seed of ['token_', 'session_'] as const) { it(`processes 20k '${seed}' repetitions quickly and still redacts a real secret`, () => { - // A non-word separator (newline) keeps the trailing `\bghp_...` token + // A non-word separator (line break) keeps the trailing `\bghp_...` token // boundary intact so the genuine secret is still detected/redacted. const input = `${seed.repeat(20000)}\nghp_1234567890abcdefghijklmnopqrstuvABCD`; diff --git a/tests/validation.test.ts b/tests/validation.test.ts index 9f17268..f6e93e1 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -1,16 +1,3 @@ -/** - * Test file demonstrating Zod-based validation testing patterns - * - * CRITICAL: Following parent project's incremental testing rule: - * Write only ONE test at a time during development! - * - * This file demonstrates: - * - Schema-first validation testing - * - Two-step type assertion patterns - * - Comprehensive error testing - * - Test helper usage - */ - import { describe, it, expect } from 'vitest'; function assertHookValidationError( @@ -44,9 +31,11 @@ import { isPostToolBatchInput, isUserPromptSubmitInput, isUserPromptExpansionInput, + isSetupInput, isSessionStartInput, isSessionEndInput, isNotificationInput, + isMessageDisplayInput, isStopInput, isStopFailureInput, isSubagentStartInput, @@ -79,6 +68,8 @@ import { validateWorktreeCreateInput, validateWorktreeRemoveInput, validatePostCompactInput, + validateSetupInput, + validateMessageDisplayInput, validateElicitationInput, validateElicitationResultInput, permissionRequestOutputSchema, @@ -126,6 +117,7 @@ import { hookInputSchemas, hookOutputSchemas, toolInputSchemas, + imageContentBlockSchema, rawHistoryLineSchema, rawTranscriptPayloadMetadataSchema, safeValidateRawHistoryLine, @@ -141,6 +133,8 @@ import { createBashPreToolUseInput, createWritePreToolUseInput, createTestHookBase, + createSetupInput, + createMessageDisplayInput, createPermissionRequestInput, createPermissionDeniedInput, createPostToolUseFailureInput, @@ -172,9 +166,7 @@ import { } from './test-utils.js'; describe('Zod-based Hook Validation', () => { - // FIRST TEST: Basic hook input validation it('should validate basic hook input structure', () => { - // Valid input should pass const validInput = createPreToolUseInput('Bash', { command: 'echo test' }); const result = validateHookInput(validInput); @@ -185,7 +177,6 @@ describe('Zod-based Hook Validation', () => { } }); - // SECOND TEST: Invalid hook input handling it('should reject invalid hook input with detailed errors', () => { const invalidInput = { invalid: 'structure' }; @@ -195,7 +186,6 @@ describe('Zod-based Hook Validation', () => { ); }); - // THIRD TEST: Tool-specific validation it('should validate Bash tool input correctly', () => { const hookInput = createBashPreToolUseInput('ls -la'); const toolInput = validateBashToolInput(hookInput); @@ -203,7 +193,6 @@ describe('Zod-based Hook Validation', () => { expect(toolInput.command).toBe('ls -la'); }); - // FOURTH TEST: Comprehensive validation test cases it('should handle various Bash validation scenarios', () => { const validBashInput = createBashToolInput('echo hello'); @@ -222,23 +211,19 @@ describe('Zod-based Hook Validation', () => { for (const testCase of testCases) { if (testCase.shouldPass) { - // Create hook input and validate const hookInput = createPreToolUseInput('Bash', testCase.input); const result = validateBashToolInput(hookInput); expect(result).toBeDefined(); } else { - // Expect validation to fail const hookInput = createPreToolUseInput('Bash', testCase.input); expect(() => validateBashToolInput(hookInput)).toThrow(); } } }); - // FIFTH TEST: Error context validation it('should provide detailed error context for debugging', () => { const invalidInput = { session_id: 'test', - // Missing required fields }; try { @@ -285,7 +270,7 @@ describe('Validation Error Handling', () => { }); }); -describe('Updated Schema Validation (Phase 1)', () => { +describe('Hook Input Schema Validation', () => { it('should allow permission_mode to be omitted', () => { const inputWithoutPermissionMode = { session_id: 'test', @@ -295,7 +280,6 @@ describe('Updated Schema Validation (Phase 1)', () => { tool_name: 'Bash', tool_input: { command: 'echo hi' }, tool_use_id: 'tuid-1', - // permission_mode is missing }; const result = validateHookInput(inputWithoutPermissionMode); expect(result.hook_event_name).toBe('PreToolUse'); @@ -317,7 +301,6 @@ describe('Updated Schema Validation (Phase 1)', () => { hook_event_name: 'PreToolUse' as const, tool_name: 'Bash', tool_input: { command: 'echo hi' }, - // tool_use_id is missing }; expectValidationError( () => validateHookInput(input), @@ -334,6 +317,153 @@ describe('Updated Schema Validation (Phase 1)', () => { } }); + it('should validate Setup with init and maintenance triggers', () => { + const setupInputs = ['init', 'maintenance'].map(trigger => ({ + ...createTestHookBase({ hook_event_name: 'Setup' }), + hook_event_name: 'Setup' as const, + trigger, + })); + + for (const input of setupInputs) { + const result = validateSetupInput(input); + expect(result.hook_event_name).toBe('Setup'); + expect(result.trigger).toBe(input.trigger); + } + }); + + it('should reject Setup with an invalid trigger', () => { + expectValidationError( + () => + validateSetupInput({ + ...createTestHookBase({ hook_event_name: 'Setup' }), + hook_event_name: 'Setup', + trigger: 'boot', + }), + 'HOOK_VALIDATION_FAILED' + ); + }); + + it('should validate MessageDisplay with UUID turn and message ids', () => { + const result = validateMessageDisplayInput({ + ...createTestHookBase({ hook_event_name: 'MessageDisplay' }), + hook_event_name: 'MessageDisplay', + turn_id: '11111111-1111-4111-8111-111111111111', + message_id: '22222222-2222-4222-8222-222222222222', + index: 0, + final: false, + delta: 'Hello', + }); + + expect(result.hook_event_name).toBe('MessageDisplay'); + expect(result.turn_id).toBe('11111111-1111-4111-8111-111111111111'); + expect(result.message_id).toBe('22222222-2222-4222-8222-222222222222'); + }); + + it('should reject MessageDisplay with an invalid UUID', () => { + expectValidationError( + () => + validateMessageDisplayInput({ + ...createTestHookBase({ hook_event_name: 'MessageDisplay' }), + hook_event_name: 'MessageDisplay', + turn_id: 'not-a-uuid', + message_id: '22222222-2222-4222-8222-222222222222', + index: 0, + final: true, + delta: 'Done', + }), + 'HOOK_VALIDATION_FAILED' + ); + }); + + it('should route Setup through the common hook validator', () => { + const result = validateHookInput( + createSetupInput({ trigger: 'maintenance' }) + ); + + expect(result.hook_event_name).toBe('Setup'); + if (isSetupInput(result)) { + expect(result.trigger).toBe('maintenance'); + } + }); + + it('should route MessageDisplay through the common hook validator with empty deltas', () => { + const result = validateHookInput( + createMessageDisplayInput({ index: 2, final: true, delta: '' }) + ); + + expect(result.hook_event_name).toBe('MessageDisplay'); + if (isMessageDisplayInput(result)) { + expect(result.index).toBe(2); + expect(result.final).toBe(true); + expect(result.delta).toBe(''); + } + }); + + it('should validate SessionStart when model is omitted and session_title is provided', () => { + const result = validateHookInput( + createSessionStartInput({ + model: undefined, + session_title: 'Recovered session', + }) + ); + + expect(result.hook_event_name).toBe('SessionStart'); + if ('model' in result) { + expect(result.model).toBeUndefined(); + } + if ('session_title' in result) { + expect(result.session_title).toBe('Recovered session'); + } + }); + + it('should validate the common effort field for valid levels', () => { + const result = validateHookInput( + createSessionStartInput({ effort: { level: 'xhigh' } }) + ); + + expect(result.effort).toEqual({ level: 'xhigh' }); + }); + + it('should reject invalid common effort levels', () => { + expectValidationError( + () => + validateHookInput({ + ...createSessionStartInput(), + effort: { level: 'turbo' }, + }), + 'HOOK_VALIDATION_FAILED' + ); + }); + + it('should validate duration_ms on PostToolUse inputs', () => { + const result = validatePostToolUseInput({ + ...createPostToolUseInput( + 'Bash', + { command: 'pnpm run test:run' }, + { stdout: 'ok' } + ), + duration_ms: 125, + }); + + expect(result.duration_ms).toBe(125); + }); + + it('should validate duration_ms on PostToolUseFailure inputs', () => { + const result = validateHookInput({ + ...createPostToolUseFailureInput( + 'Bash', + { command: 'pnpm run test:run' }, + 'Command failed' + ), + duration_ms: 250, + }); + + expect(result.hook_event_name).toBe('PostToolUseFailure'); + if ('duration_ms' in result) { + expect(result.duration_ms).toBe(250); + } + }); + it('should validate SessionEnd with bypass_permissions_disabled reason', () => { const input = createSessionEndInput('bypass_permissions_disabled'); const result = validateHookInput(input); @@ -515,14 +645,37 @@ describe('Transcript Validation Primitives', () => { } }); - it('keeps concrete diagnostics for unsupported content block discriminators', () => { + it('accepts image content blocks with source metadata', () => { const result = safeValidateRawHistoryLine({ type: 'assistant', ...baseHistoryFields, uuid: 'hist-009', message: { role: 'assistant', - content: [{ type: 'image', source: 'future-block' }], + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'abc123', + }, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it('keeps concrete diagnostics for unsupported content block discriminators', () => { + const result = safeValidateRawHistoryLine({ + type: 'assistant', + ...baseHistoryFields, + uuid: 'hist-009-unsupported', + message: { + role: 'assistant', + content: [{ type: 'document', source: 'future-block' }], }, }); @@ -537,6 +690,70 @@ describe('Transcript Validation Primitives', () => { } }); + it('validates user image content blocks', () => { + const result = safeValidateRawHistoryLine({ + type: 'user', + ...baseHistoryFields, + uuid: 'hist-image-user', + message: { + role: 'user', + content: [ + { type: 'image', source: { media_type: 'image/jpeg', data: 'abc' } }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it('validates assistant image content blocks', () => { + const result = safeValidateRawHistoryLine({ + type: 'assistant', + ...baseHistoryFields, + uuid: 'hist-image-assistant', + message: { + role: 'assistant', + content: [ + { type: 'image', source: { media_type: 'image/webp', data: 'abc' } }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it('validates mixed text and image content blocks', () => { + const result = safeValidateRawHistoryLine({ + type: 'assistant', + ...baseHistoryFields, + uuid: 'hist-image-mixed', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'See this screenshot.' }, + { type: 'image', source: { media_type: 'image/png', data: 'abc' } }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it('validates image-only content blocks', () => { + const result = imageContentBlockSchema.safeParse({ + type: 'image', + source: { media_type: 'image/png', data: 'abc' }, + }); + + expect(result.success).toBe(true); + }); + + it('validates image content blocks with missing source', () => { + const result = imageContentBlockSchema.safeParse({ type: 'image' }); + + expect(result.success).toBe(true); + }); + it('returns structured diagnostics for malformed history line shapes', () => { const result = safeValidateRawHistoryLine({ type: 'assistant', @@ -600,10 +817,6 @@ describe('Transcript Validation Primitives', () => { }); }); -// ============================================================================= -// Phase 2D Tests -// ============================================================================= - describe('HookOutputBuilder', () => { it('success() without message returns suppressOutput: true', () => { const output = HookOutputBuilder.success(); @@ -743,6 +956,20 @@ describe('Type Guards', () => { expect(isNotificationInput(validated)).toBe(true); }); + it('isSetupInput identifies correctly', () => { + const input = createSetupInput(); + const validated = validateHookInput(input); + expect(isSetupInput(validated)).toBe(true); + expect(isMessageDisplayInput(validated)).toBe(false); + }); + + it('isMessageDisplayInput identifies correctly', () => { + const input = createMessageDisplayInput(); + const validated = validateHookInput(input); + expect(isMessageDisplayInput(validated)).toBe(true); + expect(isSetupInput(validated)).toBe(false); + }); + it('isStopInput identifies correctly', () => { const input = createStopInput(); const validated = validateHookInput(input); @@ -780,11 +1007,7 @@ describe('safeValidateHookInput', () => { expect(result.error?.code).toBe('MISSING_HOOK_EVENT_NAME'); }); - it('wraps unexpected errors with UNEXPECTED_ERROR code', () => { - // null input triggers INVALID_INPUT_TYPE which is still a HookValidationError - // To test UNEXPECTED_ERROR, we'd need an internal error, but the function - // handles all known paths. We verify the catch-all path exists by testing - // that non-HookValidationError inputs still return structured results. + it('returns structured errors for invalid input types', () => { const result = safeValidateHookInput(null); expect(result.success).toBe(false); expect(result.error).toBeInstanceOf(HookValidationError); @@ -915,11 +1138,7 @@ describe('Hook-Type-Specific Validators', () => { }); }); -// ============================================================================= -// Phase 2 Tests: New Event Types -// ============================================================================= - -describe('Phase 2: New Event Input Schemas', () => { +describe('Additional Event Input Schemas', () => { it('should validate PermissionRequest input', () => { const input = createPermissionRequestInput('Bash', { command: 'rm -rf node_modules', @@ -948,7 +1167,6 @@ describe('Phase 2: New Event Input Schemas', () => { ...createTestHookBase({ hook_event_name: 'PermissionRequest' }), hook_event_name: 'PermissionRequest' as const, tool_input: { command: 'ls' }, - // tool_name missing }; expectValidationError( () => validateHookInput(input), @@ -990,7 +1208,6 @@ describe('Phase 2: New Event Input Schemas', () => { tool_name: 'Bash', tool_input: { command: 'ls' }, tool_use_id: 'tuid-1', - // error missing }; expectValidationError( () => validateHookInput(input), @@ -1048,7 +1265,6 @@ describe('Phase 2: New Event Input Schemas', () => { ...createTestHookBase({ hook_event_name: 'TaskCompleted' }), hook_event_name: 'TaskCompleted' as const, task_subject: 'Test', - // task_id missing }; expectValidationError( () => validateHookInput(input), @@ -1107,7 +1323,7 @@ describe('Phase 2: New Event Input Schemas', () => { }); }); -describe('Phase 2: PermissionRequest Output Schema', () => { +describe('PermissionRequest Output Schema', () => { it('should validate allow decision', () => { const output = { hookSpecificOutput: { @@ -1171,7 +1387,7 @@ describe('Phase 2: PermissionRequest Output Schema', () => { }); }); -describe('Session A: New Event Output Schemas', () => { +describe('Additional Event Output Schemas', () => { it('accepts UserPromptExpansion context and block output', () => { const result = userPromptExpansionOutputSchema.safeParse({ decision: 'block', @@ -1259,9 +1475,31 @@ describe('Session A: New Event Output Schemas', () => { }); expect(result.success).toBe(true); }); + + it('accepts SessionStart output with initialUserMessage, sessionTitle, watchPaths, and reloadSkills', () => { + const result = sessionStartOutputSchema.safeParse({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + initialUserMessage: 'hi', + sessionTitle: 't', + watchPaths: ['/a'], + reloadSkills: true, + }, + }); + + expect(result.success).toBe(true); + }); + + it('accepts terminalSequence as a common output field', () => { + const result = baseHookOutputSchema.safeParse({ + terminalSequence: '\u001b[2J\u001b[H', + }); + + expect(result.success).toBe(true); + }); }); -describe('Phase 2: New Type Guards', () => { +describe('Event Type Guards', () => { it('isPermissionRequestInput identifies correctly', () => { const input = createPermissionRequestInput('Bash', { command: 'ls' }); const validated = validateHookInput(input); @@ -1375,7 +1613,7 @@ describe('Phase 2: New Type Guards', () => { }); }); -describe('Phase 2: New Tool Input Validators', () => { +describe('Additional Tool Input Validators', () => { it('validateWebFetchToolInput validates valid input', () => { const hookInput = createPreToolUseInput('WebFetch', { url: 'https://example.com', @@ -1641,11 +1879,7 @@ describe('Phase 2: New Tool Input Validators', () => { }); }); -// ============================================================================= -// Phase 3: Output Types and HookOutputBuilder Tests -// ============================================================================= - -describe('Phase 3: Output Schema Updates', () => { +describe('Output Schema Validation Details', () => { describe('PreToolUse output schema', () => { it('accepts updatedInput in hookSpecificOutput', () => { const output = { @@ -1723,6 +1957,24 @@ describe('Phase 3: Output Schema Updates', () => { }); describe('PostToolUse output schema', () => { + it('accepts updatedToolOutput in hookSpecificOutput', () => { + const output = { + decision: 'block' as const, + reason: 'Tool output replaced', + hookSpecificOutput: { + hookEventName: 'PostToolUse', + updatedToolOutput: { replaced: true }, + }, + }; + const result = postToolUseOutputSchema.safeParse(output); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hookSpecificOutput?.updatedToolOutput).toEqual({ + replaced: true, + }); + } + }); + it('accepts updatedMCPToolOutput in hookSpecificOutput', () => { const output = { decision: 'block' as const, @@ -1741,6 +1993,22 @@ describe('Phase 3: Output Schema Updates', () => { } }); + it('accepts non-object updatedMCPToolOutput values', () => { + const output = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + updatedMCPToolOutput: 'sanitized string output', + }, + }; + const result = postToolUseOutputSchema.safeParse(output); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hookSpecificOutput?.updatedMCPToolOutput).toBe( + 'sanitized string output' + ); + } + }); + it('accepts both additionalContext and updatedMCPToolOutput', () => { const output = { hookSpecificOutput: { @@ -1790,7 +2058,7 @@ describe('Phase 3: Output Schema Updates', () => { }); }); -describe('Phase 3: HookOutputBuilder Updates', () => { +describe('HookOutputBuilder Schema Helpers', () => { describe('permission() with options', () => { it('creates output without options (backward compatible)', () => { const output = HookOutputBuilder.permission('allow', 'Approved'); @@ -2092,10 +2360,6 @@ describe('Phase 3: HookOutputBuilder Updates', () => { }); }); -// ============================================================================= -// Phase 4+5: Glob/Grep/MultiEdit Tool Schemas & Hook Config Schemas -// ============================================================================= - describe('Glob/Grep/MultiEdit Tool Input Schemas', () => { describe('globToolInputSchema', () => { it('validates minimal Glob input', () => { @@ -2224,6 +2488,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { const result = commandHookHandlerSchema.safeParse({ type: 'command', command: '.claude/hooks/run-tests.sh', + args: ['--project', 'hooks'], async: true, asyncRewake: true, shell: 'powershell', @@ -2235,6 +2500,16 @@ describe('Hook Configuration Schemas (settings.json)', () => { expect(result.success).toBe(true); }); + it('rejects command handler with non-string args entries', () => { + const result = commandHookHandlerSchema.safeParse({ + type: 'command', + command: '.claude/hooks/run-tests.sh', + args: ['--project', 1], + }); + + expect(result.success).toBe(false); + }); + it('rejects command handler without command', () => { const result = commandHookHandlerSchema.safeParse({ type: 'command' }); expect(result.success).toBe(false); @@ -2420,13 +2695,11 @@ describe('Hook Configuration Schemas (settings.json)', () => { }); expect(commandResult.success).toBe(true); - // prompt handler should strip the async field (not fail, just ignore) const promptResult = promptHookHandlerSchema.safeParse({ type: 'prompt', prompt: 'test', async: true, }); - // Zod strips unknown fields by default, so this should still succeed expect(promptResult.success).toBe(true); if (promptResult.success) { expect('async' in promptResult.data).toBe(false); @@ -2489,6 +2762,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { describe('hookEventNameSchema', () => { const validEvents = [ 'SessionStart', + 'Setup', 'UserPromptSubmit', 'UserPromptExpansion', 'PreToolUse', @@ -2498,6 +2772,7 @@ describe('Hook Configuration Schemas (settings.json)', () => { 'PostToolUseFailure', 'PostToolBatch', 'Notification', + 'MessageDisplay', 'SubagentStart', 'SubagentStop', 'TaskCreated', @@ -2518,7 +2793,8 @@ describe('Hook Configuration Schemas (settings.json)', () => { 'SessionEnd', ]; - it('accepts all 28 valid event names', () => { + it('accepts all 30 valid event names', () => { + expect(validEvents).toHaveLength(30); for (const event of validEvents) { const result = hookEventNameSchema.safeParse(event); expect(result.success).toBe(true); @@ -2548,6 +2824,16 @@ describe('Hook Configuration Schemas (settings.json)', () => { hooks: [{ type: 'prompt', prompt: 'Check tasks: $ARGUMENTS' }], }, ], + Setup: [ + { + hooks: [{ type: 'command', command: '.claude/hooks/setup.ts' }], + }, + ], + MessageDisplay: [ + { + hooks: [{ type: 'command', command: '.claude/hooks/display.ts' }], + }, + ], }, }); expect(result.success).toBe(true); @@ -2668,20 +2954,16 @@ describe('Hook Configuration Schemas (settings.json)', () => { }); }); -// ============================================================================= -// Post-Phase 5 Review: Additional Coverage -// ============================================================================= - describe('Schema Collection Completeness', () => { - it('hookInputSchemas has all 28 event types', () => { + it('hookInputSchemas has all 30 event types', () => { const keys = Object.keys(hookInputSchemas); - expect(keys).toHaveLength(28); + expect(keys).toHaveLength(30); expect(keys.sort()).toEqual([...hookEventNameSchema.options].sort()); }); - it('hookOutputSchemas has all 28 event types', () => { + it('hookOutputSchemas has all 30 event types', () => { const keys = Object.keys(hookOutputSchemas); - expect(keys).toHaveLength(28); + expect(keys).toHaveLength(30); expect(keys.sort()).toEqual([...hookEventNameSchema.options].sort()); }); @@ -2902,7 +3184,6 @@ describe('Edge Cases', () => { }); expect(result.success).toBe(true); if (result.success) { - // Unknown key is stripped, known key is preserved expect(result.data.hooks?.['PreToolUse']).toBeDefined(); expect('FakeEvent' in (result.data.hooks ?? {})).toBe(false); }