Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env bb \"$CLAUDE_PROJECT_DIR\"/scripts/agent-hooks/lint_format_check_hook.clj",
"async": true,
"timeout": 300
}
]
}
]
}
}
17 changes: 17 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env bb \"$(git rev-parse --show-toplevel)/scripts/agent-hooks/lint_format_check_hook.clj\"",
"statusMessage": "Running ceeker format-check and lint",
"timeout": 300
}
]
}
]
}
}
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
.PHONY: test test-install-script lint format-check format ci run clean uber
.PHONY: test test-install-script test-agent-hooks lint format-check format ci run clean uber

test: test-install-script
test: test-install-script test-agent-hooks
mkdir -p target/test-runtime
mkdir -p target/test-runtime/tmux
TMUX= TMUX_PANE= TMUX_TMPDIR=$(CURDIR)/target/test-runtime/tmux XDG_RUNTIME_DIR=$(CURDIR)/target/test-runtime clojure -M:test

test-install-script:
sh test/install_script_test.sh

test-agent-hooks:
sh test/agent_hook_test.sh

lint:
clojure -M:lint

Expand Down
9 changes: 9 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,15 @@ make lint
make format
```

ceeker repo には、Claude Code と Codex 向けの repo-local hook 設定を同梱しています。

- `.claude/settings.json`: `Write|Edit|MultiEdit|Bash` の `PostToolUse` 後に `scripts/agent-hooks/lint_format_check_hook.clj` を非同期実行
- `.codex/hooks.json`: `PostToolUse` 後に同じスクリプトを実行

この hook は babashka (`bb`) で動作し、`make format-check` と `make lint` を順に実行して結果を agent に返します。Claude Code は repo を開くだけで有効です。Codex は feature flag が必要なので、未設定なら `~/.codex/config.toml` に `[features] codex_hooks = true` を追加するか `codex features enable codex_hooks` を実行してください。

補足: Codex の `PostToolUse` は 2026-04-14 時点の公式仕様では `Bash` のみが発火対象です。そのため ceeker の Codex hook も、ファイル変更の可能性がある Bash 実行に対してのみ `format-check` / `lint` を走らせます。

## CI

GitHub Actions で以下のジョブが PR / main push 時に実行されます:
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,15 @@ make lint
make format
```

The ceeker repo ships repo-local hooks for both Claude Code and Codex.

- `.claude/settings.json`: runs `scripts/agent-hooks/lint_format_check_hook.clj` asynchronously after `PostToolUse` for `Write|Edit|MultiEdit|Bash`
- `.codex/hooks.json`: runs the same script after `PostToolUse`

The hook is implemented as a babashka (`bb`) script. It runs `make format-check` and `make lint` in sequence and reports the result back to the agent. Claude Code picks this up automatically when you open the repo. For Codex, ensure the feature flag is enabled by adding `[features] codex_hooks = true` to `~/.codex/config.toml` or by running `codex features enable codex_hooks`.

Note: per the official Codex hooks documentation as of April 14, 2026, `PostToolUse` currently fires only for `Bash`. ceeker therefore limits the Codex hook to Bash commands that are likely to have modified the workspace before running `format-check` and `lint`.

## CI

GitHub Actions runs the following jobs on PRs and pushes to main:
Expand Down
110 changes: 110 additions & 0 deletions scripts/agent-hooks/lint_format_check_hook.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env bb

(ns lint-format-check-hook
(:require [cheshire.core :as json]
[clojure.java.shell :as sh]
[clojure.string :as str]))

(def edit-tools #{"Write" "Edit" "MultiEdit"})

(def validation-commands
#{"make lint"
"make format-check"
"make ci"
"clojure -M:lint"
"clojure -M:format-check"
"clojure -M:test"})

