Skip to main content

scc_cli/
plugin.rs

1//! Claude Code integration (EPIC-100, docs/API_AND_INTEGRATIONS.md §5).
2//!
3//! `scc setup claude` writes `.claude/settings.json` hooks that call the
4//! embedded shell scripts. Hooks:
5//! - SessionStart: inject startup capsule + verify warnings + checkpoint
6//! - UserPromptSubmit: inject a task pack for repository-changing prompts
7//! - PostToolUse (Edit|Write|MultiEdit|NotebookEdit): incremental refresh
8//! - PreCompact: re-inject the fused startup capsule + persist a task
9//!   checkpoint so the architecture survives compaction (docs §126)
10//!
11//! Normal usage requires no slash command.
12
13use std::path::Path;
14
15// SessionStart update reminder (stale-while-revalidate shell checker).
16// trace:exempt reason=internal-detail
17const 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"#;
130// trace:v1 id=impl.scc.plugin work=WORK-SCC-001 satisfies=REQ-SCC-API
131
132pub 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    // One entry per script (a Map would collapse the two SessionStart
145    // scripts into one — each entry appends separately below).
146    // trace:exempt reason=internal-detail
147    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    // merge with existing settings.json if present (P0 §12): SCC hooks are
165    // APPENDED to the per-event arrays so existing hooks (Serena, security,
166    // RTK, ...) survive installation
167    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                // append SCC's matcher entry to whatever already runs
185                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    // trace:exempt reason=unit-test
212    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        // Wave 14: the startup capsule (Atlas + Surface + coverage +
224        // omissions) is the architecture injection — never Atlas-only.
225        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        // Update reminder: cache-only SessionStart hook emitting
241        // systemMessage JSON solely when an update is due.
242        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    // trace:exempt reason=unit-test
258    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    // trace:exempt reason=unit-test
274    fn install_preserves_existing_hooks() {
275        // P0 §12: SCC installation must append to, never replace, existing
276        // hooks for the same event (Serena/security/RTK coexistence).
277        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    // trace:exempt reason=unit-test
329    fn post_tool_use_has_no_shell_timeout_dependency() {
330        // P0 §14: integration must not rely on GNU `timeout`.
331        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        // P0 §13: state location comes from SCC itself
337        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}