[Sync] llm stuff
This commit is contained in:
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook: refuse to let a severed dotfiles link go unnoticed.
|
||||
#
|
||||
# Claude Code writes settings with an atomic temp+rename. Through the stow layer that can
|
||||
# replace ~/.config/claude/<file> (a symlink into the repo) with a plain FILE holding whatever
|
||||
# snapshot the writing process had in memory -- silently freezing every later repo edit out
|
||||
# of the live config. It happened on 2026-09-15 23:31: a stale copy dropped four hook events
|
||||
# (git-guard among them) and nobody noticed for an hour. link.sh heals the identical case but
|
||||
# only when run; this makes every session start say so instead.
|
||||
#
|
||||
# Read-only: it never relinks. Exit 2 on SessionStart shows stderr to the user and the session
|
||||
# continues. Exit 0 when everything resolves into the repo.
|
||||
set -u
|
||||
src="$HOME/.config/claude"
|
||||
bad=""
|
||||
for f in settings.json statusline.py keybindings.json CLAUDE.md link.sh; do
|
||||
p="$src/$f"
|
||||
[ -e "$p" ] || continue
|
||||
if [ ! -L "$p" ]; then bad="$bad $p is a plain file (should be a symlink into the dots repo)"$'\n'; fi
|
||||
done
|
||||
# the ~/.claude side too: link.sh's own guard covers it, but only when link.sh runs
|
||||
for f in settings.json keybindings.json CLAUDE.md statusline.py; do
|
||||
p="$HOME/.claude/$f"
|
||||
[ -e "$p" ] || continue
|
||||
case "$(readlink -f "$p")" in
|
||||
*"/.dots/"*) ;;
|
||||
*) bad="$bad $p resolves to $(readlink -f "$p"), not into the dots repo"$'\n' ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$bad" ] && exit 0
|
||||
printf 'DRIFT: Claude config has broken out of the dots repo; the live config is a stale copy.\n%s fix: review with `diff`, then `bash ~/.config/claude/link.sh` (heals identical copies, warns otherwise)\n' "$bad" >&2
|
||||
exit 2
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PostToolUse hook: format an edited file with the right formatter.
|
||||
|
||||
Reads the hook JSON from stdin, extracts the edited file path, and runs a
|
||||
Reads the hook JSON from stdin, works out which file(s) were just edited, and runs a
|
||||
language-appropriate formatter -- but, to avoid imposing a style on a project
|
||||
that never asked for it, every formatter here only runs when the project opts in
|
||||
via a config file discoverable by walking up from the edited file: stylua, ruff,
|
||||
@@ -9,6 +9,15 @@ prettier and taplo on their own configs, gofmt and rustfmt on go.mod / Cargo.tom
|
||||
shfmt and fish_indent on .editorconfig. No marker, no formatting -- a repo that
|
||||
hand-formats its shell keeps its own layout.
|
||||
|
||||
Two entry points, both wired in settings.json > hooks > PostToolUse:
|
||||
* Edit|Write -- the edited path is `tool_input.file_path`.
|
||||
* Bash -- under `defaultMode: auto` Claude is steered to edit through the shell
|
||||
(sed -i, heredocs, python), which the Edit|Write matcher never sees.
|
||||
There is no per-file event for that (`FileChanged` only watches literal
|
||||
filenames), so this scans `tool_input.command` for path-like tokens with a
|
||||
handled extension and formats the ones modified in the last few seconds --
|
||||
the mtime gate keeps a parallel session's dirty files out of it.
|
||||
|
||||
Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not
|
||||
guaranteed on the host, hence python for the stdin JSON parse.
|
||||
"""
|
||||
@@ -19,6 +28,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def find_up(start, names):
|
||||
@@ -178,14 +188,13 @@ HANDLERS = {
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return
|
||||
ti = data.get("tool_input") or {}
|
||||
tr = data.get("tool_response") or {}
|
||||
path = ti.get("file_path") or tr.get("filePath") or ""
|
||||
RECENT_SECS = 30 # a Bash edit is "ours" if the file changed this recently
|
||||
|
||||
_EXT_ALT = "|".join(re.escape(e[1:]) for e in HANDLERS)
|
||||
_PATHISH = re.compile(r"[\w.~/@+-]+\.(?:" + _EXT_ALT + r")\b")
|
||||
|
||||
|
||||
def format_one(path):
|
||||
if not path or not os.path.isfile(path):
|
||||
return
|
||||
# Resolve symlinks before walking for markers: deployed dotfiles get edited via
|
||||
@@ -204,5 +213,42 @@ def main():
|
||||
pass
|
||||
|
||||
|
||||
def recent_paths_in_command(command, cwd):
|
||||
"""Files a shell command plausibly just wrote: path-like tokens with a handled
|
||||
extension, resolved against the tool's cwd, that exist and changed within RECENT_SECS.
|
||||
Unchanged mentions (the source of a `cp`, a file merely grepped) fail the mtime gate."""
|
||||
now = time.time()
|
||||
seen, out = set(), []
|
||||
for tok in _PATHISH.findall(command or ""):
|
||||
p = os.path.expanduser(tok)
|
||||
if not os.path.isabs(p):
|
||||
p = os.path.join(cwd, p)
|
||||
p = os.path.normpath(p)
|
||||
if p in seen:
|
||||
continue
|
||||
seen.add(p)
|
||||
try:
|
||||
if os.path.isfile(p) and now - os.path.getmtime(p) <= RECENT_SECS:
|
||||
out.append(p)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return
|
||||
ti = data.get("tool_input") or {}
|
||||
tr = data.get("tool_response") or {}
|
||||
if data.get("tool_name") == "Bash":
|
||||
cwd = data.get("cwd") or os.getcwd()
|
||||
for p in recent_paths_in_command(ti.get("command"), cwd):
|
||||
format_one(p)
|
||||
return
|
||||
format_one(ti.get("file_path") or tr.get("filePath") or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression matrix for git-guard.py: `bash ~/.claude/hooks/git-guard-test.sh` (exit 1 on mismatch).
|
||||
# Kept as a file on purpose: typed inline, the very commands under test would trip the live hook.
|
||||
G="$(dirname "$(readlink -f "$0")")/git-guard.py"; fail=0
|
||||
# `git reset <one arg>` is allowed only for an existing path: give the matrix one to point at.
|
||||
tmp=$(mktemp -d) && cd "$tmp" && touch existing.py
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
run() { printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$2")}}" | python3 "$G" 2>/dev/null; rc=$?
|
||||
got=allow; [ $rc -eq 2 ] && got=block; [ "$got" = "$1" ] && mark=ok || { mark=MISMATCH; fail=1; }; printf '%-8s %-6s %-6s %s\n' "$mark" "$1" "$got" "$(printf '%s' "$2" | head -1)"; }
|
||||
echo "--- must block: commits / history ---"
|
||||
run block 'git push'
|
||||
run block 'git -C /home/coja/.dots push origin main'
|
||||
run block 'git -c user.name=x commit -m fix'
|
||||
run block '/usr/bin/git push'
|
||||
run block 'env GIT_DIR=.git git push'
|
||||
run block 'timeout 5 git push'
|
||||
run block 'sudo -u me git push'
|
||||
run block 'cd /tmp && git push'
|
||||
run block 'git status; git commit -am wip'
|
||||
run block 'x=1; git push'
|
||||
run block 'git push 2>&1 | tee log'
|
||||
run block "sh -c 'git push origin HEAD'"
|
||||
run block 'eval "git push"'
|
||||
run block 'git -C $(pwd) push'
|
||||
run block 'echo "$(git push)"'
|
||||
run block 'git --git-dir=/x/.git --work-tree=/x push'
|
||||
run block 'git commit -m "fix: grep pattern; not a command"'
|
||||
run block 'git revert HEAD'
|
||||
run block 'git cherry-pick abc123'
|
||||
run block 'git rebase -i HEAD~3'
|
||||
run block 'git rebase main'
|
||||
run block 'git merge feature'
|
||||
run block 'git merge --no-ff feature'
|
||||
echo "--- must block: discards working-tree changes ---"
|
||||
run block 'git reset --hard HEAD~1'
|
||||
run block 'git reset HEAD~1 --hard'
|
||||
run block 'git reset --soft HEAD~1'
|
||||
run block 'git reset --keep HEAD~2'
|
||||
run block 'git reset --merge'
|
||||
run block 'git reset'
|
||||
run block 'git reset -q'
|
||||
run block 'git reset HEAD'
|
||||
run block 'git reset HEAD~1'
|
||||
run block 'git reset abc1234'
|
||||
run block 'git reset 0123456789abcdef0123456789abcdef01234567'
|
||||
run block 'git reset origin/main'
|
||||
run block 'git reset @{u}'
|
||||
run block 'git restore --staged .'
|
||||
run block 'git restore --staged :/'
|
||||
run block 'git clean -fd'
|
||||
run block 'git -C . clean -n'
|
||||
run block 'git restore file.py'
|
||||
run block 'git restore --staged --worktree file.py'
|
||||
run block 'git restore -W file.py'
|
||||
run block 'git checkout -- .'
|
||||
run block 'git checkout -- file.py'
|
||||
run block 'git checkout HEAD -- file.py'
|
||||
run block 'git checkout .'
|
||||
run block 'git checkout -f main'
|
||||
run block 'git stash drop'
|
||||
run block 'git stash pop'
|
||||
run block 'git stash clear'
|
||||
run block 'git branch -D feature'
|
||||
run block 'git branch -f main HEAD~1'
|
||||
run block 'git branch -M main trunk'
|
||||
echo "--- must block: gh pr write ops ---"
|
||||
run block 'gh pr merge 12 --squash'
|
||||
run block 'gh -R o/r pr create --fill'
|
||||
run block 'gh pr edit 3 --title x'
|
||||
run block 'gh pr close 4'
|
||||
echo "--- must allow ---"
|
||||
run allow 'git status'
|
||||
run allow 'git log -1 --oneline'
|
||||
run allow 'git diff --cached --stat'
|
||||
run allow 'git add -A common/.config/claude'
|
||||
run allow 'git rev-parse --git-path CLAUDE_COMMIT_MSG'
|
||||
run allow 'git -c core.pager=cat log -3'
|
||||
run allow 'git reset -- file.txt'
|
||||
run allow 'git reset HEAD file.txt'
|
||||
run allow 'git reset HEAD existing.py other.py'
|
||||
run allow 'git reset existing.py'
|
||||
run allow 'git reset -p'
|
||||
run allow 'git reset -- .'
|
||||
run allow 'git restore --staged existing.py'
|
||||
run allow 'git restore --staged file.py'
|
||||
run allow 'git restore -S file.py'
|
||||
run allow 'git checkout main'
|
||||
run allow 'git checkout -b feature'
|
||||
run allow 'git switch main'
|
||||
run allow 'git merge --abort'
|
||||
run allow 'git stash'
|
||||
run allow 'git stash list'
|
||||
run allow 'git stash push -m wip'
|
||||
run allow 'git stash show -p'
|
||||
run allow 'git branch -d merged-branch'
|
||||
run allow 'git branch --list'
|
||||
run allow 'git branch -vv'
|
||||
run allow 'git commit-tree HEAD^{tree} -m x'
|
||||
run allow 'git worktree list'
|
||||
run allow 'grep -rn "git push" README.md'
|
||||
run allow 'grep -n "foo; git commit" file'
|
||||
run allow "echo 'git push'"
|
||||
run allow 'echo "run git push"'
|
||||
run allow "cat > README.md <<'EOF'
|
||||
Deploy with:
|
||||
git status; git commit -am x; git push origin main
|
||||
EOF
|
||||
echo written"
|
||||
run allow 'python3 - <<EOF
|
||||
print("git commit is manual")
|
||||
EOF'
|
||||
run allow 'gh pr view 12 --json updatedAt'
|
||||
run allow 'gh api graphql -f query="mutation{...}"'
|
||||
run allow 'gh auth status'
|
||||
run allow 'ls -la HEAD#x; git status'
|
||||
run allow 'git log --grep="git push" -5'
|
||||
run allow '~/.config/git/claude-commit-msg list'
|
||||
echo "--- fail-open ---"
|
||||
printf '%s' '{"tool_name":"Edit","tool_input":{"command":"git push"}}' | python3 "$G"; [ $? -eq 0 ] && echo "ok non-Bash tool ignored" || { echo "MISMATCH non-Bash"; fail=1; }
|
||||
printf 'not json' | python3 "$G"; [ $? -eq 0 ] && echo "ok garbage stdin passes" || { echo "MISMATCH garbage"; fail=1; }
|
||||
[ $fail -eq 0 ] && echo "GIT-GUARD MATRIX: all ok" || { echo "GIT-GUARD MATRIX: mismatches above"; exit 1; }
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PreToolUse hook (Bash): enforce the no-commit / no-push contract structurally.
|
||||
|
||||
settings.json's deny rules (`Bash(git push:*)`, ...) match a literal *prefix* of the
|
||||
command text, so `git -C . push`, `git -c k=v commit`, `/usr/bin/git push`,
|
||||
`env X=1 git push`, `timeout 5 git push` and `sh -c 'git push'` all slip past them.
|
||||
This hook parses each shell segment instead: finds the git/gh token wherever it sits,
|
||||
skips git's global options (`-C dir`, `-c k=v`, `--git-dir=...`) and blocks
|
||||
|
||||
git commit | push | revert | cherry-pick | rebase | merge (not --abort/--quit/--continue)
|
||||
git clean | reset (any form that moves HEAD or unstages everything; `reset -- <paths>`,
|
||||
`reset HEAD <path>` and `reset <existing path>` are fine) | restore <paths> (--staged
|
||||
alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f
|
||||
git stash drop | pop | clear git branch -D / -f / -M
|
||||
gh pr merge | create | close | edit
|
||||
|
||||
i.e. anything that commits, rewrites history, or discards working-tree changes -- the last
|
||||
group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate.
|
||||
|
||||
Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and
|
||||
stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts.
|
||||
|
||||
Tokenizing is quote-aware first (shlex in punctuation mode), and only then split at the
|
||||
separators `;`, `|`, `&&`, `(`, `)`: a quoted word that merely *contains* a separator and a
|
||||
git verb (a grep pattern, a commit message) stays one word and is not a command. `$(...)`
|
||||
and backtick bodies are scanned on their own and then collapsed, so `git -C $(pwd) push`
|
||||
still parses as a push. `sh -c '...'` and `eval` bodies recurse.
|
||||
|
||||
Deliberate gaps: heredoc bodies are skipped -- they are file/script *contents*, and a
|
||||
README that documents pushing must stay writable -- so a script piped in through a heredoc
|
||||
is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode
|
||||
classifier and the global CLAUDE.md still cover those; this is the belt for the prefix
|
||||
rules' braces. Fails open: any parse error lets the command through (exit 0).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"}
|
||||
MERGE_OK = {"--abort", "--quit", "--continue"}
|
||||
STASH_BLOCK = {"drop", "pop", "clear"}
|
||||
BRANCH_FORCE = {"-D", "-f", "--force", "-M"}
|
||||
RESET_MODES = {"--hard", "--soft", "--merge", "--keep"}
|
||||
UNSTAGE_ALL = {".", ":/", "*", ":"}
|
||||
_HEX = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
|
||||
|
||||
def looks_like_ref(a):
|
||||
return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/")
|
||||
or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a)))
|
||||
GH_PR_BLOCK = {"merge", "create", "close", "edit"}
|
||||
# global options that take a *separate* argument; the `--opt=value` spelling is one token.
|
||||
GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace",
|
||||
"--super-prefix", "--config-env"}
|
||||
GH_OPT_ARG = {"-R", "--repo"}
|
||||
SHELLS = {"sh", "bash", "dash", "zsh", "ksh", "fish"}
|
||||
|
||||
# `<<'EOF' ... EOF` / `<<-EOF ... EOF`: drop the body, keep the command around it.
|
||||
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n[ \t]*\2[ \t]*(?=\n|$)", re.S)
|
||||
# innermost `$(...)` / `...` command substitutions (peeled repeatedly for nesting).
|
||||
_SUBST = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")
|
||||
# tokens that end one simple command and start the next.
|
||||
_SEP = {";", ";;", "|", "||", "|&", "&", "&&", "(", ")"}
|
||||
|
||||
|
||||
def _skip_opts(args, with_arg):
|
||||
j = 0
|
||||
while j < len(args) and args[j].startswith("-"):
|
||||
j += 2 if args[j] in with_arg else 1
|
||||
return j
|
||||
|
||||
|
||||
def git_reason(args):
|
||||
j = _skip_opts(args, GIT_OPT_ARG)
|
||||
if j >= len(args):
|
||||
return None
|
||||
sub, rest = args[j], args[j + 1:]
|
||||
if sub in GIT_BLOCK:
|
||||
return f"git {sub}"
|
||||
if sub == "reset":
|
||||
opts = [a for a in rest if a.startswith("-") and a != "--"]
|
||||
if RESET_MODES & set(opts):
|
||||
return "git reset --hard/--soft/--merge/--keep (moves HEAD)"
|
||||
if "-p" in opts or "--patch" in opts or "--" in rest:
|
||||
return None # interactive, or the explicit path form
|
||||
pos = [a for a in rest if not a.startswith("-")]
|
||||
if not pos or pos == ["HEAD"]:
|
||||
return "git reset (unstages everything, other sessions' rounds included)"
|
||||
if len(pos) == 1 and (looks_like_ref(pos[0]) or not os.path.exists(pos[0])):
|
||||
return "git reset <commit> (moves HEAD)"
|
||||
return None # `reset HEAD <paths>` / `reset <paths>`
|
||||
if sub == "merge" and not (MERGE_OK & set(rest)):
|
||||
return "git merge (creates a commit)"
|
||||
if sub == "restore":
|
||||
staged = any(a in ("--staged", "-S") for a in rest)
|
||||
worktree = any(a in ("--worktree", "-W") for a in rest)
|
||||
if not staged or worktree:
|
||||
return "git restore (discards working-tree changes)"
|
||||
if UNSTAGE_ALL & {a for a in rest if not a.startswith("-")}:
|
||||
return "git restore --staged . (unstages everything, other sessions' rounds included)"
|
||||
if sub == "checkout" and ("--" in rest or "." in rest or "-f" in rest or "--force" in rest):
|
||||
return "git checkout -- <paths> (discards working-tree changes)"
|
||||
if sub == "stash" and rest and rest[0] in STASH_BLOCK:
|
||||
return f"git stash {rest[0]}"
|
||||
if sub == "branch" and BRANCH_FORCE & set(rest):
|
||||
return "git branch -D/-f (force-deletes or force-moves a branch)"
|
||||
return None
|
||||
|
||||
|
||||
def gh_reason(args):
|
||||
j = _skip_opts(args, GH_OPT_ARG)
|
||||
if j >= len(args) or args[j] != "pr":
|
||||
return None
|
||||
rest = args[j + 1:]
|
||||
k = _skip_opts(rest, GH_OPT_ARG)
|
||||
if k < len(rest) and rest[k] in GH_PR_BLOCK:
|
||||
return f"gh pr {rest[k]}"
|
||||
return None
|
||||
|
||||
|
||||
def tokenize(text):
|
||||
"""Quote-aware shell words; the separator characters come out as their own tokens
|
||||
(shlex punctuation mode). Comment handling is off: a `#` mid-word must not swallow
|
||||
the rest of the line."""
|
||||
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
||||
lex.whitespace_split = True
|
||||
lex.commenters = ""
|
||||
try:
|
||||
return list(lex)
|
||||
except ValueError: # unbalanced quotes: best effort
|
||||
return text.split()
|
||||
|
||||
|
||||
def segments(toks):
|
||||
seg = []
|
||||
for t in toks:
|
||||
if t in _SEP:
|
||||
if seg:
|
||||
yield seg
|
||||
seg = []
|
||||
else:
|
||||
seg.append(t)
|
||||
if seg:
|
||||
yield seg
|
||||
|
||||
|
||||
def scan(text, depth=0):
|
||||
"""Reason string for the first blocked invocation in `text`, else None."""
|
||||
if depth > 4:
|
||||
return None
|
||||
text = _HEREDOC.sub(" ", text)
|
||||
# command substitutions: scan each body on its own, then collapse it to a plain word so
|
||||
# the enclosing command still parses (`git -C $(pwd) push` -> `git -C SUBST push`).
|
||||
bodies = []
|
||||
|
||||
def _grab(m):
|
||||
bodies.append(m.group(1) if m.group(1) is not None else m.group(2))
|
||||
return " SUBST "
|
||||
|
||||
prev = None
|
||||
while prev != text:
|
||||
prev, text = text, _SUBST.sub(_grab, text)
|
||||
for body in bodies:
|
||||
r = scan(body, depth + 1)
|
||||
if r:
|
||||
return r
|
||||
for toks in segments(tokenize(text)):
|
||||
for i, tok in enumerate(toks):
|
||||
base = os.path.basename(tok)
|
||||
r = None
|
||||
if base == "git":
|
||||
r = git_reason(toks[i + 1:])
|
||||
elif base == "gh":
|
||||
r = gh_reason(toks[i + 1:])
|
||||
elif base == "eval":
|
||||
r = scan(" ".join(toks[i + 1:]), depth + 1)
|
||||
elif base in SHELLS and "-c" in toks[i + 1:]:
|
||||
k = toks.index("-c", i + 1) # `sh -c '<cmd>'`: the real command is one word
|
||||
if k + 1 < len(toks):
|
||||
r = scan(toks[k + 1], depth + 1)
|
||||
if r:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if data.get("tool_name", "Bash") != "Bash":
|
||||
return
|
||||
cmd = (data.get("tool_input") or {}).get("command") or ""
|
||||
reason = scan(cmd) if cmd else None
|
||||
except Exception:
|
||||
return # fail open: the deny list still applies
|
||||
if reason:
|
||||
short = cmd.strip().splitlines()[0]
|
||||
short = short if len(short) <= 80 else short[:77] + "..."
|
||||
sys.stderr.write(
|
||||
f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges "
|
||||
"manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""UserPromptExpansion hook: run a skill TUI-side, without a model turn.
|
||||
|
||||
Custom slash commands in Claude Code are prompts -- typing `/x` expands skills/x/SKILL.md
|
||||
and sends it to the model. For a command that is really just a local action (flip a
|
||||
setting, print a state) that round trip is waste. This hook gives any skill a TUI-side
|
||||
implementation: if `~/.claude/skills/<command_name>/local.py` exists, it is executed with
|
||||
the command's arguments, its output is shown to you, and the expansion is blocked (exit 2),
|
||||
so nothing reaches the model. Skills without a local.py expand as usual (exit 0).
|
||||
|
||||
Order matters: the hook fires AFTER the skill body has been expanded, so any `!` inline
|
||||
commands in that SKILL.md have already run by the time this executes. A skill with a
|
||||
local.py must therefore not also apply its action via `!` -- it would happen twice.
|
||||
|
||||
Input (stdin JSON): command_name, command_args (list), expanded_prompt, cwd, ...
|
||||
Fails open: any error here means exit 0 and the normal expansion goes ahead.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
name = data.get("command_name") or ""
|
||||
args = data.get("command_args") or []
|
||||
if isinstance(args, str):
|
||||
args = args.split()
|
||||
if not name or "/" in name or name.startswith("."):
|
||||
return
|
||||
script = os.path.expanduser(f"~/.claude/skills/{name}/local.py")
|
||||
if not os.path.isfile(script):
|
||||
return
|
||||
r = subprocess.run(
|
||||
[sys.executable, script, *[str(a) for a in args]],
|
||||
capture_output=True, text=True, timeout=20, cwd=data.get("cwd") or None,
|
||||
)
|
||||
except Exception:
|
||||
return # fail open: let the skill expand normally
|
||||
out = (r.stdout or "") + (r.stderr or "")
|
||||
sys.stderr.write(out.rstrip("\n") + f"\n[/{name}: handled TUI-side by skills/{name}/local.py, no model turn]\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Notification hook: a desktop notification when Claude is waiting on you.
|
||||
|
||||
settings.json wires it to `idle_prompt|permission_prompt` (the Notification matcher is the
|
||||
`notification_type`). Push notifications are off in this setup, so this is the local
|
||||
counterpart of pi's notify.ts. Uses notify-send where present (the Wayland/GUI hosts) and
|
||||
silently no-ops on a headless box or without a session bus. Payload fields are read
|
||||
defensively -- the docs do not pin the Notification input schema: `message`, `title`,
|
||||
`notification_type`, `cwd`.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
TITLES = {
|
||||
"idle_prompt": "Claude Code is waiting for you",
|
||||
"permission_prompt": "Claude Code needs a permission",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
return
|
||||
exe = shutil.which("notify-send")
|
||||
if not exe:
|
||||
return
|
||||
kind = data.get("notification_type") or "notification"
|
||||
title = data.get("title") or TITLES.get(kind, "Claude Code")
|
||||
body = (data.get("message") or "").strip() or kind
|
||||
cwd = data.get("cwd") or ""
|
||||
if cwd:
|
||||
home = os.path.expanduser("~")
|
||||
body += "\n" + (("~" + cwd[len(home):]) if cwd.startswith(home) else cwd)
|
||||
try:
|
||||
subprocess.run(
|
||||
[exe, "--app-name=Claude Code", "--urgency=normal", "--expire-time=8000",
|
||||
# same tag -> a newer notification replaces the previous one instead of stacking
|
||||
f"--hint=string:x-canonical-private-synchronous:claude-code-{kind}",
|
||||
title, body],
|
||||
timeout=5, capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user