(def mutating-bash-patterns
[#"\bapply_patch\b"
#"\bgit\s+apply\b"
#"\bpatch\b"
#"\bsed\s+-i\b"
#"\bperl\s+-pi\b"
#"\bmv\b"
#"\bcp\b"
#"\brm\b"
#"\bmkdir\b"
#"\btouch\b"
#"\btee\b"
#"\bchmod\b"
#"\bchown\b"
#">>"
#"(^|[^>])>([^>]|$)"])

(defn read-payload []
(let [raw (str/trim (slurp *in*))]
(if (str/blank? raw)
{}
(json/parse-string raw true))))

(defn normalize-command [command]
(->> (str/split (str/trim command) #"\s+")
(remove str/blank?)
(str/join " ")))

(defn should-run [payload]
(if (not= "PostToolUse" (:hook_event_name payload))
[false nil]
(let [tool-name (str (:tool_name payload))
command (str (get-in payload [:tool_input :command] ""))
normalized-command (normalize-command command)]
(cond
(contains? edit-tools tool-name)
[true tool-name]

(not= "Bash" tool-name)
[false nil]

(contains? validation-commands normalized-command)
[false nil]

(some #(re-find % command) mutating-bash-patterns)
[true "Bash command"]

:else
[false nil]))))

(defn summarize-output [output]
(let [trimmed (str/trim output)]
(if (str/blank? trimmed)
""
(let [summary (->> (str/split-lines trimmed)
(take 20)
(str/join "\n"))]
(if (> (count summary) 2000)
(str (subs summary 0 2000) "\n...")
summary)))))

(defn run-target [target]
(let [{:keys [exit out err]} (sh/sh "make" target)
combined (->> [out err]
(map str/trim)
(remove str/blank?)
(str/join "\n"))]
[(zero? exit) combined]))

(defn emit-message [message]
(println (json/generate-string {:systemMessage message})))

(defn failure-message [target trigger output]
(let [summary (summarize-output output)
message (str target " failed after " trigger ".")]
(if (str/blank? summary)
message
(str message "\n" summary))))

(defn -main []
(let [payload (read-payload)
[should-validate trigger] (should-run payload)]
(when should-validate
(let [[format-ok format-output] (run-target "format-check")]
(if-not format-ok
(emit-message (failure-message "format-check" trigger format-output))
(let [[lint-ok lint-output] (run-target "lint")]
(if lint-ok
(emit-message (str "format-check and lint passed after " trigger "."))
(emit-message (failure-message "lint" trigger lint-output))))))))
0)

(System/exit (-main))
173 changes: 173 additions & 0 deletions test/agent_hook_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env sh
set -eu

REPO_ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
HOOK_SCRIPT="${REPO_ROOT}/scripts/agent-hooks/lint_format_check_hook.clj"
TEST_TMP_ROOT=$(mktemp -d)
PASS_COUNT=0
FAIL_COUNT=0

cleanup() {
rm -rf "${TEST_TMP_ROOT}"
}

pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf 'PASS %s\n' "$1"
}

fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
printf 'FAIL %s: %s\n' "$1" "$2" >&2
}

assert_contains() {
file_path="$1"
expected="$2"
label="$3"
if ! grep -F "$expected" "$file_path" >/dev/null 2>&1; then
fail "$label" "expected '$expected' in $file_path"
return 1
fi
return 0
}

assert_empty() {
file_path="$1"
label="$2"
if [ -s "$file_path" ]; then
fail "$label" "expected empty file: $file_path"
return 1
fi
return 0
}

assert_line_count() {
file_path="$1"
expected="$2"
label="$3"
actual=$(wc -l <"$file_path" | tr -d ' ')
if [ "$actual" != "$expected" ]; then
fail "$label" "expected $expected lines in $file_path, got $actual"
return 1
fi
return 0
}

setup_case() {
CASE_DIR="${TEST_TMP_ROOT}/$1"
STUB_DIR="${CASE_DIR}/bin"
LOG_FILE="${CASE_DIR}/make.log"
OUTPUT_FILE="${CASE_DIR}/stdout.json"
INPUT_FILE="${CASE_DIR}/input.json"
MAKE_BEHAVIOR="${CASE_DIR}/make-behavior"

mkdir -p "${STUB_DIR}"
: >"${LOG_FILE}"
: >"${OUTPUT_FILE}"
: >"${MAKE_BEHAVIOR}"

cat >"${STUB_DIR}/make" <<'EOF'
#!/usr/bin/env sh
set -eu
printf '%s\n' "$*" >> "${HOOK_TEST_MAKE_LOG}"

if [ -f "${HOOK_TEST_MAKE_BEHAVIOR}" ]; then
while IFS='=' read -r key value; do
[ -n "${key}" ] || continue
if [ "$1" = "${key}" ]; then
if [ "${value}" = "0" ]; then
printf '%s ok\n' "$1"
exit 0
fi
printf '%s failed\n' "$1" >&2
exit "${value}"
fi
done < "${HOOK_TEST_MAKE_BEHAVIOR}"
fi

printf '%s ok\n' "$1"
EOF
chmod +x "${STUB_DIR}/make"
}

run_hook() {
payload="$1"
printf '%s\n' "$payload" >"${INPUT_FILE}"
HOOK_TEST_MAKE_LOG="${LOG_FILE}" \
HOOK_TEST_MAKE_BEHAVIOR="${MAKE_BEHAVIOR}" \
PATH="${STUB_DIR}:${PATH}" \
bb "${HOOK_SCRIPT}" <"${INPUT_FILE}" >"${OUTPUT_FILE}"
}

test_write_runs_format_and_lint() {
setup_case "write-runs-checks"

if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"src/ceeker/core.clj"}}'; then
assert_line_count "${LOG_FILE}" 2 "write runs both make targets" || return 1
assert_contains "${LOG_FILE}" "format-check" "write runs format-check first" || return 1
assert_contains "${LOG_FILE}" "lint" "write runs lint second" || return 1
assert_contains "${OUTPUT_FILE}" "format-check and lint passed after Write" "write pass message" || return 1
pass "Write tool triggers format-check and lint"
return 0
fi

fail "Write tool triggers format-check and lint" "hook command failed"
return 1
}

