diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ccd6125 --- /dev/null +++ b/.claude/settings.json @@ -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 + } + ] + } + ] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..5f23d89 --- /dev/null +++ b/.codex/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/Makefile b/Makefile index 11a2512..6fb7635 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.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 @@ -8,6 +8,9 @@ test: test-install-script test-install-script: sh test/install_script_test.sh +test-agent-hooks: + sh test/agent_hook_test.sh + lint: clojure -M:lint diff --git a/README.ja.md b/README.ja.md index 6161355..8c4f890 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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 時に実行されます: diff --git a/README.md b/README.md index acca111..eed0108 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/scripts/agent-hooks/lint_format_check_hook.clj b/scripts/agent-hooks/lint_format_check_hook.clj new file mode 100644 index 0000000..8c91034 --- /dev/null +++ b/scripts/agent-hooks/lint_format_check_hook.clj @@ -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)) diff --git a/test/agent_hook_test.sh b/test/agent_hook_test.sh new file mode 100644 index 0000000..7a649fa --- /dev/null +++ b/test/agent_hook_test.sh @@ -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}"