Go 1218 lines
// Package hooks wires and unwires eeco's two opt-in, reversible
// integration points — the only touches outside the gitignored
// workspace (Constraint 2).
//
// - a local .git/hooks/pre-commit that runs leak-guard, installed
// only when no pre-commit hook exists and removed only when the
// on-disk script is byte-identical to what eeco wrote;
// - one namespaced entry in the AI CLI's user-global JSON settings
// file that emits a one-line queue reminder at session start.
//
// Every action is recorded in a ledger inside the workspace
// (<workspace>/state/hooks.json) so it is cleanly undoable, and the
// settings-file edit is backed up (into the workspace) and re-validated
// after the write, restoring the backup if the result is not valid
// JSON. Nothing here ever commits, pushes, or writes the tracked tree.
package hooks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// Hook names accepted by Toggle and reported by Status.
const (
PreCommit = "pre-commit"
PostMerge = "post-merge"
SessionStart = "session-start"
CommitMsg = "commit-msg"
CommitGuard = "commit-guard"
)
// Names lists the toggleable hook names in report order.
var Names = []string{PreCommit, PostMerge, SessionStart, CommitMsg, CommitGuard}
// ledgerName is the reversibility record inside <workspace>/state.
const ledgerName = "hooks.json"
// backupSubdir is where the original settings file is copied before an
// edit, inside <workspace>/state (never beside the user's own file).
const backupSubdir = "backups"
// preCommitMarker is a unique line embedded in eeco's pre-commit
// script. It is the exact-match fallback identifier when the ledger
// hash is unavailable; an unrelated hook never carries it.
const preCommitMarker = "eeco-managed-pre-commit-v1"
// postMergeMarker is the exact-match fallback identifier in eeco's
// post-merge script, the analog of preCommitMarker.
const postMergeMarker = "eeco-managed-post-merge-v1"
// sessionToken is the path-independent namespace marker carried in the
// session-start hook command. Removal matches on this token, so a moved
// eeco binary is still cleanly removable.
const sessionToken = "hooks session-emit"
// commitGuardToken is the path-independent namespace marker carried in
// the commit-guard PreToolUse hook command — the analog of sessionToken
// for the harness PreToolUse channel, so a moved eeco binary stays
// cleanly removable.
const commitGuardToken = "hooks commit-guard-check"
// ErrSessionNotConfigured is returned by the session-start operations
// when neither delivery channel is configured. It is a clean, expected
// condition (not a failure): nothing is touched. No brand path is baked
// in, per Constraint 4 — the operator points eeco at the file or files.
var ErrSessionNotConfigured = errors.New(
"session-start not configured: set session_settings_path (or " +
"EECO_SESSION_SETTINGS) for an AI CLI that reads a JSON settings " +
"file, and/or set session_files in config.local for an assistant " +
"that reads a plain text/markdown file")
// ErrCommitGuardNotConfigured is returned by the commit-guard operations
// when no settings file is configured. Like ErrSessionNotConfigured it is
// a clean, expected condition (not a failure): nothing is touched. The
// commit-guard installs a PreToolUse group into the same Claude settings
// file the session-start JSON channel uses.
var ErrCommitGuardNotConfigured = errors.New(
"commit-guard not configured: set session_settings_path (or " +
"EECO_SESSION_SETTINGS) to the AI CLI's JSON settings file so eeco " +
"can install the PreToolUse hook")
// record is one hook's reversibility state. Files is set only on the
// session-start record and only when the file-delivery channel
// (`session_files`) wired one or more targets — additive, so older
// ledgers without the field still load.
type record struct {
Installed bool `json:"installed"`
Path string `json:"path,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Backup string `json:"backup,omitempty"`
At string `json:"at,omitempty"`
Files []fileRecord `json:"files,omitempty"`
}
// ledger is the persisted state of the hooks. PostMerge, CommitMsg, and
// CockpitMachinery are additive; an older ledger.json without the key still
// loads (zero record = off).
type ledger struct {
PreCommit record `json:"pre_commit"`
PostMerge record `json:"post_merge"`
SessionStart record `json:"session_start"`
CommitMsg record `json:"commit_msg"`
CommitGuard record `json:"commit_guard"`
CockpitMachinery record `json:"cockpit_machinery"`
}
func ledgerPath(cfg *config.Config) string {
return filepath.Join(cfg.Workspace, "state", ledgerName)
}
func loadLedger(cfg *config.Config) (ledger, error) {
var l ledger
b, err := os.ReadFile(ledgerPath(cfg))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return l, nil
}
return l, fmt.Errorf("read hook ledger: %w", err)
}
if len(b) == 0 {
return l, nil
}
if err := json.Unmarshal(b, &l); err != nil {
// A corrupt ledger must not wedge the tool: start from empty
// state. On-disk verification still protects against deletion.
return ledger{}, nil
}
return l, nil
}
func saveLedger(cfg *config.Config, l ledger) error {
dir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("hook ledger dir: %w", err)
}
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}
// selfPath resolves the absolute path of the running eeco binary, used
// in the installed hook so the integration does not depend on PATH.
// A resolution failure degrades to the bare name. On a brew-installed
// eeco the resolved path lands inside the versioned Cellar directory;
// stableBrewBin unwinds that to the version-agnostic bin shim so the
// installed hook survives every `brew upgrade eeco`.
func selfPath() string {
p, err := os.Executable()
if err != nil || p == "" {
return "eeco"
}
if r, rerr := filepath.EvalSymlinks(p); rerr == nil {
p = r
}
if stable := stableBrewBin(p); stable != "" {
return stable
}
return p
}
// stableBrewBin returns the brew bin shim for a path inside
// "<prefix>/Cellar/eeco/<version>/bin/eeco", or "" if p is not a brew
// cellar path or the bin shim is not present. The bin shim is the
// version-agnostic entry brew creates in <prefix>/bin/, so an installed
// hook keeps working after `brew upgrade eeco` reaps the old cellar
// directory.
func stableBrewBin(p string) string {
prefix, _, ok := strings.Cut(p, "/Cellar/eeco/")
if !ok {
return ""
}
stable := filepath.Join(prefix, "bin", "eeco")
if _, err := os.Stat(stable); err != nil {
return ""
}
return stable
}
// --- pre-commit -----------------------------------------------------
// gitHooksDir returns <repo>/.git/hooks, or an error when .git is not a
// directory (for example a worktree, whose .git is a file). The
// pre-commit hook is deliberately repo-scoped and untracked.
func gitHooksDir(cfg *config.Config) (string, error) {
gitDir := filepath.Join(cfg.RepoRoot, ".git")
info, err := os.Stat(gitDir)
if err != nil {
return "", fmt.Errorf("locate .git: %w", err)
}
if !info.IsDir() {
return "", errors.New(".git is not a directory (worktree?) — pre-commit wiring unsupported here")
}
return filepath.Join(gitDir, "hooks"), nil
}
// preCommitScript renders the hook body. workflows is the ordered list
// of builtin workflow names to invoke; the runner stops at the first
// non-zero exit via `set -e`. The eeco binary path is captured once in
// a shell variable so a relocated binary that resolves identically at
// install time stays referenced consistently across steps.
func preCommitScript(workflows []string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("# eeco managed pre-commit hook. Reversible:\n")
b.WriteString("# eeco hooks pre-commit off\n")
b.WriteString("# Do not edit the next line; removal is exact-match.\n")
b.WriteString("# " + preCommitMarker + "\n")
b.WriteString("set -e\n")
fmt.Fprintf(&b, "EECO=%q\n", selfPath())
for _, w := range workflows {
fmt.Fprintf(&b, "\"$EECO\" run %s\n", w)
}
return b.String()
}
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// EnablePreCommit installs the pre-commit hook. It refuses, without
// modifying anything, when a non-eeco pre-commit hook already exists or
// when cfg.PreCommitWorkflows is empty (the operator opted out via an
// explicit empty `pre_commit_workflows` in config.local). Re-enabling
// an already-eeco hook is a no-op even when the desired workflow set
// has changed: run `eeco hooks pre-commit off` first to refresh.
func EnablePreCommit(cfg *config.Config) (string, error) {
if len(cfg.PreCommitWorkflows) == 0 {
return "", errors.New("pre_commit_workflows is empty in config.local — nothing to wire")
}
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
script := preCommitScript(cfg.PreCommitWorkflows)
if existing, rerr := os.ReadFile(path); rerr == nil {
if isEecoPreCommit(existing, "") {
return "pre-commit already enabled", nil
}
return "", errors.New("a non-eeco pre-commit hook already exists — left untouched")
} else if !errors.Is(rerr, os.ErrNotExist) {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hooks dir: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write pre-commit: %w", err)
}
l, err := loadLedger(cfg)
if err != nil {
return "", err
}
l.PreCommit = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(script)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit enabled (" + path + ")", nil
}
// DisablePreCommit removes the pre-commit hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with
// a marker-line fallback). A foreign or hand-edited hook is left in
// place and reported.
func DisablePreCommit(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
l.PreCommit = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if !isEecoPreCommit(b, l.PreCommit.SHA256) {
return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("remove pre-commit: %w", err)
}
l.PreCommit = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit disabled", nil
}
// RefreshPreCommit rewrites the on-disk pre-commit script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed pre-commit hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPreCommit(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
return "pre-commit not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if !isEecoManaged(b, l.PreCommit.SHA256, preCommitMarker) {
return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
}
desired := preCommitScript(cfg.PreCommitWorkflows)
if string(b) == desired {
return "pre-commit already current", nil
}
if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
return "", fmt.Errorf("write pre-commit: %w", err)
}
l.PreCommit = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(desired)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit refreshed (" + path + ")", nil
}
// isEecoPreCommit reports whether content is eeco's pre-commit script.
func isEecoPreCommit(content []byte, recordedSHA string) bool {
return isEecoManaged(content, recordedSHA, preCommitMarker)
}
// isEecoManaged reports whether content is an eeco-managed hook script:
// byte-identical to the recorded hash when one is known, otherwise
// carrying the unique marker line. A foreign hook carries neither.
func isEecoManaged(content []byte, recordedSHA, marker string) bool {
if recordedSHA != "" && sha256hex(content) == recordedSHA {
return true
}
if recordedSHA != "" {
return false
}
return strings.Contains(string(content), marker)
}
// --- post-merge -----------------------------------------------------
// postMergeScript renders the post-merge hook body. Unlike the
// pre-commit script it does NOT use `set -e` and swallows each step's
// exit (`|| true`): the merge has already completed, so a drift finding
// (exit 1) or a missing-tool block (exit 2) must surface as queue items
// and workflow output, never as a hook failure that alarms the user
// after a successful `git pull`. The eeco binary path is captured once.
func postMergeScript(workflows []string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("# eeco managed post-merge hook. Reversible:\n")
b.WriteString("# eeco hooks post-merge off\n")
b.WriteString("# Do not edit the next line; removal is exact-match.\n")
b.WriteString("# " + postMergeMarker + "\n")
fmt.Fprintf(&b, "EECO=%q\n", selfPath())
for _, w := range workflows {
fmt.Fprintf(&b, "\"$EECO\" run %s || true\n", w)
}
return b.String()
}
// EnablePostMerge installs the post-merge hook. It refuses, without
// modifying anything, when a non-eeco post-merge hook already exists or
// when cfg.PostMergeWorkflows is empty (the operator opted out via an
// explicit empty `post_merge_workflows`). Re-enabling an already-eeco
// hook is a no-op even when the desired workflow set has changed: run
// `eeco hooks post-merge off` first to refresh.
func EnablePostMerge(cfg *config.Config) (string, error) {
if len(cfg.PostMergeWorkflows) == 0 {
return "", errors.New("post_merge_workflows is empty in config.local — nothing to wire")
}
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
script := postMergeScript(cfg.PostMergeWorkflows)
if existing, rerr := os.ReadFile(path); rerr == nil {
if isEecoManaged(existing, "", postMergeMarker) {
return "post-merge already enabled", nil
}
return "", errors.New("a non-eeco post-merge hook already exists — left untouched")
} else if !errors.Is(rerr, os.ErrNotExist) {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hooks dir: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write post-merge: %w", err)
}
l, err := loadLedger(cfg)
if err != nil {
return "", err
}
l.PostMerge = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(script)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge enabled (" + path + ")", nil
}
// DisablePostMerge removes the post-merge hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with a
// marker-line fallback). A foreign or hand-edited hook is left in place
// and reported.
func DisablePostMerge(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
l.PostMerge = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
return "", errors.New("post-merge hook is present but not eeco's — left untouched")
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("remove post-merge: %w", err)
}
l.PostMerge = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge disabled", nil
}
// RefreshPostMerge rewrites the on-disk post-merge script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed post-merge hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPostMerge(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
return "post-merge not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
return "", errors.New("post-merge hook is present but not eeco's — left untouched")
}
desired := postMergeScript(cfg.PostMergeWorkflows)
if string(b) == desired {
return "post-merge already current", nil
}
if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
return "", fmt.Errorf("write post-merge: %w", err)
}
l.PostMerge = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(desired)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge refreshed (" + path + ")", nil
}
// --- session-start --------------------------------------------------
// sessionCommand is the command string written into the settings file.
// It carries the namespace token so removal is exact and
// path-independent, and --if-initialized so the bundled hook stays
// silent in any repo that is not an initialized eeco workspace.
func sessionCommand() string {
return fmt.Sprintf("%q %s --if-initialized", selfPath(), sessionToken)
}
// sessionGroup is the SessionStart group eeco appends. It is built as a
// generic map so the surrounding settings document round-trips with
// unknown fields preserved.
func sessionGroup() map[string]any {
return map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": sessionCommand(),
},
},
}
}
// EnableSessionStart wires the session-start hook across both delivery
// channels: a JSON-settings file (Claude-shaped, keyed by
// SessionSettingsPath) and one or more text/markdown files
// (marker-block delivery, keyed by SessionFiles). Either channel alone
// is enough; both compose. When neither is configured the function
// returns ErrSessionNotConfigured and touches nothing.
func EnableSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
var (
jsonMsg string
backup string
fileRecords []fileRecord
)
if cfg.SessionSettingsPath != "" {
m, b, err := enableSessionJSON(cfg)
if err != nil {
return "", err
}
jsonMsg = m
backup = b
}
var fileNotes []string
if len(cfg.SessionFiles) > 0 {
records, errs := enableSessionFiles(cfg)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
fileRecords = records
for _, r := range records {
fileNotes = append(fileNotes, r.Path)
}
}
l.SessionStart = record{
Installed: true,
Path: cfg.SessionSettingsPath,
Backup: backup,
At: time.Now().UTC().Format(time.RFC3339),
Files: fileRecords,
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
var parts []string
if jsonMsg != "" {
parts = append(parts, jsonMsg)
}
if len(fileNotes) > 0 {
parts = append(parts, "files "+strings.Join(fileNotes, ", "))
}
if len(parts) == 0 {
return "session-start enabled", nil
}
return "session-start enabled (" + strings.Join(parts, "; ") + ")", nil
}
// enableSessionJSON applies the JSON-settings-file half of the
// session-start hook. Returns a per-channel message and the backup
// path (empty when the settings file did not exist before).
func enableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", "", rerr
}
root := map[string]any{}
if existed {
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
}
if sessionInstalled(root) {
return path + " already enabled", "", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", "", berr
}
addSessionGroup(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
msg = path
if backup != "" {
msg += ", backup " + backup
}
return msg, backup, nil
}
// DisableSessionStart undoes both delivery channels: removes the eeco
// SessionStart group from the JSON-settings file when configured, and
// removes the marker block from every file recorded in the ledger (or
// in cfg.SessionFiles, when no ledger files survived). Foreign edits
// inside a marker block leave that file untouched with a per-file note.
func DisableSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
var parts []string
if cfg.SessionSettingsPath != "" {
m, b, err := disableSessionJSON(cfg)
if err != nil {
return "", err
}
if m != "" {
if b != "" {
m += " (backup " + b + ")"
}
parts = append(parts, m)
}
}
// Take the recorded files, fall back to the current configured list
// when the ledger has no entries (older eeco wired the channel; the
// file paths may still be there to clean up).
fileRecs := l.SessionStart.Files
if len(fileRecs) == 0 && len(cfg.SessionFiles) > 0 {
for _, entry := range cfg.SessionFiles {
fileRecs = append(fileRecs, fileRecord{Path: resolveSessionFile(cfg, entry)})
}
}
if len(fileRecs) > 0 {
notes, errs := disableSessionFiles(fileRecs)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
if len(notes) > 0 {
parts = append(parts, "files left untouched: "+strings.Join(notes, "; "))
} else {
var ps []string
for _, r := range fileRecs {
ps = append(ps, r.Path)
}
parts = append(parts, "files "+strings.Join(ps, ", "))
}
}
l.SessionStart = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
if len(parts) == 0 {
return "session-start not enabled", nil
}
return "session-start disabled (" + strings.Join(parts, "; ") + ")", nil
}
// disableSessionJSON removes the JSON-settings half. Returns "" for msg
// when the settings file did not have an eeco entry (the caller treats
// this as a no-op for the JSON channel).
func disableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", "", rerr
}
if !existed {
return "", "", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !sessionInstalled(root) {
return "", "", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", "", berr
}
removeSessionGroups(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
return path, backup, nil
}
// RefreshSessionStart re-derives both delivery channels from current
// project state. For the file-delivery channel it re-renders the marker
// block in every configured session_files entry (picking up a new queue
// item, an emptied mailbox, a fresh `roadmap*.md` match). For the
// JSON-settings channel it rewrites the eeco SessionStart command when
// the embedded binary path no longer matches what selfPath() produces
// — the self-heal for a brew upgrade that moved the cellar directory
// out from under a previously-installed hook. Refresh is safe to run
// repeatedly; the file outputs are byte-deterministic for a given
// project state, and the JSON rewrite is a no-op when the command is
// already current (idempotent).
func RefreshSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
var parts []string
jsonRefreshed := false
if cfg.SessionSettingsPath != "" {
jsonPath, jerr := refreshSessionJSON(cfg)
if jerr != nil {
return "", jerr
}
if jsonPath != "" {
parts = append(parts, jsonPath)
jsonRefreshed = true
}
}
var fileRecords []fileRecord
if len(cfg.SessionFiles) > 0 {
records, errs := refreshSessionFiles(cfg)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
fileRecords = records
}
if jsonRefreshed || len(fileRecords) > 0 {
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
if len(fileRecords) > 0 {
// Preserve Created across refreshes: a file that existed
// before the first enable must not become "created" by a
// later refresh.
preserved := map[string]bool{}
for _, prev := range l.SessionStart.Files {
preserved[prev.Path] = prev.Created
}
for i := range fileRecords {
if c, ok := preserved[fileRecords[i].Path]; ok {
fileRecords[i].Created = c
}
}
l.SessionStart.Files = fileRecords
}
l.SessionStart.At = time.Now().UTC().Format(time.RFC3339)
if !l.SessionStart.Installed {
l.SessionStart.Installed = true
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
}
for _, r := range fileRecords {
parts = append(parts, r.Path)
}
if len(parts) == 0 {
return "nothing to refresh (the JSON channel is already current)", nil
}
return "session-start refreshed (" + strings.Join(parts, ", ") + ")", nil
}
// refreshSessionJSON rewrites the eeco SessionStart command in the
// settings file when it carries the namespace token but its current
// command string differs from sessionCommand(). Returns the settings
// path on a successful rewrite, "" when there is nothing to do (no
// settings file, no eeco group present, or the command is already
// current). Atomic via writeJSONAtomic and revalidated like the enable
// path; an existing backup of the pre-refresh bytes is captured under
// <workspace>/state/backups/.
func refreshSessionJSON(cfg *config.Config) (string, error) {
path := cfg.SessionSettingsPath
if path == "" {
return "", nil
}
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
if !existed {
return "", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !sessionInstalled(root) {
return "", nil
}
want := sessionCommand()
if !rewriteSessionCommand(root, want) {
return "", nil
}
if _, berr := backupOriginal(cfg, orig, existed); berr != nil {
return "", berr
}
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
return path, nil
}
// rewriteSessionCommand walks every SessionStart group in root and
// replaces any command containing eeco's namespace token whose current
// value differs from want. Returns true if any command was changed.
func rewriteSessionCommand(root map[string]any, want string) bool {
changed := false
for _, g := range sessionGroups(root) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok {
continue
}
if !strings.Contains(cmd, sessionToken) {
continue
}
if cmd == want {
continue
}
hm["command"] = want
changed = true
}
}
return changed
}
// sessionInstalled reports whether root already contains an eeco
// SessionStart group (identified by the namespace token).
func sessionInstalled(root map[string]any) bool {
for _, g := range sessionGroups(root) {
if groupHasToken(g) {
return true
}
}
return false
}
// sessionGroups returns the SessionStart group list, or nil.
func sessionGroups(root map[string]any) []any {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
return nil
}
groups, _ := hooks["SessionStart"].([]any)
return groups
}
// groupHasToken reports whether a SessionStart group carries a hook
// command containing eeco's namespace token.
func groupHasToken(group any) bool {
gm, ok := group.(map[string]any)
if !ok {
return false
}
hs, ok := gm["hooks"].([]any)
if !ok {
return false
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, sessionToken) {
return true
}
}
return false
}
func addSessionGroup(root map[string]any) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
groups, _ := hooks["SessionStart"].([]any)
hooks["SessionStart"] = append(groups, sessionGroup())
}
func removeSessionGroups(root map[string]any) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
return
}
groups, ok := hooks["SessionStart"].([]any)
if !ok {
return
}
kept := make([]any, 0, len(groups))
for _, g := range groups {
if groupHasToken(g) {
continue
}
kept = append(kept, g)
}
if len(kept) == 0 {
// Leave no empty SessionStart array behind: drop the key, and
// the hooks object too if eeco's edit left it empty.
delete(hooks, "SessionStart")
if len(hooks) == 0 {
delete(root, "hooks")
}
return
}
hooks["SessionStart"] = kept
}
// readSettings reads the settings file. A missing file is not an error:
// existed is false and perm defaults to 0o644.
func readSettings(path string) (data []byte, existed bool, perm os.FileMode, err error) {
info, serr := os.Stat(path)
if errors.Is(serr, os.ErrNotExist) {
return nil, false, 0o644, nil
}
if serr != nil {
return nil, false, 0, fmt.Errorf("stat settings: %w", serr)
}
b, rerr := os.ReadFile(path)
if rerr != nil {
return nil, false, 0, fmt.Errorf("read settings: %w", rerr)
}
return b, true, info.Mode().Perm(), nil
}
// backupOriginal copies the pre-edit bytes into the workspace (never
// beside the user's file). When the file did not exist there is nothing
// to back up and it returns "".
func backupOriginal(cfg *config.Config, orig []byte, existed bool) (string, error) {
if !existed {
return "", nil
}
dir := filepath.Join(cfg.Workspace, "state", backupSubdir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("backup dir: %w", err)
}
name := "session-settings-" + time.Now().UTC().Format("20060102T150405.000000000Z") + ".json"
bp := filepath.Join(dir, name)
if err := os.WriteFile(bp, orig, 0o644); err != nil {
return "", fmt.Errorf("write backup: %w", err)
}
return bp, nil
}
// writeJSONAtomic marshals root and replaces path via a same-directory
// temp file and rename, so a crash mid-write cannot leave a truncated
// settings file.
func writeJSONAtomic(path string, root map[string]any, perm os.FileMode) error {
b, err := json.MarshalIndent(root, "", " ")
if err != nil {
return fmt.Errorf("encode settings: %w", err)
}
b = append(b, '\n')
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".eeco-settings-*")
if err != nil {
return fmt.Errorf("temp settings: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, werr := tmp.Write(b); werr != nil {
tmp.Close()
return fmt.Errorf("write temp settings: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return fmt.Errorf("close temp settings: %w", cerr)
}
if perm == 0 {
perm = 0o644
}
if cherr := os.Chmod(tmpName, perm); cherr != nil {
return fmt.Errorf("chmod temp settings: %w", cherr)
}
if rerr := os.Rename(tmpName, path); rerr != nil {
return fmt.Errorf("replace settings: %w", rerr)
}
return nil
}
// validateJSON re-reads path and confirms it parses as JSON.
func validateJSON(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
var v any
return json.Unmarshal(b, &v)
}
// restoreOriginal puts the pre-edit state back: the original bytes, or
// removal when the file did not exist before the edit.
func restoreOriginal(path string, orig []byte, existed bool) error {
if !existed {
return os.Remove(path)
}
return os.WriteFile(path, orig, 0o644)
}
// --- status ---------------------------------------------------------
// Status returns one human-readable line per hook, reflecting both the
// ledger and on-disk reality (so a hand-removed hook reads as off). It
// changes nothing.
func Status(cfg *config.Config) []string {
l, _ := loadLedger(cfg)
return []string{
PreCommit + ": " + preCommitStatus(cfg, l),
PostMerge + ": " + postMergeStatus(cfg, l),
SessionStart + ": " + sessionStatus(cfg),
CommitMsg + ": " + commitMsgStatus(cfg, l),
CommitGuard + ": " + commitGuardStatus(cfg),
}
}
func preCommitStatus(cfg *config.Config, l ledger) string {
return managedHookStatus(cfg, "pre-commit", l.PreCommit.SHA256, preCommitMarker)
}
func postMergeStatus(cfg *config.Config, l ledger) string {
return managedHookStatus(cfg, "post-merge", l.PostMerge.SHA256, postMergeMarker)
}
// managedHookStatus reports on/off for a repo-scoped managed git hook,
// reflecting on-disk reality so a hand-removed hook reads as off and a
// foreign hook of the same name reads as off-with-note.
func managedHookStatus(cfg *config.Config, hookName, recordedSHA, marker string) string {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "unavailable (" + err.Error() + ")"
}
b, rerr := os.ReadFile(filepath.Join(hooksDir, hookName))
if errors.Is(rerr, os.ErrNotExist) {
return "off"
}
if rerr != nil {
return "unknown (" + rerr.Error() + ")"
}
if isEecoManaged(b, recordedSHA, marker) {
return "on"
}
return "off (a non-eeco " + hookName + " hook is present)"
}
func sessionStatus(cfg *config.Config) string {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "not configured"
}
jsonOn := false
if cfg.SessionSettingsPath != "" {
orig, existed, _, err := readSettings(cfg.SessionSettingsPath)
switch {
case err != nil || !existed:
jsonOn = false
default:
root := map[string]any{}
if json.Unmarshal(orig, &root) != nil {
return "unknown (settings file is not valid JSON)"
}
jsonOn = sessionInstalled(root)
}
}
filesOn := false
for _, entry := range cfg.SessionFiles {
path := resolveSessionFile(cfg, entry)
b, rerr := os.ReadFile(path)
if rerr != nil {
continue
}
if _, _, found, ferr := findSessionBlock(b); ferr == nil && found {
filesOn = true
break
}
}
if jsonOn || filesOn {
return "on"
}
return "off"
}
// ShortState is the compact "<name>:on/off" pair for the status digest.
func ShortState(cfg *config.Config) string {
l, _ := loadLedger(cfg)
pc := "off"
if strings.HasPrefix(preCommitStatus(cfg, l), "on") {
pc = "on"
}
pm := "off"
if strings.HasPrefix(postMergeStatus(cfg, l), "on") {
pm = "on"
}
ss := sessionStatus(cfg)
if ss != "on" {
ss = "off"
}
cm := "off"
if strings.HasPrefix(commitMsgStatus(cfg, l), "on") {
cm = "on"
}
cg := "off"
if commitGuardStatus(cfg) == "on" {
cg = "on"
}
return "pre-commit:" + pc + " post-merge:" + pm + " session:" + ss + " commit-msg:" + cm + " commit-guard:" + cg
}