package hooks import ( "bytes" "errors" "fmt" "os" "path/filepath" "strings" "github.com/ajhahnde/eeco/internal/config" ) // Marker spellings for the file-based session-start delivery channel. // Mirrors the `` spellings from // `eeco docs compact`. HTML-comment so a Markdown reader (and // most assistants reading a CLAUDE.md / AGENTS.md / .cursorrules) hides // them from the rendered prose while still treating the body between // them as content. const ( sessionStartMarker = "" sessionEndMarker = "" ) // sessionBlockHeader is a single HTML-comment line placed inside the // block (right after the start marker) so an operator reading the file // sees what the block is and how to remove it. Kept terse. const sessionBlockHeader = "" // fileRecord is one managed file's reversibility state for the // session-start file delivery channel. type fileRecord struct { Path string `json:"path"` SHA256 string `json:"sha256"` Created bool `json:"created,omitempty"` } // resolveSessionFile expands a session_files entry against the repo // root. Repo-relative entries are joined with cfg.RepoRoot; absolute // entries are accepted verbatim. The config parser already rejected // `..` traversal and whitespace for repo-relative entries. func resolveSessionFile(cfg *config.Config, entry string) string { if filepath.IsAbs(entry) { return entry } return filepath.Join(cfg.RepoRoot, entry) } // renderSessionBlock builds the marker-delimited block from emitted // content. The exact bytes are deterministic for a given input so a // repeated enable/refresh with the same project state is a byte-for-byte // no-op (idempotency). func renderSessionBlock(emitted string, newline string) string { var b strings.Builder b.WriteString(sessionStartMarker) b.WriteString(newline) b.WriteString(sessionBlockHeader) b.WriteString(newline) if emitted != "" { // Emit may end with a trailing newline; strip then re-add a // single newline so the block ends consistently. body := strings.TrimRight(emitted, "\r\n") b.WriteString(body) b.WriteString(newline) } b.WriteString(sessionEndMarker) b.WriteString(newline) return b.String() } // emitSessionContent renders what the file-delivery block should // contain: the same text the JSON-channel command (`eeco hooks // session-emit`) prints. When every Emit block is empty (no docs, no // mailbox content, no queue items) the returned string is empty and the // caller still writes the block — a minimal block with no content body // is a valid signal to the operator that delivery is wired. func emitSessionContent(cfg *config.Config) string { var buf bytes.Buffer Emit(cfg, &buf) return buf.String() } // findSessionBlock walks src once and returns the byte offsets of the // start-marker line and the end-marker line for the single eeco // session-start block. Markers inside fenced code blocks are ignored. // found=false when no markers (or only one) are present at top level. // An unmatched/nested marker pair is reported as an error so the // caller can refuse to write rather than guess. func findSessionBlock(src []byte) (startByte, endByte int, found bool, err error) { lines := splitLinesKeepEOL(src) inFence := false startIdx, endIdx := -1, -1 for i, line := range lines { trimmed := strings.TrimRight(line, "\r\n") stripped := strings.TrimLeft(trimmed, " \t") if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") { inFence = !inFence continue } if inFence { continue } marker := strings.TrimSpace(trimmed) switch marker { case sessionStartMarker: if startIdx != -1 { return 0, 0, false, fmt.Errorf("session-start file: nested start marker at line %d (previous still open at line %d)", i+1, startIdx+1) } startIdx = i case sessionEndMarker: if startIdx == -1 { return 0, 0, false, fmt.Errorf("session-start file: end marker at line %d with no matching start", i+1) } endIdx = i // Compute byte offsets by summing line lengths. startByte = 0 for j := 0; j < startIdx; j++ { startByte += len(lines[j]) } endByte = startByte for j := startIdx; j <= endIdx; j++ { endByte += len(lines[j]) } return startByte, endByte, true, nil } } if startIdx != -1 { return 0, 0, false, fmt.Errorf("session-start file: start marker at line %d with no matching end", startIdx+1) } return 0, 0, false, nil } // writeFileAtomic mirrors writeJSONAtomic's discipline — same-directory // temp + rename — but takes raw bytes so it serves the file-delivery // channel that does not produce JSON. func writeFileAtomic(path string, content []byte, perm os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("ensure dir %s: %w", dir, err) } tmp, err := os.CreateTemp(dir, ".eeco-session-*") if err != nil { return fmt.Errorf("temp file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) if _, werr := tmp.Write(content); werr != nil { tmp.Close() return fmt.Errorf("write temp file: %w", werr) } if cerr := tmp.Close(); cerr != nil { return fmt.Errorf("close temp file: %w", cerr) } if perm == 0 { perm = 0o644 } if cherr := os.Chmod(tmpName, perm); cherr != nil { return fmt.Errorf("chmod temp file: %w", cherr) } if rerr := os.Rename(tmpName, path); rerr != nil { return fmt.Errorf("replace file: %w", rerr) } return nil } // dominantNewlineRaw picks the newline style used most often in src, // with a "\n" fallback for empty or newline-less input. func dominantNewlineRaw(src []byte) string { crlf := bytes.Count(src, []byte("\r\n")) lf := bytes.Count(src, []byte("\n")) - crlf if crlf > lf { return "\r\n" } return "\n" } // applySessionBlock returns the bytes of path after enabling/refreshing // the marker block, plus per-file metadata: the file existed before this // write, and the sha256 of the new block content alone. When the file // did not exist, a new file is created containing only the block. type applyResult struct { NewBytes []byte Block string Perm os.FileMode Existed bool Newline string } func applySessionBlock(path string, emitted string) (applyResult, error) { var res applyResult info, statErr := os.Stat(path) if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { return res, fmt.Errorf("stat %s: %w", path, statErr) } if statErr == nil && info.IsDir() { return res, fmt.Errorf("%s is a directory", path) } var existing []byte if statErr == nil { res.Existed = true res.Perm = info.Mode().Perm() b, rerr := os.ReadFile(path) if rerr != nil { return res, fmt.Errorf("read %s: %w", path, rerr) } existing = b } else { res.Perm = 0o644 } res.Newline = dominantNewlineRaw(existing) res.Block = renderSessionBlock(emitted, res.Newline) if !res.Existed { res.NewBytes = []byte(res.Block) return res, nil } startByte, endByte, found, ferr := findSessionBlock(existing) if ferr != nil { return res, ferr } if found { var buf bytes.Buffer buf.Write(existing[:startByte]) buf.WriteString(res.Block) buf.Write(existing[endByte:]) res.NewBytes = buf.Bytes() return res, nil } // No block present yet. Append at EOF, guaranteeing a blank line // between any prior content and the new block. var buf bytes.Buffer buf.Write(existing) if len(existing) > 0 { if !bytes.HasSuffix(existing, []byte("\n")) { buf.WriteString(res.Newline) } buf.WriteString(res.Newline) } buf.WriteString(res.Block) res.NewBytes = buf.Bytes() return res, nil } // enableSessionFiles materialises the marker block in every configured // session_files entry. Each entry's outcome is independent: a per-file // failure is returned in errs but does not abort the others. A path that // existed before this write is preserved (block replaced in place when // present, appended at EOF otherwise); a fresh file is created with // only the block content. func enableSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) { emitted := emitSessionContent(cfg) for _, entry := range cfg.SessionFiles { path := resolveSessionFile(cfg, entry) res, err := applySessionBlock(path, emitted) if err != nil { errs = append(errs, fmt.Errorf("%s: %w", entry, err)) continue } if err := writeFileAtomic(path, res.NewBytes, res.Perm); err != nil { errs = append(errs, fmt.Errorf("%s: %w", entry, err)) continue } records = append(records, fileRecord{ Path: path, SHA256: sha256hex([]byte(res.Block)), Created: !res.Existed, }) } return records, errs } // refreshSessionFiles re-renders the marker block in every entry. The // outcome shape mirrors enableSessionFiles; a path that has lost its // block (operator deleted the markers, or the file is gone) is treated // the same as on enable — block re-inserted, or file re-created when // missing. func refreshSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) { return enableSessionFiles(cfg) } // disableSessionFiles removes the marker block from every recorded path // that still carries an eeco-written block. A file whose block has been // hand-edited (sha mismatch) is left untouched and reported in notes; // a file whose only content was the block is deleted, restoring the // pre-enable state. func disableSessionFiles(records []fileRecord) (notes []string, errs []error) { for _, rec := range records { existing, rerr := os.ReadFile(rec.Path) if errors.Is(rerr, os.ErrNotExist) { continue } if rerr != nil { errs = append(errs, fmt.Errorf("%s: %w", rec.Path, rerr)) continue } startByte, endByte, found, ferr := findSessionBlock(existing) if ferr != nil { notes = append(notes, fmt.Sprintf("%s: %v — left untouched", rec.Path, ferr)) continue } if !found { continue } blockBytes := existing[startByte:endByte] if rec.SHA256 != "" && sha256hex(blockBytes) != rec.SHA256 { notes = append(notes, fmt.Sprintf("%s: session block edited since install — left untouched", rec.Path)) continue } head := append([]byte{}, existing[:startByte]...) tail := existing[endByte:] // When the block was at EOF, the bytes we kept ended with the // blank-line separator we inserted on enable. Normalise to a // single trailing newline (or nothing, when head is empty); the // in-middle case keeps head+tail byte-identical to the // pre-enable bytes. var remaining []byte if len(tail) == 0 { head = bytes.TrimRight(head, "\r\n") if len(head) > 0 { head = append(head, '\n') } remaining = head } else { remaining = append(head, tail...) } if rec.Created && isOnlyWhitespace(remaining) { if err := os.Remove(rec.Path); err != nil && !errors.Is(err, os.ErrNotExist) { errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err)) } continue } perm := os.FileMode(0o644) if info, serr := os.Stat(rec.Path); serr == nil { perm = info.Mode().Perm() } if err := writeFileAtomic(rec.Path, remaining, perm); err != nil { errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err)) } } return notes, errs } // isOnlyWhitespace reports whether b consists solely of whitespace // characters. func isOnlyWhitespace(b []byte) bool { for _, c := range b { switch c { case ' ', '\t', '\r', '\n': continue default: return false } } return true } // splitLinesKeepEOL returns the lines of src with the trailing newline // (LF or CRLF) preserved on each. An unterminated final line is // returned as-is. Mirrors internal/docs/compact.go's helper. func splitLinesKeepEOL(src []byte) []string { var lines []string for len(src) > 0 { i := bytes.IndexByte(src, '\n') if i < 0 { lines = append(lines, string(src)) break } lines = append(lines, string(src[:i+1])) src = src[i+1:] } return lines }