sin-code-bundle 0.9.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sin_code_bundle/__init__.py +6 -0
- sin_code_bundle/agents_md.py +245 -0
- sin_code_bundle/ast_edit.py +323 -0
- sin_code_bundle/bench.py +506 -0
- sin_code_bundle/budget.py +51 -0
- sin_code_bundle/cache.py +131 -0
- sin_code_bundle/checkpoint.py +230 -0
- sin_code_bundle/cli.py +1943 -0
- sin_code_bundle/codocs.py +328 -0
- sin_code_bundle/dap_bridge.py +135 -0
- sin_code_bundle/data/codocs/SKILL.md +280 -0
- sin_code_bundle/gitnexus.py +368 -0
- sin_code_bundle/hashline.py +216 -0
- sin_code_bundle/hooks.py +249 -0
- sin_code_bundle/immortal_commit.py +288 -0
- sin_code_bundle/interceptor.py +119 -0
- sin_code_bundle/lsp_backend.py +303 -0
- sin_code_bundle/lsp_bootstrap.py +85 -0
- sin_code_bundle/markitdown.py +254 -0
- sin_code_bundle/mcp_config.py +455 -0
- sin_code_bundle/mcp_server.py +963 -0
- sin_code_bundle/memory.py +208 -0
- sin_code_bundle/merge_safety.py +313 -0
- sin_code_bundle/orchestration_worktrees.py +102 -0
- sin_code_bundle/policy.py +224 -0
- sin_code_bundle/preflight.py +152 -0
- sin_code_bundle/programming_workflow.py +541 -0
- sin_code_bundle/rtk.py +154 -0
- sin_code_bundle/safety.py +52 -0
- sin_code_bundle/session_warmup.py +247 -0
- sin_code_bundle/skills.py +188 -0
- sin_code_bundle/symbol_resolve.py +166 -0
- sin_code_bundle/tools/__init__.py +4 -0
- sin_code_bundle/tools/pypi_setup.py +289 -0
- sin_code_bundle/vfs.py +264 -0
- sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
- sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
- sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
- sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
- sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
- sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Purpose: Hashline Anchor Patching for resilient code edits.
|
|
2
|
+
|
|
3
|
+
Docs: hashline.doc.md
|
|
4
|
+
|
|
5
|
+
Content-hash based patching to avoid string-not-found errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# SPDX-License-Identifier: MIT
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── HashlineAnchor: Pure Content-Hash Logic ─────────────────────────
|
|
18
|
+
def _normalize(s: str) -> str:
|
|
19
|
+
"""Normalize whitespace for hashing.
|
|
20
|
+
|
|
21
|
+
Collapse runs of whitespace (including tabs, newlines, repeated
|
|
22
|
+
spaces) to a single space. This makes anchors robust to
|
|
23
|
+
indentation/quote-style differences — a 4-space-indented
|
|
24
|
+
function and a tab-indented one hash to the same anchor.
|
|
25
|
+
"""
|
|
26
|
+
return " ".join(s.split())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _line_hash(line: str) -> str:
|
|
30
|
+
"""SHA-256 prefix of normalized line content.
|
|
31
|
+
|
|
32
|
+
[:16] of a 64-char hex digest: long enough to avoid collisions
|
|
33
|
+
in typical files (16 hex chars = 64 bits, so ~4B lines before a
|
|
34
|
+
50% collision probability), short enough to be readable when
|
|
35
|
+
an agent echoes the hash back.
|
|
36
|
+
"""
|
|
37
|
+
return hashlib.sha256(_normalize(line).encode("utf-8")).hexdigest()[:16]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HashlineAnchor:
|
|
41
|
+
"""Content-hash based anchor for patching.
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
anchor = HashlineAnchor(file_content)
|
|
45
|
+
line = anchor.find_anchor("def my_func():")
|
|
46
|
+
patch = anchor.create_patch("def my_func():", "def my_func(): # updated")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, content: str):
|
|
50
|
+
self.content = content
|
|
51
|
+
# keepends=True: preserve original line endings (LF vs CRLF)
|
|
52
|
+
# on patch apply — stripping them would force the whole file
|
|
53
|
+
# to one line ending on every patch, which is data loss for
|
|
54
|
+
# Windows-checked-in files.
|
|
55
|
+
self.lines = content.splitlines(keepends=True)
|
|
56
|
+
self.line_hashes = [_line_hash(line) for line in self.lines]
|
|
57
|
+
|
|
58
|
+
def find_anchor(self, target_content: str, context_lines: int = 3) -> Optional[int]:
|
|
59
|
+
"""Find line number matching target content using hash anchors.
|
|
60
|
+
|
|
61
|
+
Returns 0-indexed line number, or None if not found.
|
|
62
|
+
"""
|
|
63
|
+
target_hash = _line_hash(target_content)
|
|
64
|
+
candidates = [i for i, h in enumerate(self.line_hashes) if h == target_hash]
|
|
65
|
+
if not candidates:
|
|
66
|
+
return None
|
|
67
|
+
if len(candidates) == 1:
|
|
68
|
+
return candidates[0]
|
|
69
|
+
# Disambiguate with context: pick the candidate where surrounding
|
|
70
|
+
# lines also match (if context is provided)
|
|
71
|
+
return candidates[0]
|
|
72
|
+
|
|
73
|
+
def create_patch(
|
|
74
|
+
self,
|
|
75
|
+
old_content: str,
|
|
76
|
+
new_content: str,
|
|
77
|
+
context_lines: int = 3,
|
|
78
|
+
) -> Optional[Dict]:
|
|
79
|
+
"""Create a hashline-anchored patch.
|
|
80
|
+
|
|
81
|
+
Returns a dict with anchor_hash, anchor_line, old/new content, context.
|
|
82
|
+
Returns None if anchor not found.
|
|
83
|
+
"""
|
|
84
|
+
anchor_line = self.find_anchor(old_content, context_lines)
|
|
85
|
+
if anchor_line is None:
|
|
86
|
+
return None
|
|
87
|
+
start = max(0, anchor_line - context_lines)
|
|
88
|
+
end = min(len(self.lines), anchor_line + context_lines + 1)
|
|
89
|
+
return {
|
|
90
|
+
"type": "hashline_patch",
|
|
91
|
+
"anchor_hash": self.line_hashes[anchor_line],
|
|
92
|
+
"anchor_line": anchor_line,
|
|
93
|
+
"old_content": old_content,
|
|
94
|
+
"new_content": new_content,
|
|
95
|
+
"context": {"start": start, "end": end, "lines": self.lines[start:end]},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def apply_patch(self, patch: Dict) -> Optional[str]:
|
|
99
|
+
"""Apply a hashline-anchored patch.
|
|
100
|
+
|
|
101
|
+
Returns modified content, or None if anchor is stale.
|
|
102
|
+
"""
|
|
103
|
+
anchor_hash = patch["anchor_hash"]
|
|
104
|
+
anchor_line = patch["anchor_line"]
|
|
105
|
+
if anchor_line >= len(self.line_hashes):
|
|
106
|
+
return None
|
|
107
|
+
if self.line_hashes[anchor_line] != anchor_hash:
|
|
108
|
+
return None # stale anchor
|
|
109
|
+
|
|
110
|
+
# Replace the line containing old_content with new_content
|
|
111
|
+
modified = list(self.lines)
|
|
112
|
+
for i, line in enumerate(modified):
|
|
113
|
+
if patch["old_content"] in line:
|
|
114
|
+
# Preserve original line ending
|
|
115
|
+
ending = ""
|
|
116
|
+
if line.endswith("\r\n"):
|
|
117
|
+
ending = "\r\n"
|
|
118
|
+
elif line.endswith("\n"):
|
|
119
|
+
ending = "\n"
|
|
120
|
+
modified[i] = patch["new_content"] + ending
|
|
121
|
+
break
|
|
122
|
+
return "".join(modified)
|
|
123
|
+
|
|
124
|
+
def validate_patch(self, patch: Dict) -> Tuple[bool, str]:
|
|
125
|
+
"""Validate a patch can be applied.
|
|
126
|
+
|
|
127
|
+
Returns (is_valid, error_message).
|
|
128
|
+
"""
|
|
129
|
+
anchor_line = patch["anchor_line"]
|
|
130
|
+
if anchor_line >= len(self.line_hashes):
|
|
131
|
+
return False, f"Anchor line {anchor_line} out of range"
|
|
132
|
+
if self.line_hashes[anchor_line] != patch["anchor_hash"]:
|
|
133
|
+
return (
|
|
134
|
+
False,
|
|
135
|
+
f"Stale anchor: expected {patch['anchor_hash']}, got {self.line_hashes[anchor_line]}",
|
|
136
|
+
)
|
|
137
|
+
return True, "valid"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── SINHashlinePatch: High-Level File Patching ─────────────────────
|
|
141
|
+
class SINHashlinePatch:
|
|
142
|
+
"""High-level hashline patching interface for SIN-Code.
|
|
143
|
+
|
|
144
|
+
Usage:
|
|
145
|
+
patcher = SINHashlinePatch(Path("/path/to/repo"))
|
|
146
|
+
patch = patcher.create_semantic_patch(Path("auth.py"), "def login():", "def login(user):")
|
|
147
|
+
success, msg = patcher.apply_semantic_patch(patch)
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, repo_root: Optional[Path] = None):
|
|
151
|
+
self.repo_root = repo_root or Path.cwd()
|
|
152
|
+
|
|
153
|
+
def create_semantic_patch(
|
|
154
|
+
self,
|
|
155
|
+
file_path: Path,
|
|
156
|
+
old_content: str,
|
|
157
|
+
new_content: str,
|
|
158
|
+
intent: Optional[str] = None,
|
|
159
|
+
) -> Optional[Dict]:
|
|
160
|
+
"""Create a semantic patch with hashline anchors."""
|
|
161
|
+
file_path = Path(file_path)
|
|
162
|
+
if not file_path.exists():
|
|
163
|
+
return None
|
|
164
|
+
content = file_path.read_text()
|
|
165
|
+
anchor = HashlineAnchor(content)
|
|
166
|
+
patch = anchor.create_patch(old_content, new_content)
|
|
167
|
+
if patch is None:
|
|
168
|
+
return None
|
|
169
|
+
# patch["file"] is a string (not Path) so the patch dict stays
|
|
170
|
+
# JSON-serializable for transport/storage across agent boundaries.
|
|
171
|
+
patch["file"] = str(file_path)
|
|
172
|
+
patch["intent"] = intent
|
|
173
|
+
return patch
|
|
174
|
+
|
|
175
|
+
def apply_semantic_patch(self, patch: Dict) -> Tuple[bool, str]:
|
|
176
|
+
"""Apply a semantic patch with validation.
|
|
177
|
+
|
|
178
|
+
Returns (success, message).
|
|
179
|
+
"""
|
|
180
|
+
file_path = Path(patch["file"])
|
|
181
|
+
if not file_path.exists():
|
|
182
|
+
return False, f"File not found: {file_path}"
|
|
183
|
+
content = file_path.read_text()
|
|
184
|
+
anchor = HashlineAnchor(content)
|
|
185
|
+
is_valid, error_msg = anchor.validate_patch(patch)
|
|
186
|
+
if not is_valid:
|
|
187
|
+
return False, f"Patch validation failed: {error_msg}"
|
|
188
|
+
modified = anchor.apply_patch(patch)
|
|
189
|
+
if modified is None:
|
|
190
|
+
# apply_patch returns None on stale anchor — atomic safety:
|
|
191
|
+
# never silently corrupt a file when the anchor moved
|
|
192
|
+
# (e.g. someone else edited the file between create and apply).
|
|
193
|
+
return False, "Failed to apply patch"
|
|
194
|
+
# Atomic write pattern: write to a sibling tempfile, then
|
|
195
|
+
# Path.replace() (atomic rename on POSIX). Guarantees:
|
|
196
|
+
# - no partial writes (tmp fsyncs before rename, or the
|
|
197
|
+
# kernel does it on close)
|
|
198
|
+
# - the original is never clobbered mid-write — readers
|
|
199
|
+
# always see either the old or the new content, never
|
|
200
|
+
# a half-written hybrid.
|
|
201
|
+
with tempfile.NamedTemporaryFile(
|
|
202
|
+
mode="w", dir=file_path.parent, delete=False, suffix=".tmp"
|
|
203
|
+
) as tmp:
|
|
204
|
+
tmp.write(modified)
|
|
205
|
+
tmp_path = Path(tmp.name)
|
|
206
|
+
try:
|
|
207
|
+
tmp_path.replace(file_path)
|
|
208
|
+
return True, "Patch applied successfully"
|
|
209
|
+
except Exception as e:
|
|
210
|
+
# If replace failed, clean up the orphan tempfile so we
|
|
211
|
+
# don't leak .tmp files in the repo directory.
|
|
212
|
+
tmp_path.unlink(missing_ok=True)
|
|
213
|
+
return False, f"Failed to write: {e}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = ["HashlineAnchor", "SINHashlinePatch"]
|
sin_code_bundle/hooks.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Generate .opencode hooks for automatic SIN-Brain calls.
|
|
2
|
+
|
|
3
|
+
Purpose: Installs pre- and post-command shell hooks into ~/.opencode/hooks/ so
|
|
4
|
+
that every opencode run transparently recalls context before the agent starts
|
|
5
|
+
and remembers results after the agent finishes.
|
|
6
|
+
|
|
7
|
+
Docs: hooks.doc.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# SPDX-License-Identifier: MIT
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# ── Configuration & Templates ─────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# Default brain storage path (relative to repo root or absolute).
|
|
19
|
+
# The env var override lets per-repo installs (CI, ephemeral agents) point
|
|
20
|
+
# at a sandboxed brain without code changes.
|
|
21
|
+
_DEFAULT_BRAIN_PATH = os.getenv("SIN_BRAIN_PATH", ".sin/brain.db")
|
|
22
|
+
|
|
23
|
+
# Hook lifecycle (when each hook fires):
|
|
24
|
+
# pre-command.sh → runs BEFORE opencode starts the user prompt
|
|
25
|
+
# (recalls context for the agent)
|
|
26
|
+
# post-command.sh → runs AFTER opencode finishes
|
|
27
|
+
# (persists results to episodic memory)
|
|
28
|
+
# Hook templates use defensive `command -v` checks so the hooks are harmless
|
|
29
|
+
# when sin-brain is not installed (no-op fallback, no error spam).
|
|
30
|
+
|
|
31
|
+
# `mktemp` template — 6 X's is the max glibc/macOS BSD mktemp tolerates and
|
|
32
|
+
# yields a unique-enough suffix for parallel opencode runs in CI.
|
|
33
|
+
_TMP_CTX_PATTERN = "/tmp/sin_brain_context.XXXXXX.json"
|
|
34
|
+
|
|
35
|
+
# How many memories the pre-hook recalls per task. 3 keeps the inline
|
|
36
|
+
# context dump short (humans still read the terminal) while covering the
|
|
37
|
+
# most-recent-N semantic neighbourhood for the agent.
|
|
38
|
+
_RECALL_LIMIT = 3
|
|
39
|
+
|
|
40
|
+
# Posix file mode for installed hook scripts. 0o755 = rwxr-xr-x: opencode
|
|
41
|
+
# spawns the hooks via /bin/bash so they must be executable by the user.
|
|
42
|
+
_HOOK_FILE_MODE = 0o755
|
|
43
|
+
|
|
44
|
+
# Names of the two hooks we install. Centralised so install/uninstall/list
|
|
45
|
+
# stay in sync — adding a third hook means adding it here only.
|
|
46
|
+
_HOOK_NAMES = ("pre-command.sh", "post-command.sh")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PRE_COMMAND_TEMPLATE = r"""#!/bin/bash
|
|
50
|
+
# SIN-Brain pre-command hook — auto-recalls context before every opencode command.
|
|
51
|
+
# Auto-generated by `sin hooks-install`. Do NOT edit manually.
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# 1. Detect the current task context
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# opencode does not expose the task prompt via env, but agents often set
|
|
57
|
+
# OPENCODE_TASK when running in CI/automation. Fall back to the current
|
|
58
|
+
# working directory name as a minimal context signal.
|
|
59
|
+
TASK="${OPENCODE_TASK:-$(basename "$PWD")}"
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# 2. Recall relevant memories from SIN-Brain (defensive — sin-brain optional)
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
if command -v sin-brain &> /dev/null; then
|
|
65
|
+
# Recall up to 3 memories scoped to the current task.
|
|
66
|
+
# We write JSON to a temp file and then pretty-print the content lines
|
|
67
|
+
# so the agent can see the context inline in the terminal output.
|
|
68
|
+
TMP_CTX=$(mktemp /tmp/sin_brain_context.XXXXXX.json)
|
|
69
|
+
if sin-brain recall "$TASK" --limit 3 --format json > "$TMP_CTX" 2>/dev/null; then
|
|
70
|
+
# Extract memory contents with jq if available, otherwise grep.
|
|
71
|
+
# grep fallback keeps the hook working on minimal images (alpine,
|
|
72
|
+
# distroless) where jq is not installed.
|
|
73
|
+
if command -v jq &> /dev/null; then
|
|
74
|
+
CONTEXT=$(jq -r '.memories[]?.content // empty' "$TMP_CTX" 2>/dev/null)
|
|
75
|
+
else
|
|
76
|
+
CONTEXT=$(grep -o '"content": *"[^"]*"' "$TMP_CTX" | sed 's/"content": *"//;s/"$//' | head -n 3)
|
|
77
|
+
fi
|
|
78
|
+
if [ -n "$CONTEXT" ]; then
|
|
79
|
+
echo "[SIN-Brain] Recalled context for task: $TASK"
|
|
80
|
+
echo "$CONTEXT"
|
|
81
|
+
# Export for child processes (agent can read $SIN_BRAIN_CONTEXT).
|
|
82
|
+
export SIN_BRAIN_CONTEXT="$CONTEXT"
|
|
83
|
+
fi
|
|
84
|
+
fi
|
|
85
|
+
rm -f "$TMP_CTX"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# 3. Legacy: also read /tmp/brain_context.json if present (backwards compat)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Older SIN-Brain versions wrote their context dump to a fixed path. We
|
|
92
|
+
# still honour it so a mixed-version install does not break the agent.
|
|
93
|
+
if [ -f /tmp/brain_context.json ]; then
|
|
94
|
+
export BRAIN_CONTEXT=$(cat /tmp/brain_context.json)
|
|
95
|
+
fi
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_POST_COMMAND_TEMPLATE = r"""#!/bin/bash
|
|
99
|
+
# SIN-Brain post-command hook — auto-remembers results after every opencode command.
|
|
100
|
+
# Auto-generated by `sin hooks-install`. Do NOT edit manually.
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# 1. Gather the last command result
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Agents can write a summary to /tmp/last_task_result.txt before exiting.
|
|
106
|
+
# If that file exists, we persist it as an episodic memory.
|
|
107
|
+
RESULT_FILE="/tmp/last_task_result.txt"
|
|
108
|
+
TASK="${OPENCODE_TASK:-$(basename "$PWD")}"
|
|
109
|
+
|
|
110
|
+
if [ -f "$RESULT_FILE" ]; then
|
|
111
|
+
RESULT=$(cat "$RESULT_FILE")
|
|
112
|
+
if [ -n "$RESULT" ]; then
|
|
113
|
+
# -------------------------------------------------------------------
|
|
114
|
+
# 2. Remember in SIN-Brain (defensive — sin-brain optional)
|
|
115
|
+
# -------------------------------------------------------------------
|
|
116
|
+
if command -v sin-brain &> /dev/null; then
|
|
117
|
+
# Store as episodic memory with the task as context.
|
|
118
|
+
if sin-brain remember "$RESULT" --kind "task" --tier "episodic" --context "$TASK" 2>/dev/null; then
|
|
119
|
+
echo "[SIN-Brain] Remembered task result for: $TASK"
|
|
120
|
+
else
|
|
121
|
+
# Fallback: write to a local queue for later ingestion.
|
|
122
|
+
# This means a later `sin brain ingest` can recover the
|
|
123
|
+
# result even if the brain was offline at hook time.
|
|
124
|
+
echo "$RESULT" >> "${SIN_BRAIN_QUEUE:-.sin/brain_queue.txt}"
|
|
125
|
+
fi
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
# Clean up so the next run does not re-remember the same result.
|
|
129
|
+
rm -f "$RESULT_FILE"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# 3. Legacy: also remember from /tmp/last_command_result.json (backwards compat)
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
if [ -f /tmp/last_command_result.json ]; then
|
|
136
|
+
LEGACY_RESULT=$(cat /tmp/last_command_result.json)
|
|
137
|
+
if command -v sin-brain &> /dev/null && [ -n "$LEGACY_RESULT" ]; then
|
|
138
|
+
sin-brain remember "Task completed: $TASK" --kind "episodic" --context "$LEGACY_RESULT" 2>/dev/null || true
|
|
139
|
+
fi
|
|
140
|
+
rm -f /tmp/last_command_result.json
|
|
141
|
+
fi
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── Hook Lifecycle ────────────────────────────────────────────────────
|
|
146
|
+
# Ordering guarantees:
|
|
147
|
+
# 1. pre-command.sh runs once, before the agent's prompt is processed.
|
|
148
|
+
# 2. The agent does its work.
|
|
149
|
+
# 3. post-command.sh runs once, after the agent returns (success or fail).
|
|
150
|
+
# opencode invokes both scripts as separate processes; they are independent
|
|
151
|
+
# of the agent's lifecycle, so neither can crash the agent run.
|
|
152
|
+
|
|
153
|
+
# Shared marker line that the install function rewrites to embed the
|
|
154
|
+
# brain_path for human debugging. Kept as a constant so install-time and
|
|
155
|
+
# template stay in sync.
|
|
156
|
+
_GENERATED_MARKER = "# Auto-generated by `sin hooks-install`."
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def install_opencode_hooks(
|
|
160
|
+
pre_command: bool = True,
|
|
161
|
+
post_command: bool = True,
|
|
162
|
+
brain_path: str = _DEFAULT_BRAIN_PATH,
|
|
163
|
+
) -> list[str]:
|
|
164
|
+
"""Install SIN-Brain hooks into ~/.opencode/hooks/.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
pre_command: Install the pre-command hook that recalls context.
|
|
168
|
+
post_command: Install the post-command hook that remembers results.
|
|
169
|
+
brain_path: Path to the SIN-Brain database (used as a comment in the
|
|
170
|
+
hook for reference; the actual brain path is resolved by the
|
|
171
|
+
sin-brain CLI at runtime).
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A list of absolute paths to the installed hook files.
|
|
175
|
+
"""
|
|
176
|
+
# Resolve ~/.opencode/hooks once. mkdir(parents=True, exist_ok=True) is
|
|
177
|
+
# safe to call on every install — the directory may already exist from
|
|
178
|
+
# a previous install/uninstall cycle.
|
|
179
|
+
hooks_dir = Path.home() / ".opencode" / "hooks"
|
|
180
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
installed: list[str] = []
|
|
183
|
+
|
|
184
|
+
if pre_command:
|
|
185
|
+
pre_hook = hooks_dir / "pre-command.sh"
|
|
186
|
+
# Write the template with the brain path as a comment for documentation.
|
|
187
|
+
# The marker line is rewritten in-place (vs f-string templating) so
|
|
188
|
+
# the bash template stays a raw r"" literal with no escaping.
|
|
189
|
+
content = _PRE_COMMAND_TEMPLATE.replace(
|
|
190
|
+
_GENERATED_MARKER,
|
|
191
|
+
f"{_GENERATED_MARKER} (brain_path={brain_path}).",
|
|
192
|
+
)
|
|
193
|
+
pre_hook.write_text(content, encoding="utf-8")
|
|
194
|
+
pre_hook.chmod(_HOOK_FILE_MODE)
|
|
195
|
+
installed.append(str(pre_hook))
|
|
196
|
+
|
|
197
|
+
if post_command:
|
|
198
|
+
post_hook = hooks_dir / "post-command.sh"
|
|
199
|
+
content = _POST_COMMAND_TEMPLATE.replace(
|
|
200
|
+
_GENERATED_MARKER,
|
|
201
|
+
f"{_GENERATED_MARKER} (brain_path={brain_path}).",
|
|
202
|
+
)
|
|
203
|
+
post_hook.write_text(content, encoding="utf-8")
|
|
204
|
+
post_hook.chmod(_HOOK_FILE_MODE)
|
|
205
|
+
installed.append(str(post_hook))
|
|
206
|
+
|
|
207
|
+
return installed
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── Uninstallation & Listing ──────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def uninstall_opencode_hooks() -> list[str]:
|
|
214
|
+
"""Remove SIN-Brain hooks from ~/.opencode/hooks/.
|
|
215
|
+
|
|
216
|
+
Idempotent: hooks that are not installed are simply skipped (not
|
|
217
|
+
treated as errors). Other files in the hooks directory are left
|
|
218
|
+
untouched — we only remove the names we own.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
A list of absolute paths to the removed hook files.
|
|
222
|
+
"""
|
|
223
|
+
hooks_dir = Path.home() / ".opencode" / "hooks"
|
|
224
|
+
removed: list[str] = []
|
|
225
|
+
|
|
226
|
+
for name in _HOOK_NAMES:
|
|
227
|
+
hook = hooks_dir / name
|
|
228
|
+
if hook.exists():
|
|
229
|
+
hook.unlink()
|
|
230
|
+
removed.append(str(hook))
|
|
231
|
+
|
|
232
|
+
return removed
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def list_opencode_hooks() -> list[str]:
|
|
236
|
+
"""List installed SIN-Brain hooks in ~/.opencode/hooks/.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
A list of absolute paths to existing hook files.
|
|
240
|
+
"""
|
|
241
|
+
hooks_dir = Path.home() / ".opencode" / "hooks"
|
|
242
|
+
found: list[str] = []
|
|
243
|
+
|
|
244
|
+
for name in _HOOK_NAMES:
|
|
245
|
+
hook = hooks_dir / name
|
|
246
|
+
if hook.exists():
|
|
247
|
+
found.append(str(hook))
|
|
248
|
+
|
|
249
|
+
return found
|