Skip to content

About

Personal Claude Code configuration (hooks, commands, agents, settings).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Claude Code Config

Personal Claude Code configuration (hooks, commands, agents, settings).

Installation

Prerequisites:

  • Claude Code installed (creates ~/.claude on first run).
  • uv — runs ruff for 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.sh installs 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 shfmt and 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.

What it does

  • Sound notifications: Hooks that play sounds when Claude needs attention.

Hook Bash parsing

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.

  • argv is the decoded word array after wrappers (rtk, env, command, sudo, timeout, ...) are removed. Guards match on argv, never on joined text. dynamic[i] is true when word i depends on expansion, globbing or substitution; its text holds \uFFFD for the dynamic part. A variable assigned once in the same command by a standalone NAME=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, so S=/tmp/x; $S/run.sh has argv[0] /tmp/x/run.sh, and an unquoted value with spaces splits into words. bp_segments_text is for display only.
  • writes lists the static paths a segment writes: write redirects (>, >>, &>, >|, <>, >&file, including ones on an enclosing compound command, already absolute), tee files, rm/rmdir/touch/mkdir/unlink/shred/truncate operands, chmod/chown/chgrp files, mv sources and destination, the cp/ln/install/rsync destination, sed -i and perl -i files, and dd of=. Relative entries are relative to cwd_alts. Dynamic targets are left out.
  • unknown: true (with unknown_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 than BASHPARSE_DEPTH, default 4), nested parse error.
  • Static nested code is parsed, not marked unknown: bash|sh|zsh -c '...', eval with static words, and a heredoc or here-string fed to a shell. Its segments follow the parent with parent, depth and via.
  • cwd_alts lists every directory the segment can run in when that set is finite (cd /w; cmd gives the session directory and /w); it is empty when the directory is unknowable (dynamic cd, pushd, source, eval, env -C). git.cwd_alts is the same after git -C. A guard may allow a write only when every entry is safe.
  • cwd_known: false means the shell's directory is not certain: after a cd that might have failed (cd x; cmd), after an if, || or loop that contains a cd, cd -, cd with CDPATH set, pushd/popd, source, eval, a dynamic cd target, or a call to a function defined in the same command. cd x && cmd and cd x || exit keep it known, and so does any cd to an absolute path. git.cwd/git.cwd_known add git -C on top; stacked relative -C, --git-dir, --work-tree and GIT_DIR/GIT_WORK_TREE make it unknown.
  • exit_code_belongs_to_command is 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 (no pipefail assumed), 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 an in_substitution segment.
  • git.config lists the values of -c and --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.sh parses 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 with GUARD_INTERNAL_ERROR. Non-Bash tools skip the parse, so a missing shfmt never blocks an Edit.
  • pre_tool_use__permission_guard.sh and pre_tool_use__base_dir_protect.sh match 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 -c and 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 deploy and gcloud run services update/replace/deploy/create with --project production-490411, staging-480220 or mirror-production-496017, Supabase commands whose resolved project ref or --db-url is 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 resolves Gh or SUPABASE to the real binary.
  • supabase db query is the one fail-closed exception. Against any remote target (--linked, a --db-url that is not a plain 127.0.0.1/localhost/[::1] URL, SUPABASE_DB_URL/SUPABASE_LINKED/SUPABASE_LOCAL in the environment, --local=false, an unknown flag or any dynamic argument) it is allowed only when the SQL is one single-quoted literal argument that scripts/_sql_readonly.py accepts: optional set default_transaction_read_only = on / set session characteristics as transaction read only, then BEGIN [TRANSACTION|WORK] ... READ ONLY or START TRANSACTION ... READ ONLY, then one or more SELECT/WITH/TABLE/VALUES/SHOW/EXPLAIN statements, then a final COMMIT/ROLLBACK with nothing after it. The validator tokenizes like Postgres (comments, nested /* */, '' and dollar quotes, quoted identifiers), rejects backslashes, U& literals, $n parameters, 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-executing query_to_xml/ts_stat, and any called function whose name has a reset/flush/evict/lock/send/schedule/exec/http-style segment). -f, stdin, heredocs, pipes, xargs/find -exec, env -S, a supabase ... query command line inside another program's argument (including git -c aliases and config values) and unparseable commands that mention supabase and query are denied. A SET default_transaction_read_only = on alone 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_dumpall and pg_restore (any case, any path) are allowed only when every target is provably local: 127.0.0.1, localhost, ::1 or a unix-socket directory, from -h/--host/--host=/-hX/a short cluster/a getopt prefix such as --ho, a postgres:///postgresql:// URI (every host of a multi-host list, ?host=/?hostaddr=, percent-decoded) or a key=value connection string in -d/--dbname/a positional, and PGHOST/PGHOSTADDR/PGDATABASE (inline or inherited) and an inline DATABASE_URL. service=, PGSERVICE, pgcli -D/--dsn and --ssh-tunnel, any dynamic argument, a psql -c/heredoc/here-string/piped text with \c/\connect to a non-local target or a :variable, and \!, \setenv, \o|/\g|/\w|, \copy ... program or 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 as docker, kubectl, xargs, npx or keyshelf; for any other wrapper such as doas, nice or stdbuf a dynamic positional is tolerated, but a dynamic or remote -h/--host/-d is not), inside env -S strings, and on a client command line inside another program's argument (python -c "os.system('psql -h ...')", git -c alias...). ssh/mosh running 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 the supabase db query scan uses, since the guard cannot tell which programs run their arguments. PGHOST, PGHOSTADDR, PGSERVICE and PGDATABASE assigned or exported anywhere in the same command line are checked the same way, and naming one in export, declare, read, printf -v and similar builtins without a readable value (read PGHOST, export PGHOST of a value set elsewhere) is denied. --help, --version and -V (not for pgcli) open no connection and are allowed. DATABASE_URL is 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, aria2c and gh api calls (and any argument of another command, such as python -c) whose percent-decoded, glob-stripped URL hits api.supabase.com/api.supabase.io with a path (not the query string) naming database, pg-meta, query or sql, an HTTP client URL containing /v1/projects/ that is built from a variable or command substitution, any host's /v1/projects/.../database or /platform/pg-meta/, or the Management API host (parsed as a host, so api.supabase.com.example.org is 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 ONLY is the first transaction statement and the guard proves nothing runs after the final COMMIT/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, a psql -f file, \i, ~/.psqlrc/PSQLRC), SQL or URLs read from files (curl -K, -d @file, wget -i), interpreters that build the target at run time (python -c with a computed host, node -e), shell functions and aliases defined earlier in the session, a URL held entirely in a variable (curl "$URL"), docker run of 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 writes path whose every candidate resolves into a git repo outside .claude/worktrees/ (gitignored paths and projects/*/memory/ excepted), a git write whose every existing cwd_alts directory is in a base repo, and a --git-dir/--work-tree/GIT_DIR/GIT_WORK_TREE literal absolute path inside a base repo. A candidate directory that does not exist is skipped, since a cd into it fails. When the directory cannot be proven (dynamic cd, cd in a branch or loop, mixed candidates), it allows.
  • post_tool_use__gh_monitor_trigger.sh finds git push, gh pr create and gh pr merge anywhere in a compound command. When A is not set it needs evidence: HEAD == @{push} for a push, a PR URL in the output for a create, PR state MERGED for a merge (always). Every skipped candidate writes one skip: 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 run gh_monitor.sh wait in the background; a subagent is told not to wait.
  • gh_monitor_relay.sh (main agent only: PostToolUse on the Agent tool; on Stop the same _relay_lib.sh runs inside stop__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 background wait command 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, a SendMessage (to: "main") naming the PR, its title and merge sha, and the exact wait command, with an order not to report it deployed before the verdict; it is also told to put the wait command, verbatim, in its final report. Hooks inside a subagent carry its agent_id and the main session's session_id, so the relay skips any payload with an agent_id.
  • post_tool_use__sync_main_after_merge.sh syncs only after a parsed gh 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> or ANSWER 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 live gh_monitor.sh wait task, or a FAILURE naming the PR), G3 WAITING names a running task from background_tasks/session_crons (shell tasks need gh_monitor.sh or a [wait:<tag>] description, TTL 2 h or ttl=<min>), G4 COMPLETE after a merge or push meets the recorded exit predicate (cmd: items checked by done_gate.sh check <n> after the last merge, merged:/staging: items by verify records on staging or production unless a surface:<repo>:<simulator|local> item allows another surface, linear:<ID>:<state> items by a Linear get_issue probe; the predicate may not shrink within a prompt), G5 probe-backed claims: ANSWER VERIFIED needs 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 ends ANSWER VERIFIED, and FAILURE needs 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 a probe record per completed Bash/Read/Grep/Glob/WebFetch/WebSearch/BashOutput/TaskOutput/MCP call: normalized cmd, bin, exit, ro (narrow read-only grammar), out_sha256, a redacted out_excerpt, prompt_id/agent_id/tool_use_id, ts_ms, and a fact for Linear get_issue. Every FAILURE appends a failure record (accepted, exempt, capped, attempts), which nudge_report.py summarizes.
  • State: ~/.claude/state/sessions/<session_id>/ (0700, owner and symlink checked, CLAUDE_STATE_DIR overrides the root). ledger.jsonl records merge/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) and ack (a FAILURE whose <what> part names the PR as <n>, #<n>, <owner/repo>#<n> or its URL). Appends hold lockf on ledger.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 an https:// sha_url; probes are HTTPS GETs (url, expect_status, expect) run by curl --proto =https with no shell. A staging SHA that contains the merge (GitHub compare ahead/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 to unresolved.json. If the counter cannot be read or saved, the stop passes and FAIL_OPEN is logged, so a broken state file never blocks forever. prompt_context__done_gate.sh re-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.sh denies AskUserQuestion unless every question starts its text with BLOCKED:<category> or has the header one-by-one or plan. config_change__guard.sh blocks a mid-session user-settings change that sets disableAllHooks or 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.off in a 0700 dir, honoured for 24 h after its mtime. The permission guard denies the agent any write to ~/.claude/state, and any touch of state/override.
  • uv run scripts/done_gate_replay.py --days 2 replays 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 7d mines 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.

About

Personal Claude Code configuration (hooks, commands, agents, settings).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages