Personal Claude Code configuration (hooks, commands, agents, settings).
Prerequisites:
- Claude Code installed (creates
~/.claudeon first run). uv— runsrufffor the post-edit lint hook and the setup cleanup step.jq— every hook reads its JSON input with it.shfmt— parses Bash commands for the hooks (scripts/bashparse).setup.shinstalls it with Homebrew; without Homebrew, install it by hand.
bash <(curl -fsSL https://raw.githubusercontent.com/YoniChechik/claude-code-config/main/setup.sh)This git-enables your existing ~/.claude directory, removes the retired webhook MCP registration, and installs shfmt:
- Already installed: it prints the version and moves on.
- Homebrew present: it runs
brew install shfmt. If that fails, it prints a warning and finishes the rest of setup. - No Homebrew: it prints where to download
shfmtand finishes the rest of setup.
Until shfmt is on the PATH, the Bash guards cannot parse commands and allow them, and the PostToolUse Bash hooks skip.
- Sound notifications: Hooks that play sounds when Claude needs attention.
scripts/_bashparse.sh (CLI: scripts/bashparse [--cwd DIR] [--depth N] < command) turns one Bash command line into a flat JSON array of segments, one per simple command, using shfmt --to-json and scripts/bashparse.jq.
Failure contract. bp_parse returns non-zero and sets BP_ERR when it cannot give a trustworthy answer. A guard must treat every non-zero return as deny, never allow.
| Return | Meaning |
|---|---|
| 2 | Bash syntax error (zsh-only syntax, unterminated quote) |
| 3 | shfmt not installed |
| 4 | the parse pipeline exceeded BASHPARSE_TIMEOUT seconds (default 3); its whole process group is killed |
| 5 | jq missing or failed, or shfmt produced bad JSON |
| 6 | bp_parse_hook got hook input that is not a JSON object, or whose tool_input.command or cwd is not a string or contains a NUL byte |
Fields callers must respect.
argvis the decoded word array after wrappers (rtk,env,command,sudo,timeout, ...) are removed. Guards match onargv, never on joined text.dynamic[i]is true when wordidepends on expansion, globbing or substitution; its text holds\uFFFDfor the dynamic part. A variable assigned once in the same command by a standaloneNAME=literal(never re-assigned, read, looped over, exported, declared or used with${NAME:-...}-style operators) expands to its value at every use that follows it on the same control path, soS=/tmp/x; $S/run.shhasargv[0]/tmp/x/run.sh, and an unquoted value with spaces splits into words.bp_segments_textis for display only.writeslists the static paths a segment writes: write redirects (>,>>,&>,>|,<>,>&file, including ones on an enclosing compound command, already absolute),teefiles,rm/rmdir/touch/mkdir/unlink/shred/truncateoperands,chmod/chown/chgrpfiles,mvsources and destination, thecp/ln/install/rsyncdestination,sed -iandperl -ifiles, anddd of=. Relative entries are relative tocwd_alts. Dynamic targets are left out.unknown: true(withunknown_reasons) means the parser cannot see what will run. Reasons:dynamic command name,substitution as command,source,dynamic eval,dynamic shell code,dynamic shell heredoc,shell reads stdin,indirect exec(xargs,parallel,find -exec),env -S,depth limit(code nested deeper thanBASHPARSE_DEPTH, default 4),nested parse error.- Static nested code is parsed, not marked unknown:
bash|sh|zsh -c '...',evalwith static words, and a heredoc or here-string fed to a shell. Its segments follow the parent withparent,depthandvia. cwd_altslists every directory the segment can run in when that set is finite (cd /w; cmdgives the session directory and/w); it is empty when the directory is unknowable (dynamiccd,pushd,source,eval,env -C).git.cwd_altsis the same aftergit -C. A guard may allow a write only when every entry is safe.cwd_known: falsemeans the shell's directory is not certain: after acdthat might have failed (cd x; cmd), after anif,||or loop that contains acd,cd -,cdwithCDPATHset,pushd/popd,source,eval, a dynamiccdtarget, or a call to a function defined in the same command.cd x && cmdandcd x || exitkeep it known, and so does anycdto an absolute path.git.cwd/git.cwd_knownaddgit -Con top; stacked relative-C,--git-dir,--work-treeandGIT_DIR/GIT_WORK_TREEmake it unknown.exit_code_belongs_to_commandis true only when a zero exit status for the whole Bash call proves this segment ran and succeeded. It is false for non-last pipeline members (nopipefailassumed), negated (!) or backgrounded commands, the left side of&&, either side of||, anything before a later;, and anything inside a substitution, function or compound command. When it is false, callers need other evidence, such as a PR URL in the output.- Heredoc bodies are data. Only
$(...)inside an unquoted body runs, and it appears as anin_substitutionsegment. git.configlists the values of-cand--config-env; a dynamic value appears as\uFFFD.
Bash API. One jq process does the whole flattening: _bashparse.sh pipes the hook JSON and shfmt's AST into bashparse.jq, which also emits a NUL-separated record per segment. After bp_parse or bp_parse_hook, BP_N is the segment count and bp_seg <i> loads segment i into SEG_* variables and arrays (SEG_ARGV, SEG_DYN as a 0/1 string, SEG_ENV, SEG_RAW, SEG_REASONS, SEG_WRITES, SEG_CWD, SEG_CWD_KNOWN, SEG_CWD_ALTS, SEG_FLAGS, SEG_GIT_*) with no further process. SEG_FLAGS letters: S subshell, G brace group, X substitution, F function body, C compound command, B background, N negated, A exit code attributable, U unknown, P nested code. bp_find [--all] word... matches unwrapped argv prefixes (git options normalized) into BP_MATCHES. bp_hook_prepare parses a hook payload once and is a no-op when BP_READY=1, which is how pre_tool_use__dispatch.sh hands one parse to both guards. hooks/bash-timeout-pre.sh rewrites every Bash command to bash '<...>/hooks/bash-timeout-run.sh' <cap> <fg|bg> <id> '<command>', and PostToolUse hooks receive that rewritten command, so a hook payload in exactly that form is parsed (and logged, and recorded by the probe ledger) as the inner command (unwrap_bash_timeout in _hook_input.jq).
How the hooks use it.
Every guard decision and skipped real candidate is one line in ${CLAUDE_HOOK_LOG_DIR:-~/.claude/logs}/hooks.log. The PostToolUse hooks log nothing for a command that is not a candidate: the cheap substring filter, a non-Bash tool and a parse that finds no git push, gh pr create or gh pr merge all exit silently.
pre_tool_use__dispatch.shparses once and runs both guards on that parse. A guard returns deny or nothing (allow); the dispatcher never asks, and any other guard output, a crash or a missing library fails closed withGUARD_INTERNAL_ERROR. Non-Bash tools skip the parse, so a missingshfmtnever blocks anEdit.pre_tool_use__permission_guard.shandpre_tool_use__base_dir_protect.shmatch argv, so quoted text and quoted heredoc bodies are never commands. Unparseable commands and segments the parser cannot see into (xargs, find -exec, a variable command name, a script piped into a shell) are allowed, except where the remote-database rules below deny them;eval,bash|sh|zsh -cand a shell here-string or heredoc whose code is built from a variable or command substitution are always denied.- The permission guard denies only: writes to
~/.claude/state(Bash and Write/Edit/NotebookEdit/MultiEdit),gh --admin,gh repo delete,gcloud projects delete,gcloud run deployandgcloud run services update/replace/deploy/createwith--projectproduction-490411, staging-480220 or mirror-production-496017, Supabase commands whose resolved project ref or--db-urlis production, Pulumi ESC mutations of a production environment, and Pulumi mutations against a production backend or a stack named production, prod, mirror or main (with or without--target). Anything it cannot resolve (no ref, no stack, a shell-variable target) is allowed. Command names are matched case-insensitively, because macOS resolvesGhorSUPABASEto the real binary. supabase db queryis the one fail-closed exception. Against any remote target (--linked, a--db-urlthat is not a plain127.0.0.1/localhost/[::1]URL,SUPABASE_DB_URL/SUPABASE_LINKED/SUPABASE_LOCALin the environment,--local=false, an unknown flag or any dynamic argument) it is allowed only when the SQL is one single-quoted literal argument thatscripts/_sql_readonly.pyaccepts: optionalset default_transaction_read_only = on/set session characteristics as transaction read only, thenBEGIN [TRANSACTION|WORK] ... READ ONLYorSTART TRANSACTION ... READ ONLY, then one or moreSELECT/WITH/TABLE/VALUES/SHOW/EXPLAINstatements, then a finalCOMMIT/ROLLBACKwith nothing after it. The validator tokenizes like Postgres (comments, nested/* */,''and dollar quotes, quoted identifiers), rejects backslashes,U&literals,$nparameters, non-ASCII or control characters outside literals, empty statements, write/DDL/SET/transaction-control keywords and side-effect functions (nextval,set_config, advisory locks,lo_*,dblink*,pg_background*,http*, server file and signal functions, SQL-executingquery_to_xml/ts_stat, and any called function whose name has areset/flush/evict/lock/send/schedule/exec/http-style segment).-f, stdin, heredocs, pipes,xargs/find -exec,env -S, asupabase ... querycommand line inside another program's argument (includinggit -caliases and config values) and unparseable commands that mentionsupabaseandqueryare denied. ASET default_transaction_read_only = onalone is not accepted: measured on Postgres 17, it leaves the multi-statement transaction it runs in read-write. Local queries are unchanged.- Remote Postgres clients (
scripts/_remote_db_guard.py):psql,pgcli,pg_dump,pg_dumpallandpg_restore(any case, any path) are allowed only when every target is provably local:127.0.0.1,localhost,::1or a unix-socket directory, from-h/--host/--host=/-hX/a short cluster/a getopt prefix such as--ho, apostgres:///postgresql://URI (every host of a multi-host list,?host=/?hostaddr=, percent-decoded) or akey=valueconnection string in-d/--dbname/a positional, andPGHOST/PGHOSTADDR/PGDATABASE(inline or inherited) and an inlineDATABASE_URL.service=,PGSERVICE,pgcli -D/--dsnand--ssh-tunnel, any dynamic argument, a psql-c/heredoc/here-string/piped text with\c/\connectto a non-local target or a:variable, and\!,\setenv,\o|/\g|/\w|,\copy ... programor a backtick in a meta-command are denied. The same check runs on the client after any wrapper command (strictly after a known launcher such asdocker,kubectl,xargs,npxorkeyshelf; for any other wrapper such asdoas,niceorstdbufa dynamic positional is tolerated, but a dynamic or remote-h/--host/-dis not), insideenv -Sstrings, and on a client command line inside another program's argument (python -c "os.system('psql -h ...')",git -c alias...).ssh/moshrunning a client is denied, since localhost there is another machine. An argument that merely contains such a command line (echo 'psql -h db.example.com') is denied too, by the same fail-closed rule thesupabase db queryscan uses, since the guard cannot tell which programs run their arguments.PGHOST,PGHOSTADDR,PGSERVICEandPGDATABASEassigned or exported anywhere in the same command line are checked the same way, and naming one inexport,declare,read,printf -vand similar builtins without a readable value (read PGHOST,export PGHOSTof a value set elsewhere) is denied.--help,--versionand-V(not forpgcli) open no connection and are allowed.DATABASE_URLis checked only when it is set on the command line: libpq and psql never read it, so an inherited value only matters through an explicit"$DATABASE_URL"argument, which is dynamic and denied. A deny names the allowed form,supabase db query --linked 'begin read only; select ...; commit;'. Local use is unchanged. - Supabase Management API:
curl,wget,http/httpie,xh,curlie,aria2candgh apicalls (and any argument of another command, such aspython -c) whose percent-decoded, glob-stripped URL hitsapi.supabase.com/api.supabase.iowith a path (not the query string) namingdatabase,pg-meta,queryorsql, an HTTP client URL containing/v1/projects/that is built from a variable or command substitution, any host's/v1/projects/.../databaseor/platform/pg-meta/, or the Management API host (parsed as a host, soapi.supabase.com.example.orgis not it) with a path built from a variable or using curl{}/[]URL globbing, are denied with the same pushback. Other Management API calls are untouched. - Limitation: the Postgres read-only guarantee holds only because
BEGIN READ ONLYis the first transaction statement and the guard proves nothing runs after the finalCOMMIT/ROLLBACK. It cannot see inside user-defined functions, views or operators, so one that calls an extension with outside side effects (dblink,pg_net,http) under another name is not caught. Still unguarded, because the parser only sees the Bash command line: code inside script files (bash x.sh,source x.sh,python x.py, apsql -ffile,\i,~/.psqlrc/PSQLRC), SQL or URLs read from files (curl -K,-d @file,wget -i), interpreters that build the target at run time (python -cwith a computed host,node -e), shell functions and aliases defined earlier in the session, a URL held entirely in a variable (curl "$URL"),docker runof the Supabase CLI image, PostgREST/Edge Function calls to<ref>.supabase.co, and a script piped into a shell (... | bash). - The base-dir guard ignores unknown segments and unparseable commands. It denies only provable base-repo writes: a
writespath whose every candidate resolves into a git repo outside.claude/worktrees/(gitignored paths andprojects/*/memory/excepted), a git write whose every existingcwd_altsdirectory is in a base repo, and a--git-dir/--work-tree/GIT_DIR/GIT_WORK_TREEliteral absolute path inside a base repo. A candidate directory that does not exist is skipped, since acdinto it fails. When the directory cannot be proven (dynamiccd,cdin a branch or loop, mixed candidates), it allows. post_tool_use__gh_monitor_trigger.shfindsgit push,gh pr createandgh pr mergeanywhere in a compound command. WhenAis not set it needs evidence:HEAD == @{push}for a push, a PR URL in the output for a create, PR stateMERGEDfor a merge (always). Every skipped candidate writes oneskip:line, and duplicate watchers for the same branch collapse into one. For a merge it starts the post-merge watcher itself (gh_monitor.sh merge <PR> --repo <r> --detach), detached from the hook and from any subagent, and tells the main agent to rungh_monitor.sh waitin the background; a subagent is told not to wait.gh_monitor_relay.sh(main agent only: PostToolUse on theAgenttool; on Stop the same_relay_lib.shruns insidestop__coordinator.sh) hands the main agent, once, each post-merge watch of its session it has not been told about: the finished verdict, or the backgroundwaitcommand plus an order not to report the PR deployed before the verdict. This is how a merge made by a subagent reaches the main session: when a foreground subagent returns, before the main agent replies, and at the end of any later turn as a backstop. A subagent that merges is first told to send the main session, at once and before any other step, aSendMessage(to: "main") naming the PR, its title and merge sha, and the exactwaitcommand, with an order not to report it deployed before the verdict; it is also told to put thewaitcommand, verbatim, in its final report. Hooks inside a subagent carry itsagent_idand the main session'ssession_id, so the relay skips any payload with anagent_id.post_tool_use__sync_main_after_merge.shsyncs only after a parsedgh pr merge, from that segment's directory. It runs on PostToolUse and PostToolUseFailure, because a compound command can fail after its merge succeeded.
Stop gate (done gate). One blocking Stop hook, stop__coordinator.sh (5 s timeout, local files only, always exit 0 with JSON or nothing). It joins the relay text with the gate verdict in one reason.
- Every main-agent final message must end with one STATUS line:
COMPLETE <evidence>,WAITING <task-id> <what>,BLOCKED <category>: <ask>,FAILURE <what>: <evidence>orANSWER VERIFIED <command or path>: <observed>. Rules: G1 the STATUS line itself (BLOCKED categories: credential, captcha, payment, physical, destructive-prod-data, spending-money, irreversible-external-send, paradigm-shift), G2 every merge has a passing staging verify record (or a livegh_monitor.sh waittask, or a FAILURE naming the PR), G3WAITINGnames a running task frombackground_tasks/session_crons(shell tasks needgh_monitor.shor a[wait:<tag>]description, TTL 2 h orttl=<min>), G4COMPLETEafter a merge or push meets the recorded exit predicate (cmd:items checked bydone_gate.sh check <n>after the last merge,merged:/staging:items by verify records on staging or production unless asurface:<repo>:<simulator|local>item allows another surface,linear:<ID>:<state>items by a Linearget_issueprobe; the predicate may not shrink within a prompt), G5 probe-backed claims:ANSWER VERIFIEDneeds a read-only probe in this prompt matching the citation (normalised per pipe stage) whose output holds the backtick quote; a plan with open choices lists them under# Decisions (defaults applied)and endsANSWER VERIFIED, andFAILUREneeds 3 distinct failed probes across 2 bins plus a quote of a failed output (PR-naming FAILUREs and TTL-expired watcher tags are exempt). - Probes:
post_tool_use__probe_ledger.sh(sync, PostToolUse and PostToolUseFailure, jq in_probe_ledger.jq, shared normalization in_probe_common.jq) appends aproberecord per completed Bash/Read/Grep/Glob/WebFetch/WebSearch/BashOutput/TaskOutput/MCP call: normalizedcmd,bin,exit,ro(narrow read-only grammar),out_sha256, a redactedout_excerpt,prompt_id/agent_id/tool_use_id,ts_ms, and afactfor Linearget_issue. Every FAILURE appends afailurerecord (accepted,exempt,capped,attempts), whichnudge_report.pysummarizes. - State:
~/.claude/state/sessions/<session_id>/(0700, owner and symlink checked,CLAUDE_STATE_DIRoverrides the root).ledger.jsonlrecordsmerge/push(the merge trigger, with the resolved PR number),predicate(/session-name <label> --done),check(done_gate.sh),verify(staging_verify.sh, run by the post-merge watcher) andack(a FAILURE whose<what>part names the PR as<n>,#<n>,<owner/repo>#<n>or its URL). Appends holdlockfonledger.lock; merge and verify records match on the PR number. Unparseable ledger lines are logged as errors. - Staging verify config:
~/.claude/verify/<owner>__<repo>.json(a user-owned regular file in this repo, never read from the target repo). Targets carry anhttps://sha_url; probes are HTTPS GETs (url,expect_status,expect) run bycurl --proto =httpswith no shell. A staging SHA that contains the merge (GitHub compareahead/identical) passes. - Cap: 3 blocks per prompt (
counter.json), the third restates the FAILURE ladder (it does not offer FAILURE as a shortcut), then the stop passes and the open rules go tounresolved.json. If the counter cannot be read or saved, the stop passes andFAIL_OPENis logged, so a broken state file never blocks forever.prompt_context__done_gate.shre-injects them (and every unverified merge) on the next prompt and at session start, and also injects the session-name line at session start and again on the first prompt 30 minutes after the last nudge. pre_tool_use__ask_gate.shdeniesAskUserQuestionunless every question starts its text withBLOCKED:<category>or has the headerone-by-oneorplan.config_change__guard.shblocks a mid-session user-settings change that setsdisableAllHooksor removes the coordinator, the merge trigger, the question gate, the prompt-context hook or the guard itself.- The gate, the question gate and the config guard always enforce. Kill switch:
~/.claude/state/override/done_gate.offin a 0700 dir, honoured for 24 h after its mtime. The permission guard denies the agent any write to~/.claude/state, and any touch ofstate/override. uv run scripts/done_gate_replay.py --days 2replays historical final messages through the gate in a throwaway state dir and prints would-block counts per rule.uv run scripts/nudge_report.py --since 7dmines transcripts for the user nudges ("continue", "why did you stop") that the gate is meant to remove, and reports them per category and per session.