a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
628 lines
20 KiB
Go
628 lines
20 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/agents"
|
|
)
|
|
|
|
// CurrentPreToolUseMatcher is the canonical matcher pattern we bake
|
|
// into Claude Code's PreToolUse hook. Older versions used
|
|
// "Read|Grep", "Read|Grep|Glob", "Read|Grep|Glob|Task",
|
|
// "Read|Grep|Glob|Task|Bash", or "Read|Grep|Glob|Task|Bash|Edit|Write";
|
|
// upgradeGortexMatcher rewrites those in place. Edit and Write are
|
|
// included so the hook can redirect whole-file rewrites of indexed
|
|
// source to the Gortex MCP edit tools (gated by GORTEX_HOOK_BLOCK_EDIT
|
|
// in the hook itself). The two mcp__gortex__ read tools are included so
|
|
// the hook can also nudge a full-body read_file / get_editing_context
|
|
// toward compress_bodies / search_text — the one gap that fires once
|
|
// the agent is already inside a Gortex tool (gated by
|
|
// GORTEX_HOOK_FORCE_COMPRESS for the hard-deny posture).
|
|
const CurrentPreToolUseMatcher = "Read|Grep|Glob|Task|Bash|Edit|Write|mcp__gortex__read_file|mcp__gortex__get_editing_context"
|
|
|
|
// CurrentPostToolUseMatcher names the tools whose response the
|
|
// PostToolUse hook augments. Only the read-shaped tools have an obvious
|
|
// "enrich this output with graph context" payload — Bash / Edit / Write
|
|
// don't benefit from a post-call graph snapshot, so they're omitted.
|
|
const CurrentPostToolUseMatcher = "Read|Grep|Glob"
|
|
|
|
// HookModeDeny / HookModeEnrich / HookModeConsultUnlock /
|
|
// HookModeAdaptiveNudge are the posture strings the installer accepts.
|
|
// They mirror hooks.Mode without importing it (the claudecode package
|
|
// is a leaf of the agents adapter tree and must stay import-free of
|
|
// hooks).
|
|
const (
|
|
HookModeDeny = "deny"
|
|
HookModeEnrich = "enrich"
|
|
HookModeConsultUnlock = "consult-unlock"
|
|
HookModeAdaptiveNudge = "nudge"
|
|
)
|
|
|
|
// normalizeHookMode maps user input to a canonical mode. Empty or
|
|
// unknown values fall through to deny so existing installs and shell
|
|
// typos preserve the original behavior. "adaptive-nudge" is accepted
|
|
// as an alias for the canonical "nudge".
|
|
func normalizeHookMode(mode string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case HookModeEnrich:
|
|
return HookModeEnrich
|
|
case HookModeConsultUnlock:
|
|
return HookModeConsultUnlock
|
|
case HookModeAdaptiveNudge, "adaptive-nudge":
|
|
return HookModeAdaptiveNudge
|
|
default:
|
|
return HookModeDeny
|
|
}
|
|
}
|
|
|
|
// hookCommandWithMode appends `--mode=<mode>` to the base hook command
|
|
// when mode is non-default. The deny mode is the historical default —
|
|
// emitting it bare keeps existing settings.json diffs minimal during an
|
|
// upgrade. Every other posture is emitted explicitly so the installed
|
|
// command unambiguously declares itself.
|
|
func hookCommandWithMode(base, mode string) string {
|
|
switch normalizeHookMode(mode) {
|
|
case HookModeEnrich:
|
|
return base + " --mode=enrich"
|
|
case HookModeConsultUnlock:
|
|
return base + " --mode=consult-unlock"
|
|
case HookModeAdaptiveNudge:
|
|
return base + " --mode=nudge"
|
|
default:
|
|
return base
|
|
}
|
|
}
|
|
|
|
// ResolveHookCommand returns the shell command to bake into Claude
|
|
// Code's hook config. It routes through agents.ResolveGortexHookBinary,
|
|
// which shares the same same-file decision core as the MCP server
|
|
// stanza (agents.ResolveGortexCommand): the hook and the MCP stanza
|
|
// must resolve to the same binary file whenever the running binary is
|
|
// usable, so a side-by-side install can never point the hook at a
|
|
// different daemon than the one serving the session's graph tools. The
|
|
// hook keeps an absolute-path preference (it avoids PATH-at-fire-time
|
|
// fragility); it falls back to bare "gortex hook" only when no stable
|
|
// binary is resolvable at all.
|
|
//
|
|
// A warning is written to w on the bare fallback because it relies on
|
|
// PATH resolution at hook-fire time — fragile when the user's shell
|
|
// environment differs between Claude Code and a terminal.
|
|
func ResolveHookCommand(w io.Writer) string {
|
|
bin := agents.ResolveGortexHookBinary()
|
|
if bin == "gortex" && w != nil {
|
|
fmt.Fprintln(w,
|
|
"[gortex init] warning: `gortex` not found on PATH; "+
|
|
"writing bare \"gortex hook\" into settings — install gortex to PATH for a stable hook command")
|
|
}
|
|
return shellSafeHookBinary(bin) + " hook"
|
|
}
|
|
|
|
// shellSafeHookBinary normalizes a resolved binary path into a form
|
|
// safe to embed in a shell-executed hook command. Claude Code runs
|
|
// hooks through a shell; on Windows that shell is Git Bash, which
|
|
// treats backslashes as escape characters — a native path like
|
|
// C:\Users\me\gortex.exe is mangled to C:Usersmegortex.exe (\U, \m, …
|
|
// swallowed) and the hook fails with "command not found". Forward
|
|
// slashes survive: Git Bash maps C:/Users/... back to a native path
|
|
// when it spawns the .exe. The replacement is unconditional rather
|
|
// than Windows-guarded — any backslash in an unquoted shell command is
|
|
// a bug regardless of OS, and a real Unix binary path never contains
|
|
// one as a separator.
|
|
func shellSafeHookBinary(path string) string {
|
|
return strings.ReplaceAll(path, "\\", "/")
|
|
}
|
|
|
|
// HookCommandPathIsEphemeral reports whether cmd's binary path lives
|
|
// in a location that is wiped between sessions (system tmpdirs, the
|
|
// macOS go-build cache) or no longer exists on disk. Used by
|
|
// healStaleHookCommands to detect settings.json entries that
|
|
// outlived their backing binary.
|
|
func HookCommandPathIsEphemeral(cmd string) bool {
|
|
fields := strings.Fields(cmd)
|
|
if len(fields) == 0 {
|
|
return false
|
|
}
|
|
bin := fields[0]
|
|
ephemeralPrefixes := []string{"/tmp/", "/var/folders/", "/private/tmp/", "/private/var/folders/"}
|
|
for _, p := range ephemeralPrefixes {
|
|
if strings.HasPrefix(bin, p) {
|
|
return true
|
|
}
|
|
}
|
|
// An absolute path that no longer resolves to a file is also stale.
|
|
if filepath.IsAbs(bin) {
|
|
if _, err := os.Stat(bin); err != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// healStaleHookCommands rewrites Gortex hook entries whose command
|
|
// points at an ephemeral or missing binary path. Returns the number
|
|
// of entries rewritten. Non-Gortex entries are left alone; Gortex
|
|
// entries whose path is healthy are also left alone.
|
|
func healStaleHookCommands(hooks map[string]any, newCommand string) int {
|
|
healed := 0
|
|
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
|
|
list, ok := hooks[event].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, h := range list {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
inner, ok := hm["hooks"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, e := range inner {
|
|
em, ok := e.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cmd, _ := em["command"].(string)
|
|
if !commandInvokesGortexHook(cmd) {
|
|
continue
|
|
}
|
|
if !HookCommandPathIsEphemeral(cmd) {
|
|
continue
|
|
}
|
|
em["command"] = newCommand
|
|
healed++
|
|
}
|
|
}
|
|
}
|
|
return healed
|
|
}
|
|
|
|
func appendHookEntry(hooks map[string]any, event string, entry map[string]any) {
|
|
if _, ok := hooks[event]; !ok {
|
|
hooks[event] = []any{}
|
|
}
|
|
list := hooks[event].([]any)
|
|
hooks[event] = append(list, entry)
|
|
}
|
|
|
|
// upgradeGortexMatcher rewrites older PreToolUse matchers to the
|
|
// current CurrentPreToolUseMatcher. Returns true when a change was
|
|
// made. Handles every historical matcher we've shipped; anything
|
|
// not in that set is left alone.
|
|
func upgradeGortexMatcher(hooks map[string]any) bool {
|
|
pre, ok := hooks["PreToolUse"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
legacyMatchers := map[string]bool{
|
|
"Read|Grep": true,
|
|
"Read|Grep|Glob": true,
|
|
"Read|Grep|Glob|Task": true,
|
|
"Read|Grep|Glob|Task|Bash": true,
|
|
"Read|Grep|Glob|Task|Bash|Edit|Write": true,
|
|
}
|
|
upgraded := false
|
|
for _, h := range pre {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
matcher, _ := hm["matcher"].(string)
|
|
if !legacyMatchers[matcher] {
|
|
continue
|
|
}
|
|
if !entryInvokesGortexHook(hm) {
|
|
continue
|
|
}
|
|
hm["matcher"] = CurrentPreToolUseMatcher
|
|
upgraded = true
|
|
}
|
|
return upgraded
|
|
}
|
|
|
|
// entryInvokesGortexHook returns true when any hooks[*].command
|
|
// looks like a Gortex hook invocation.
|
|
func entryInvokesGortexHook(entry map[string]any) bool {
|
|
inner, ok := entry["hooks"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, e := range inner {
|
|
em, ok := e.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cmd, _ := em["command"].(string)
|
|
if commandInvokesGortexHook(cmd) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dedupGortexEntries collapses duplicate Gortex hook entries inside
|
|
// hooks[event] down to the first one. Non-Gortex entries are
|
|
// preserved in order. Returns the number of duplicates removed.
|
|
func dedupGortexEntries(hooks map[string]any, event string) int {
|
|
list, ok := hooks[event].([]any)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
seenGortex := false
|
|
kept := make([]any, 0, len(list))
|
|
removed := 0
|
|
for _, h := range list {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
kept = append(kept, h)
|
|
continue
|
|
}
|
|
if !entryInvokesGortexHook(hm) {
|
|
kept = append(kept, h)
|
|
continue
|
|
}
|
|
if seenGortex {
|
|
removed++
|
|
continue
|
|
}
|
|
seenGortex = true
|
|
kept = append(kept, h)
|
|
}
|
|
if removed > 0 {
|
|
hooks[event] = kept
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// commandInvokesGortexHook returns true when cmd is a Gortex hook
|
|
// invocation. Splits on whitespace and checks that "hook" is a
|
|
// standalone token and that "gortex" appears in the binary path
|
|
// component.
|
|
func commandInvokesGortexHook(cmd string) bool {
|
|
fields := strings.Fields(cmd)
|
|
if len(fields) < 2 {
|
|
return false
|
|
}
|
|
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
|
|
return false
|
|
}
|
|
return slices.Contains(fields[1:], "hook")
|
|
}
|
|
|
|
// rewriteGortexHookMode rewrites every Gortex hook entry's command
|
|
// across all events so it matches newCommand. Used when the install
|
|
// posture changes (deny ↔ enrich) — the existing entries already
|
|
// invoke `gortex hook` but with the wrong `--mode=...` suffix; we
|
|
// re-stamp them in place instead of removing + re-adding so user-added
|
|
// fields (timeout, statusMessage) are preserved. Returns the count of
|
|
// rewritten entries.
|
|
func rewriteGortexHookMode(hooks map[string]any, newCommand string) int {
|
|
rewritten := 0
|
|
for _, event := range []string{"PreToolUse", "PostToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
|
|
list, ok := hooks[event].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, h := range list {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
inner, ok := hm["hooks"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, e := range inner {
|
|
em, ok := e.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cmd, _ := em["command"].(string)
|
|
if !commandInvokesGortexHook(cmd) {
|
|
continue
|
|
}
|
|
if cmd == newCommand {
|
|
continue
|
|
}
|
|
em["command"] = newCommand
|
|
rewritten++
|
|
}
|
|
}
|
|
}
|
|
return rewritten
|
|
}
|
|
|
|
// removeGortexHookEntries drops every entry under hooks[event] that
|
|
// invokes `gortex hook`, preserving entries owned by other tools.
|
|
// Returns the number of entries removed. Used to clean up PostToolUse
|
|
// when the installer switches back from enrich to deny mode.
|
|
func removeGortexHookEntries(hooks map[string]any, event string) int {
|
|
list, ok := hooks[event].([]any)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
removed := 0
|
|
kept := make([]any, 0, len(list))
|
|
for _, h := range list {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
kept = append(kept, h)
|
|
continue
|
|
}
|
|
if entryInvokesGortexHook(hm) {
|
|
removed++
|
|
continue
|
|
}
|
|
kept = append(kept, h)
|
|
}
|
|
if removed > 0 {
|
|
if len(kept) == 0 {
|
|
delete(hooks, event)
|
|
} else {
|
|
hooks[event] = kept
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
// hasGortexHookEntry returns true when the given event already has a
|
|
// hook entry that invokes `gortex hook`.
|
|
func hasGortexHookEntry(hooks map[string]any, event string) bool {
|
|
existing, ok := hooks[event].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, h := range existing {
|
|
hm, ok := h.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if entryInvokesGortexHook(hm) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// InstallHook is the top-level "make settings.local.json hooks
|
|
// match the current Gortex config" operation. It reads the file,
|
|
// heals stale commands, upgrades old matchers, dedupes repeat
|
|
// entries, then installs any missing Gortex hooks (PreToolUse,
|
|
// PreCompact, Stop, SessionStart, and — in enrich mode — PostToolUse).
|
|
// Writes back atomically via the shared helper.
|
|
//
|
|
// This function intentionally accepts a plain filesystem path
|
|
// rather than an Env — the same helper is used for project-level
|
|
// (.claude/settings.local.json) and user-level (~/.claude/…) files.
|
|
//
|
|
// Delegates to InstallHookWithMode with the deny posture for callers
|
|
// that don't care about hook mode (mostly tests and back-compat paths).
|
|
func InstallHook(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
|
|
return InstallHookWithMode(w, settingsPath, HookModeDeny, opts)
|
|
}
|
|
|
|
// InstallHookWithMode is the mode-aware variant. mode is one of
|
|
// HookModeDeny (default — install PreToolUse with deny behavior, no
|
|
// PostToolUse) or HookModeEnrich (install PreToolUse in soft-context
|
|
// mode plus a PostToolUse entry that augments tool output with graph
|
|
// context). Switching modes between installs rewrites the existing
|
|
// Gortex hook command in place and adds or removes the PostToolUse
|
|
// entry to match.
|
|
func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts agents.ApplyOpts) (agents.FileAction, error) {
|
|
mode = normalizeHookMode(mode)
|
|
var settings map[string]any
|
|
existed := false
|
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
|
existed = true
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
settings = make(map[string]any)
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return agents.FileAction{}, fmt.Errorf("read %s: %w", settingsPath, err)
|
|
} else {
|
|
settings = make(map[string]any)
|
|
}
|
|
|
|
baseCommand := ResolveHookCommand(w)
|
|
hookCommand := hookCommandWithMode(baseCommand, mode)
|
|
|
|
if _, ok := settings["hooks"]; !ok {
|
|
settings["hooks"] = make(map[string]any)
|
|
}
|
|
hooks := settings["hooks"].(map[string]any)
|
|
|
|
healedCount := healStaleHookCommands(hooks, hookCommand)
|
|
matcherUpgraded := upgradeGortexMatcher(hooks)
|
|
modeRewriteCount := rewriteGortexHookMode(hooks, hookCommand)
|
|
dedupedCount := dedupGortexEntries(hooks, "PreToolUse") +
|
|
dedupGortexEntries(hooks, "PreCompact") +
|
|
dedupGortexEntries(hooks, "PostToolUse") +
|
|
dedupGortexEntries(hooks, "Stop") +
|
|
dedupGortexEntries(hooks, "SessionStart") +
|
|
dedupGortexEntries(hooks, "UserPromptSubmit")
|
|
|
|
// PostToolUse is only present when mode=enrich. Removing it on a
|
|
// switch to any other posture keeps settings.json clean — agents
|
|
// won't fire a no-op hook on every Read/Grep response.
|
|
postToolUseRemoved := 0
|
|
if mode != HookModeEnrich {
|
|
postToolUseRemoved = removeGortexHookEntries(hooks, "PostToolUse")
|
|
}
|
|
|
|
preToolUseInstalled := hasGortexHookEntry(hooks, "PreToolUse")
|
|
preCompactInstalled := hasGortexHookEntry(hooks, "PreCompact")
|
|
stopInstalled := hasGortexHookEntry(hooks, "Stop")
|
|
sessionStartInstalled := hasGortexHookEntry(hooks, "SessionStart")
|
|
userPromptSubmitInstalled := hasGortexHookEntry(hooks, "UserPromptSubmit")
|
|
postToolUseInstalled := hasGortexHookEntry(hooks, "PostToolUse")
|
|
|
|
if !preToolUseInstalled {
|
|
appendHookEntry(hooks, "PreToolUse", map[string]any{
|
|
"matcher": CurrentPreToolUseMatcher,
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 3000,
|
|
"statusMessage": "Enriching with Gortex graph context...",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if !preCompactInstalled {
|
|
appendHookEntry(hooks, "PreCompact", map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 3000,
|
|
"statusMessage": "Injecting Gortex orientation snapshot...",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if !stopInstalled {
|
|
appendHookEntry(hooks, "Stop", map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 5000,
|
|
"statusMessage": "Running Gortex post-task diagnostics...",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if !sessionStartInstalled {
|
|
// SessionStart fires at the start of a new or resumed session
|
|
// — a perfect moment to inject the Gortex orientation snapshot
|
|
// so the first turn doesn't have to call graph_stats. It
|
|
// complements PreCompact (which fires on summary boundaries).
|
|
appendHookEntry(hooks, "SessionStart", map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 3000,
|
|
"statusMessage": "Loading Gortex graph orientation...",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if !userPromptSubmitInstalled {
|
|
// UserPromptSubmit fires before every user turn — the moment to
|
|
// proactively inject graph symbols relevant to the prompt so the
|
|
// model reaches for Gortex instead of grepping. It is best-effort
|
|
// and time-bounded; a miss is a silent no-op.
|
|
appendHookEntry(hooks, "UserPromptSubmit", map[string]any{
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 3000,
|
|
"statusMessage": "Surfacing relevant Gortex symbols...",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
// PostToolUse is only installed in enrich mode. It augments
|
|
// Grep / Glob / Read responses with graph context (enclosing
|
|
// symbols, file footprints) so the agent sees the graph value
|
|
// adjacent to the raw output instead of via a deny redirect.
|
|
postToolUseAdded := false
|
|
if mode == HookModeEnrich && !postToolUseInstalled {
|
|
appendHookEntry(hooks, "PostToolUse", map[string]any{
|
|
"matcher": CurrentPostToolUseMatcher,
|
|
"hooks": []any{
|
|
map[string]any{
|
|
"type": "command",
|
|
"command": hookCommand,
|
|
"timeout": 3000,
|
|
"statusMessage": "Layering Gortex graph context onto tool output...",
|
|
},
|
|
},
|
|
})
|
|
postToolUseAdded = true
|
|
}
|
|
|
|
allPresent := preToolUseInstalled && preCompactInstalled && stopInstalled && sessionStartInstalled &&
|
|
userPromptSubmitInstalled && (mode != HookModeEnrich || postToolUseInstalled)
|
|
noChanges := allPresent && !matcherUpgraded && dedupedCount == 0 && healedCount == 0 &&
|
|
modeRewriteCount == 0 && postToolUseRemoved == 0 && !postToolUseAdded
|
|
if noChanges {
|
|
if w != nil {
|
|
fmt.Fprintf(w, "[gortex init] all hooks already present in %s\n", settingsPath)
|
|
}
|
|
return agents.FileAction{Path: settingsPath, Action: agents.ActionSkip, Reason: "already-configured"}, nil
|
|
}
|
|
|
|
if opts.DryRun {
|
|
action := agents.ActionWouldCreate
|
|
if existed {
|
|
action = agents.ActionWouldMerge
|
|
}
|
|
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
|
|
}
|
|
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return agents.FileAction{}, err
|
|
}
|
|
if err := agents.AtomicWriteFile(settingsPath, data, 0o644); err != nil {
|
|
return agents.FileAction{}, err
|
|
}
|
|
|
|
// Report exactly what changed — helpful for the doctor subcommand
|
|
// and for reassuring users during `gortex init` re-runs.
|
|
var changes []string
|
|
if matcherUpgraded {
|
|
changes = append(changes, "upgraded PreToolUse matcher")
|
|
}
|
|
if dedupedCount > 0 {
|
|
changes = append(changes, fmt.Sprintf("removed %d duplicate entries", dedupedCount))
|
|
}
|
|
if healedCount > 0 {
|
|
changes = append(changes, fmt.Sprintf("rewrote %d stale hook path(s)", healedCount))
|
|
}
|
|
if !preToolUseInstalled {
|
|
changes = append(changes, "installed PreToolUse")
|
|
}
|
|
if !preCompactInstalled {
|
|
changes = append(changes, "installed PreCompact")
|
|
}
|
|
if !stopInstalled {
|
|
changes = append(changes, "installed Stop")
|
|
}
|
|
if !sessionStartInstalled {
|
|
changes = append(changes, "installed SessionStart")
|
|
}
|
|
if !userPromptSubmitInstalled {
|
|
changes = append(changes, "installed UserPromptSubmit")
|
|
}
|
|
if postToolUseAdded {
|
|
changes = append(changes, "installed PostToolUse (enrich mode)")
|
|
}
|
|
if postToolUseRemoved > 0 {
|
|
changes = append(changes, fmt.Sprintf("removed PostToolUse (switched to %s mode)", mode))
|
|
}
|
|
if modeRewriteCount > 0 {
|
|
changes = append(changes, fmt.Sprintf("rewrote %d hook command(s) for mode=%s", modeRewriteCount, mode))
|
|
}
|
|
if w != nil {
|
|
fmt.Fprintf(w, "[gortex init] %s in %s\n", strings.Join(changes, ", "), settingsPath)
|
|
}
|
|
action := agents.ActionCreate
|
|
if existed {
|
|
action = agents.ActionMerge
|
|
}
|
|
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
|
|
}
|