test_read_only_bash_skips_checks() {
setup_case "bash-skip"

if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"rg hook README.md"}}'; then
assert_empty "${LOG_FILE}" "read-only bash should skip make" || return 1
assert_empty "${OUTPUT_FILE}" "read-only bash should not emit message" || return 1
pass "read-only Bash command skips checks"
return 0
fi

fail "read-only Bash command skips checks" "hook command failed"
return 1
}

test_mutating_bash_runs_checks() {
setup_case "bash-runs-checks"

if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git apply /tmp/change.patch"}}'; then
assert_line_count "${LOG_FILE}" 2 "mutating bash runs both make targets" || return 1
assert_contains "${OUTPUT_FILE}" "format-check and lint passed after Bash command" "bash pass message" || return 1
pass "mutating Bash command triggers checks"
return 0
fi

fail "mutating Bash command triggers checks" "hook command failed"
return 1
}

test_format_check_failure_reports_message() {
setup_case "format-failure"
printf 'format-check=1\n' >"${MAKE_BEHAVIOR}"

if run_hook '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"src/ceeker/core.clj"}}'; then
assert_line_count "${LOG_FILE}" 1 "format-check failure should stop before lint" || return 1
assert_contains "${OUTPUT_FILE}" "format-check failed after Edit" "format failure message" || return 1
assert_contains "${OUTPUT_FILE}" "format-check failed" "format failure output" || return 1
pass "format-check failure is reported"
return 0
fi

fail "format-check failure is reported" "hook command failed"
return 1
}

test_write_runs_format_and_lint
test_read_only_bash_skips_checks
test_mutating_bash_runs_checks
test_format_check_failure_reports_message

if [ "${FAIL_COUNT}" -ne 0 ]; then
printf '%s test(s) failed\n' "${FAIL_COUNT}" >&2
exit 1
fi

printf '%s test(s) passed\n' "${PASS_COUNT}"
Loading