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
78 changes: 67 additions & 11 deletions brepl
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
io/file
.getParentFile
.getAbsolutePath)]
(doseq [lib ["hook_utils" "validator" "backup" "installer"]]
(doseq [lib ["hook_utils" "validator" "backup" "installer" "stop_hooks"]]
(load-file (str script-dir "/lib/" lib ".clj"))))

(require '[brepl.lib.validator :as validator]
'[brepl.lib.backup :as backup]
'[brepl.lib.installer :as installer])
'[brepl.lib.installer :as installer]
'[brepl.lib.stop-hooks :as stop-hooks])

(def cli-spec
{:e {:desc "Expression to evaluate (everything after -e is treated as code)"
Expand Down Expand Up @@ -691,12 +692,64 @@
(println "Hooks uninstalled successfully.")
(System/exit 0))

(defn handle-session-end [args]
(if (empty? args)
(do (println "Error: session-id required")
(System/exit 1))
(do (backup/cleanup-session (first args))
(System/exit 0))))
(defn handle-session-end [_args]
(let [input (try
(json/parse-stream *in* true)
(catch Exception e
(binding [*out* *err*]
(println "Error parsing SessionEnd event JSON:" (.getMessage e)))
(System/exit 1)))
session-id (or (:session_id input) "unknown")]
(backup/cleanup-session session-id)
;; Also cleanup stop hook state
(stop-hooks/cleanup-state session-id)
(System/exit 0)))

(defn handle-stop [_args]
;; Initialize stop-hooks dynamic vars for REPL hook execution
(alter-var-root #'stop-hooks/*nrepl-eval-fn* (constantly eval-expression))
(alter-var-root #'stop-hooks/*resolve-port-fn* (constantly resolve-port))
(alter-var-root #'stop-hooks/*resolve-host-fn* (constantly resolve-host))

;; Parse stdin JSON to get session_id
(let [input (try
(json/parse-stream *in* true)
(catch Exception e
(binding [*out* *err*]
(println "Error parsing Stop event JSON:" (.getMessage e)))
(System/exit 1)))
session-id (or (:session_id input) "unknown")]

;; Load and validate hooks config
(let [config (stop-hooks/load-hooks)]
(cond
;; No config file - success, no hooks to run
(nil? config)
(System/exit 0)

;; Parse error in config
(:error config)
(do
(binding [*out* *err*]
(println "Error parsing .brepl/hooks.edn:" (:error config)))
(System/exit 1))

;; Validate config
:else
(let [validation (stop-hooks/validate-hooks config)]
(if-not (:valid? validation)
(do
(binding [*out* *err*]
(println "Invalid .brepl/hooks.edn configuration:")
(println (pr-str (:errors validation))))
(System/exit 1))

;; Run hooks
(let [result (stop-hooks/run-stop-hooks session-id config)]
(when (pos? (:exit-code result))
(binding [*out* *err*]
(println (:message result))))
(System/exit (:exit-code result)))))))))

(defn handle-skill-install [_args]
(let [result (installer/install-skill)]
Expand All @@ -719,22 +772,24 @@
(println "USAGE:")
(println " brepl hook validate [--debug] <file> <content>")
(println " brepl hook eval [--debug] <file>")
(println " brepl hook stop")
(println " brepl hook install [--strict-eval] [--debug]")
(println " brepl hook uninstall")
(println " brepl hook session-end <session-id>")
(println " brepl hook session-end")
(println)
(println "SUBCOMMANDS:")
(println " validate Validate Clojure file syntax before edit")
(println " eval Evaluate file and check for runtime errors")
(println " stop Run stop hooks from .brepl/hooks.edn")
(println " install Install hooks in .claude/settings.local.json")
(println " uninstall Remove hooks from .claude/settings.local.json")
(println " session-end Clean up session backup files")
(println " session-end Clean up session backup files (reads JSON from stdin)")
(println)
(println "FLAGS:")
(println " --debug Save hook JSON input to ./tmp/hooks-requests/")
(println " --strict-eval Exit with error on eval failures (install only)")
(System/exit (if (and subcommand
(not (contains? #{"validate" "eval" "install" "uninstall" "session-end"} subcommand)))
(not (contains? #{"validate" "eval" "stop" "install" "uninstall" "session-end"} subcommand)))
1 0)))

(defn show-balance-help []
Expand Down Expand Up @@ -809,6 +864,7 @@
(case subcommand
"validate" (handle-validate args)
"eval" (handle-eval args)
"stop" (handle-stop args)
"install" (handle-install args)
"uninstall" (handle-uninstall args)
"session-end" (handle-session-end args)
Expand Down
88 changes: 78 additions & 10 deletions lib/installer.clj
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
(ns brepl.lib.installer
"Hook installer for Claude Code integration."
(:require [babashka.fs :as fs]
[cheshire.core :as json]))
[cheshire.core :as json]
[clojure.string :as str]))

(defn settings-local-path []
".claude/settings.local.json")
Expand Down Expand Up @@ -39,16 +40,34 @@
:hooks [{:type "command"
:command (str "brepl hook eval" debug-flag)
:continueOnError (not (:strict-eval opts))}]}]
:Stop [{:matcher ""
:hooks [{:type "command"
:command (str "brepl hook stop" debug-flag)}]}]
:SessionEnd [{:matcher "*"
:hooks [{:type "command"
:command "brepl hook session-end"}]}]}))

(defn brepl-hook?
"Check if a hook entry belongs to brepl."
[hook-entry]
(some #(str/starts-with? (:command %) "brepl hook")
(:hooks hook-entry)))

(defn merge-hook-event
"Merge new brepl entries with existing non-brepl entries for a single event."
[existing-entries new-entries]
(let [non-brepl (remove brepl-hook? existing-entries)]
(into (vec non-brepl) new-entries)))

(defn merge-hooks
"Merge new hooks with existing ones, avoiding duplicates."
"Merge brepl hooks with existing hooks, preserving non-brepl hooks."
[existing-hooks new-hooks]
;; For now, just replace with new hooks
;; In production, would do smarter merging
new-hooks)
(reduce-kv
(fn [acc event-name new-entries]
(let [existing-entries (get acc event-name [])]
(assoc acc event-name (merge-hook-event existing-entries new-entries))))
existing-hooks
new-hooks))

(defn find-brepl-resources
"Find the brepl resources directory."
Expand Down Expand Up @@ -86,17 +105,66 @@
{:success true :message "Skill removed from .claude/skills/brepl"})
{:success true :message "Skill not found (already uninstalled)"})))

(def hooks-template
";; brepl stop hooks configuration

{:stop
[;; Example: Run tests via nREPL after Claude stops
;; {:type :repl
;; :code (clojure.test/run-tests)
;; :required? true ; Must pass - Claude retries until success
;; :max-retries 10 ; Give up after 10 attempts (0 = infinite)
;; :timeout 120}

;; Example: Run linter via bash
;; {:type :bash
;; :command \"clj-kondo --lint src\"
;; :required? false ; Optional - inform on failure but don't retry
;; :timeout 30}
]}

;; Hook fields:
;; :type - :repl or :bash (required)
;; :required? - if true: must pass, retry on failure (default: false)
;; :max-retries - max retry attempts, 0 = infinite (default: 10)
;; :timeout - seconds before timeout (default: 60)
;;
;; REPL hooks:
;; :code - Clojure code as s-expression or string (required)
;;
;; Bash hooks:
;; :command - shell command to run (required)
;; :cwd - working directory (default: \".\")
;; :env - environment variables map (default: {})
")

(defn generate-hooks-template
"Generate .brepl/hooks.edn template if it doesn't exist."
[]
(let [brepl-dir ".brepl"
hooks-file (str brepl-dir "/hooks.edn")]
(if (fs/exists? hooks-file)
{:created false :message "hooks.edn already exists"}
(do
(fs/create-dirs brepl-dir)
(spit hooks-file hooks-template)
{:created true :message "Created .brepl/hooks.edn template"}))))

(defn install-hooks
"Install brepl hooks to .claude/settings.local.json and brepl skill."
[opts]
(let [settings (read-settings)
existing-hooks (get settings :hooks {})
new-hooks (brepl-hook-config opts)
updated-settings (assoc settings :hooks new-hooks)]
merged-hooks (merge-hooks existing-hooks new-hooks)
updated-settings (assoc settings :hooks merged-hooks)]
(write-settings updated-settings)
(let [skill-result (install-skill)]
(if (:success skill-result)
{:success true :message "Hooks and skill installed successfully"}
{:success true :message (str "Hooks installed. " (:message skill-result))}))))
(let [skill-result (install-skill)
template-result (generate-hooks-template)]
{:success true
:message (str "Hooks installed successfully"
(when (:created template-result)
". Created .brepl/hooks.edn template"))})))

(defn uninstall-hooks
"Remove brepl hooks from .claude/settings.local.json."
Expand Down
Loading