1use std::path::Path;
14
15const CHECK_UPDATE_SH: &str = include_str!("../embed/plugins/claude/hooks/scc/check-update.sh");
18
19const SESSION_START: &str = r#"#!/usr/bin/env bash
20# SCC SessionStart: inject system capsule + freshness warnings + checkpoint.
21# State location comes from SCC itself (honors SCC_STATE_DIR), so the
22# repository can be read-only with external state.
23SCC_BIN="${SCC_BIN:-scc}"
24command -v "$SCC_BIN" >/dev/null 2>&1 || exit 0
25SCC_STATE="$("$SCC_BIN" state-path 2>/dev/null)" || exit 0
26[ -n "$SCC_STATE" ] && [ -f "$SCC_STATE/scc.db" ] || exit 0
27"$SCC_BIN" verify --warnings 2>/dev/null
28"$SCC_BIN" checkpoint load --inject 2>/dev/null
29echo ""
30# Wave 14: the fused startup capsule (Atlas + Surface + coverage +
31# omissions) is the startup architecture injection.
32"$SCC_BIN" context startup 2>/dev/null
33"#;
34
35const USER_PROMPT_SUBMIT: &str = r#"#!/usr/bin/env bash
36# SCC UserPromptSubmit: inject a bounded task pack for coding prompts.
37SCC_BIN="${SCC_BIN:-scc}"
38command -v "$SCC_BIN" >/dev/null 2>&1 || exit 0
39command -v python3 >/dev/null 2>&1 || exit 0
40prompt=$(python3 -c 'import json,sys
41try:
42 print(json.load(sys.stdin).get("prompt",""))
43except Exception:
44 print("")' 2>/dev/null)
45[ -z "$prompt" ] && exit 0
46# Skip conversational, git-only, or very short prompts.
47case "$prompt" in
48 git*|help|hello*|hi*|thanks*|thank*|yes|no|ok|okay|"") exit 0 ;;
49esac
50len=${#prompt}
51[ "$len" -lt 40 ] && exit 0
52SCC_STATE="$("$SCC_BIN" state-path 2>/dev/null)" || exit 0
53[ -n "$SCC_STATE" ] && [ -f "$SCC_STATE/scc.db" ] || exit 0
54# Wave 2 (§37): the Atlas is already in context; UserPromptSubmit injects
55# a small task focus ONLY when context.inject_task_focus is true (the CLI
56# itself is the gatekeeper — prints nothing when disabled).
57"$SCC_BIN" context task "$prompt" --hook 2>/dev/null
58"#;
59
60const POST_TOOL_USE: &str = r#"#!/usr/bin/env bash
61# SCC PostToolUse: incremental refresh of changed files.
62# The operation timeout lives in the Rust CLI's caller (python subprocess
63# timeout below) — no GNU `timeout` dependency, platform-independent.
64SCC_BIN="${SCC_BIN:-scc}"
65command -v "$SCC_BIN" >/dev/null 2>&1 || exit 0
66command -v python3 >/dev/null 2>&1 || exit 0
67python3 - "$SCC_BIN" <<'PYEOF'
68import json, subprocess, sys, os
69scc = sys.argv[1]
70try:
71 d = json.load(sys.stdin)
72 ti = d.get("tool_input", {}) or {}
73 files = []
74 for k in ("file_path", "filePaths", "files"):
75 v = ti.get(k)
76 if isinstance(v, str):
77 files.append(v)
78 elif isinstance(v, list):
79 files.extend(x for x in v if isinstance(x, str))
80 files = [f for f in files if f]
81except Exception:
82 files = []
83if not files:
84 sys.exit(0)
85try:
86 state = subprocess.run([scc, "state-path"], capture_output=True, text=True, timeout=5)
87 db = state.stdout.strip()
88 if state.returncode != 0 or not db or not os.path.isfile(os.path.join(db, "scc.db")):
89 sys.exit(0)
90 subprocess.run(
91 [scc, "index", "--paths"] + files + ["--quiet"],
92 capture_output=True, text=True, timeout=15,
93 )
94except Exception:
95 pass
96PYEOF
97"#;
98
99const PRE_COMPACT: &str = r#"#!/usr/bin/env bash
100# SCC PreCompact: re-inject the fused startup capsule + task checkpoint so
101# the architecture survives compaction (Wave 14 §126).
102SCC_BIN="${SCC_BIN:-scc}"
103command -v "$SCC_BIN" >/dev/null 2>&1 || exit 0
104SCC_STATE="$("$SCC_BIN" state-path 2>/dev/null)" || exit 0
105[ -n "$SCC_STATE" ] && [ -f "$SCC_STATE/scc.db" ] || exit 0
106python3 - "$SCC_BIN" <<'PYEOF'
107import json, subprocess, sys
108scc = sys.argv[1]
109def run(args, timeout=15):
110 try:
111 return subprocess.run([scc] + args, capture_output=True, text=True, timeout=timeout).stdout
112 except Exception:
113 return ""
114try:
115 save = run(["checkpoint", "save", "--json"], 10)
116 checkpoint = run(["checkpoint", "load", "--inject"], 10)
117 if not checkpoint.strip():
118 checkpoint = save if save.strip() else "SCC checkpoint unavailable at compaction time."
119 startup = run(["context", "startup"])
120 if not startup.strip():
121 startup = "SCC startup capsule unavailable at compaction time."
122 content = "SCC CONTEXT (re-injected after compaction)\n\n" + startup
123 if checkpoint.strip():
124 content += "\n\nTASK CHECKPOINT\n\n" + checkpoint
125except Exception:
126 content = "SCC rehydration unavailable at compaction time."
127print(json.dumps({"files": {"scc-rehydrate.md": content}}))
128PYEOF
129"#;
130pub fn install(root: &Path) -> crate::Result<()> {
133 let claude_dir = root.join(".claude");
134 let hook_dir = claude_dir.join("hooks/scc");
135 std::fs::create_dir_all(&hook_dir)?;
136
137 let scripts: [(&str, &str, &str); 5] = [
138 ("session_start.sh", "SessionStart", SESSION_START),
139 ("check_update.sh", "SessionStart", CHECK_UPDATE_SH),
140 ("user_prompt_submit.sh", "UserPromptSubmit", USER_PROMPT_SUBMIT),
141 ("post_tool_use.sh", "PostToolUse", POST_TOOL_USE),
142 ("pre_compact.sh", "PreCompact", PRE_COMPACT),
143 ];
144 let mut new_entries: Vec<(String, serde_json::Value)> = Vec::new();
148 for (file, event, content) in scripts {
149 let script_path = hook_dir.join(file);
150 std::fs::write(&script_path, content)?;
151 #[cfg(unix)]
152 {
153 use std::os::unix::fs::PermissionsExt;
154 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
155 }
156 let command = format!("{}", script_path.display());
157 let entry = serde_json::json!([{
158 "matcher": if event == "PostToolUse" { "Edit|Write|MultiEdit|NotebookEdit" } else { "*" },
159 "hooks": [{"type": "command", "command": command}]
160 }]);
161 new_entries.push((event.to_string(), entry));
162 }
163
164 let settings_path = claude_dir.join("settings.json");
168 let mut settings: serde_json::Value = if settings_path.exists() {
169 let text = std::fs::read_to_string(&settings_path)?;
170 serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
171 } else {
172 serde_json::json!({})
173 };
174 let existing_hooks: serde_json::Map<String, serde_json::Value> = settings
175 .get("hooks")
176 .and_then(|h| h.as_object())
177 .cloned()
178 .unwrap_or_default();
179 let mut merged = existing_hooks;
180 for (event, entry) in new_entries {
181 let scc_entry = entry;
182 match merged.get_mut(&event) {
183 Some(serde_json::Value::Array(existing)) => {
184 existing.push(scc_entry);
186 }
187 _ => {
188 merged.insert(event, scc_entry);
189 }
190 }
191 }
192 settings["hooks"] = serde_json::Value::Object(merged);
193 std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
194
195 println!("Claude Code plugin installed:");
196 println!(" hooks -> {}", settings_path.display());
197 println!(" scripts -> {}", hook_dir.display());
198 println!();
199 println!("Make sure `scc` is on PATH (or set SCC_BIN).");
200 println!("Restart Claude Code for the hooks to take effect.");
201 println!("No slash command needed: startup capsule, task packs, and");
202 println!("checkpoints are automatic.");
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn scripts_are_valid_bash() {
213 for (_, _, content) in [
214 ("", "", SESSION_START),
215 ("", "", CHECK_UPDATE_SH),
216 ("", "", USER_PROMPT_SUBMIT),
217 ("", "", POST_TOOL_USE),
218 ("", "", PRE_COMPACT),
219 ] {
220 assert!(content.starts_with("#!/usr/bin/env bash"));
221 assert!(content.contains("scc"));
222 }
223 assert!(
226 SESSION_START.contains("context startup"),
227 "SessionStart must run the fused startup capsule: {SESSION_START}"
228 );
229 assert!(
230 PRE_COMPACT.contains("\"context\", \"startup\""),
231 "PreCompact must run the fused startup capsule: {PRE_COMPACT}"
232 );
233 for script in [SESSION_START, PRE_COMPACT] {
234 assert!(
235 !script.contains("$SCC_BIN\" atlas") && !script.contains("run([\"atlas\"])"),
236 "no Atlas-only injection: {script}"
237 );
238 }
239 assert!(SESSION_START.contains("context startup 2>/dev/null"));
240 assert!(
243 CHECK_UPDATE_SH.contains("systemMessage"),
244 "check_update.sh must emit systemMessage JSON"
245 );
246 assert!(
247 CHECK_UPDATE_SH.contains("start_new_session"),
248 "check_update.sh refresh must detach from the hook process"
249 );
250 assert!(
251 PRE_COMPACT.contains("SCC CONTEXT (re-injected after compaction)"),
252 "rehydration header must name the fused capsule"
253 );
254 }
255
256 #[test]
257 fn install_writes_hooks() {
259 let dir = tempfile::TempDir::new().unwrap();
260 install(dir.path()).unwrap();
261 let settings: serde_json::Value = serde_json::from_str(
262 &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap(),
263 )
264 .unwrap();
265 let hooks = settings["hooks"].as_object().unwrap();
266 for event in ["SessionStart", "UserPromptSubmit", "PostToolUse", "PreCompact"] {
267 assert!(hooks.contains_key(event), "missing {event}");
268 }
269 assert!(dir.path().join(".claude/hooks/scc/session_start.sh").exists());
270 }
271
272 #[test]
273 fn install_preserves_existing_hooks() {
275 let dir = tempfile::TempDir::new().unwrap();
278 let claude = dir.path().join(".claude");
279 std::fs::create_dir_all(&claude).unwrap();
280 let existing = serde_json::json!({
281 "hooks": {
282 "SessionStart": [
283 {"matcher": "*", "hooks": [{"type": "command", "command": "/serena/start"}]}
284 ],
285 "UserPromptSubmit": [
286 {"matcher": "*", "hooks": [{"type": "command", "command": "/security/scan"}]}
287 ]
288 }
289 });
290 std::fs::write(
291 claude.join("settings.json"),
292 serde_json::to_string_pretty(&existing).unwrap(),
293 )
294 .unwrap();
295
296 install(dir.path()).unwrap();
297 let settings: serde_json::Value = serde_json::from_str(
298 &std::fs::read_to_string(claude.join("settings.json")).unwrap(),
299 )
300 .unwrap();
301 let hooks = settings["hooks"].as_object().unwrap();
302
303 let ss = hooks["SessionStart"].as_array().unwrap();
304 assert_eq!(ss.len(), 3, "existing + SCC SessionStart + SCC check_update: {ss:?}");
305 let serena = ss
306 .iter()
307 .find(|e| e.to_string().contains("serena"))
308 .expect("Serena hook preserved");
309 assert!(serena.get("hooks").is_some());
310 let scc_entry = ss
311 .iter()
312 .find(|e| e.to_string().contains("session_start.sh"))
313 .expect("SCC hook appended");
314 assert!(scc_entry[0].get("hooks").is_some(), "matcher entry: {scc_entry:?}");
315 let upd_entry = ss
316 .iter()
317 .find(|e| e.to_string().contains("check_update.sh"))
318 .expect("SCC update reminder appended");
319 assert!(upd_entry[0].get("hooks").is_some(), "matcher entry: {upd_entry:?}");
320
321 let ups = hooks["UserPromptSubmit"].as_array().unwrap();
322 assert_eq!(ups.len(), 2, "existing + SCC UserPromptSubmit: {ups:?}");
323 assert!(ups.iter().any(|e| e.to_string().contains("security")));
324 assert!(ups.iter().any(|e| e.to_string().contains("user_prompt_submit.sh")));
325 }
326
327 #[test]
328 fn post_tool_use_has_no_shell_timeout_dependency() {
330 assert!(!POST_TOOL_USE.contains("timeout 15"), "no shell timeout call");
332 assert!(
333 POST_TOOL_USE.contains("timeout=15"),
334 "python-side subprocess timeout"
335 );
336 for script in [SESSION_START, USER_PROMPT_SUBMIT, POST_TOOL_USE, PRE_COMPACT] {
338 assert!(
339 !script.contains(".scc/scc.db"),
340 "no direct .scc probe: {script}"
341 );
342 assert!(script.contains("state-path"), "must use scc state-path");
343 }
344 }
345}