1use std::path::Path;
26
27use crate::agents_md::{
28 replace_scc_section_in, resolve_omp_agents_path, AgentsTarget,
29 SCC_OMP_SECTION_CLOSE, SCC_OMP_SECTION_OPEN,
30};
31
32const EXTENSION_TS: &str = include_str!("../embed/plugins/omp/scc/index.ts");
35const EXTENSION_PACKAGE: &str = include_str!("../embed/plugins/omp/scc/package.json");
36const SKILL_MD: &str = include_str!("../embed/plugins/omp/scc/skills/scc-system-context/SKILL.md");
37const UPDATE_CHECK_TS: &str = include_str!("../embed/plugins/omp/scc/update-check.ts");
38
39const AGENTS_RULES: &str = "<!-- SCC-OMP-SECTION -->\n\
41# SCC (System Context Compiler)\n\
42This repository is indexed by SCC. Durable rules:\n\
43- The native SCC extension injects the startup architecture once per session\n\
44 and a task-specific context pack per prompt. Work within the injected task\n\
45 context; it is the authoritative system slice for the current goal.\n\
46- For the fused startup architecture (Atlas + Surface + coverage),\n\
47 `scc context startup` is the canonical command; `scc atlas` is only its\n\
48 Level-0 Atlas component.\n\
49- Authority ordering: source/runtime > SCC System IR > checkpoint > Hindsight\n\
50 > model assumption.\n\
51- Drift and invariants: `scc drift`, `scc ci check`, and `scc impact <files>`\n\
52 before cross-layer edits.\n\
53<!-- /SCC-OMP-SECTION -->\n";
54
55pub fn cmd_setup_omp(root: &Path) -> crate::Result<()> {
58 let omp_dir = root.join(".omp");
59 let ext_dir = omp_dir.join("extensions/scc");
61 std::fs::create_dir_all(&ext_dir)?;
62 std::fs::write(ext_dir.join("index.ts"), installable_extension_ts())?;
63 std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
64 std::fs::write(ext_dir.join("package.json"), extension_package())?;
65 println!("wrote {}", ext_dir.join("index.ts").display());
66
67 merge_mcp_json(&omp_dir)?;
69
70 let skill_dir = omp_dir.join("skills/scc-system-context");
72 std::fs::create_dir_all(&skill_dir)?;
73 std::fs::write(skill_dir.join("SKILL.md"), skill_md())?;
74 println!("wrote {}", skill_dir.join("SKILL.md").display());
75
76 let agents_path = write_agents_rules(root)?;
80
81 println!();
82 println!("OMP integration installed:");
83 println!(" extension -> {}", ext_dir.join("index.ts").display());
84 println!(" mcp.json -> {}", omp_dir.join("mcp.json").display());
85 println!(" skill -> {}", skill_dir.join("SKILL.md").display());
86 println!(" AGENTS.md -> {}", agents_path.display());
87 println!();
88 println!("Restart OMP, then run `/extensions` to verify the SCC extension");
89 println!("is loaded (`/extensions` is an inspector, not a reload).");
90 println!("Verify MCP with `/mcp reload` then `/mcp test scc`.");
91 println!(
92 "The `scc` binary must be on PATH, or set SCC_BIN (the extension reads process.env.SCC_BIN)."
93 );
94 Ok(())
95}
96
97fn installable_extension_ts() -> String {
103 strip_authoring_markers(EXTENSION_TS)
104}
105
106pub(crate) fn strip_authoring_markers(src: &str) -> String {
113 let mut out = String::with_capacity(src.len());
114 for line in src.split('\n') {
115 let t = line.trim_start();
116 let style = if t.starts_with("// trace:v1 ") {
117 Some("//")
118 } else if t.starts_with("# trace:v1 ") {
119 Some("#")
120 } else if t.starts_with("-- trace:v1 ") {
121 Some("--")
122 } else if t.starts_with("<!-- trace:v1 ") {
123 Some("<!--")
124 } else {
125 None
126 };
127 if let Some(c) = style {
128 let indent = &line[..line.len() - t.len()];
129 let close = if c == "<!--" { " -->" } else { "" };
130 out.push_str(indent);
131 out.push_str(c);
132 out.push_str(" trace:exempt reason=scc-installed-tooling (authoring marker from the SCC source repo removed at install)");
133 out.push_str(close);
134 } else {
135 out.push_str(line);
136 }
137 out.push('\n');
138 }
139 if src.ends_with('\n') {
140 out.pop();
141 }
142 out
143}
144
145fn installable_aux_ts(src: &str) -> String {
147 strip_authoring_markers(src)
148}
149
150pub fn cmd_setup_pi(root: &Path) -> crate::Result<()> {
155 let ext_dir = root.join(".pi").join("extensions").join("scc");
156 std::fs::create_dir_all(&ext_dir)?;
157 std::fs::write(ext_dir.join("index.ts"), installable_pi_ts(EXTENSION_TS))?;
158 std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
159 std::fs::write(ext_dir.join("package.json"), extension_package())?;
160 println!("wrote {}", ext_dir.join("index.ts").display());
161 println!();
162 println!("Pi integration installed (project-local .pi/extensions/scc).");
163 println!("Restart Pi; the extension hot-reloads with /reload.");
164 println!("The `scc` binary must be on PATH, or set SCC_BIN.");
165 Ok(())
166}
167
168fn installable_pi_ts(src: &str) -> String {
170 strip_authoring_markers(src).replace(
171 "@oh-my-pi/pi-coding-agent",
172 "@mariozechner/pi-coding-agent",
173 )
174}
175
176fn extension_package() -> &'static str {
178 EXTENSION_PACKAGE
179}
180
181fn skill_md() -> &'static str {
183 SKILL_MD
184}
185
186fn scc_bin() -> String {
188 if let Ok(v) = std::env::var("SCC_BIN") {
189 let v = v.trim().to_string();
190 if !v.is_empty() {
191 return v;
192 }
193 }
194 if let Ok(exe) = std::env::current_exe() {
198 if exe.file_stem().map(|n| n == "scc").unwrap_or(false) {
199 return exe.to_string_lossy().into_owned();
200 }
201 }
202 "scc".to_string()
203}
204
205fn merge_mcp_json(omp_dir: &Path) -> crate::Result<()> {
212 let path = omp_dir.join("mcp.json");
213 let mut v: serde_json::Value = if path.exists() {
214 let text = std::fs::read_to_string(&path)?;
215 serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
216 } else {
217 serde_json::json!({})
218 };
219 let servers = v
220 .get_mut("mcpServers")
221 .and_then(|m| m.as_object_mut())
222 .cloned()
223 .unwrap_or_default();
224 let mut servers = servers;
225 let cmd = scc_bin();
226 match servers.get_mut("scc") {
227 Some(existing) => {
228 if std::env::var("SCC_BIN").map(|s| !s.trim().is_empty()).unwrap_or(false) {
229 existing["command"] = serde_json::Value::String(cmd);
230 }
231 }
232 None => {
233 servers.insert(
234 "scc".to_string(),
235 serde_json::json!({"command": cmd, "args": ["mcp"]}),
236 );
237 }
238 }
239 v["mcpServers"] = serde_json::Value::Object(servers);
240 std::fs::write(&path, serde_json::to_string_pretty(&v)?)?;
241 Ok(())
242}
243
244fn write_agents_rules(root: &Path) -> crate::Result<std::path::PathBuf> {
248 let resolution = resolve_omp_agents_path(root);
249 if resolution.both_exist {
250 eprintln!(
251 "warning: both .omp/AGENTS.md and AGENTS.md exist; OMP prefers .omp/AGENTS.md \
252 (higher-priority same-scope context) and will shadow the root file. \
253 Patching .omp/AGENTS.md. Remove one of the files if that precedence is unintended."
254 );
255 }
256 let path = match resolution.target {
257 AgentsTarget::Omp => root.join(".omp").join("AGENTS.md"),
258 AgentsTarget::Root => root.join("AGENTS.md"),
259 };
260 if let Some(parent) = path.parent() {
261 std::fs::create_dir_all(parent)?;
262 }
263 let existing = std::fs::read_to_string(&path).unwrap_or_default();
264 let out = replace_scc_section_in(
265 &existing,
266 AGENTS_RULES,
267 SCC_OMP_SECTION_OPEN,
268 SCC_OMP_SECTION_CLOSE,
269 );
270 std::fs::write(&path, out)?;
271 Ok(path)
272}
273
274#[cfg(test)]
275mod tests {
276 #[test]
277 fn pi_setup_installs_retargeted_extension() {
279 let dir = tempfile::TempDir::new().unwrap();
280 let root = dir.path().join("repo");
281 std::fs::create_dir_all(&root).unwrap();
282 cmd_setup_pi(&root).unwrap();
283 let ext = root.join(".pi").join("extensions").join("scc");
284 assert!(ext.join("index.ts").exists());
285 assert!(ext.join("update-check.ts").exists());
286 assert!(ext.join("package.json").exists());
287 let ts = std::fs::read_to_string(ext.join("index.ts")).unwrap();
288 assert!(ts.contains("@mariozechner/pi-coding-agent"), "pi scope retargeted");
289 assert!(!ts.contains("@oh-my-pi/"), "no fork scope remains");
290 assert!(ts.contains("maybeNotifyUpdate"), "notifier wired");
291 assert!(!ts.contains("trace:v1 id="), "authoring markers stripped");
292 }
293
294 use super::*;
295 use std::sync::{Mutex, MutexGuard};
296
297 static ENV_LOCK: Mutex<()> = Mutex::new(());
298
299 struct SccBinGuard {
305 _lock: MutexGuard<'static, ()>,
306 prev: Option<String>,
307 }
308
309 impl Drop for SccBinGuard {
311 fn drop(&mut self) {
313 match &self.prev {
314 Some(v) => std::env::set_var("SCC_BIN", v),
315 None => std::env::remove_var("SCC_BIN"),
316 }
317 }
318 }
319
320 fn lock_scc_bin(value: Option<&str>) -> SccBinGuard {
322 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
323 let prev = std::env::var("SCC_BIN").ok();
324 match value {
325 Some(v) => std::env::set_var("SCC_BIN", v),
326 None => std::env::remove_var("SCC_BIN"),
327 }
328 SccBinGuard { _lock: lock, prev }
329 }
330
331 fn installed_extension(root: &Path) -> String {
333 std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap()
334 }
335
336 #[test]
337 fn installs_extension_and_mcp() {
339 let _env = lock_scc_bin(None);
340 let dir = tempfile::TempDir::new().unwrap();
341 let root = dir.path().join("repo");
342 std::fs::create_dir_all(&root).unwrap();
343 cmd_setup_omp(&root).unwrap();
344
345 assert!(root.join(".omp/extensions/scc/index.ts").exists());
347 assert!(root.join(".omp/extensions/scc/update-check.ts").exists());
348 assert!(root.join(".omp/extensions/scc/package.json").exists());
349 let installed_ts = std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap();
350 assert!(!installed_ts.contains("trace:v1 id="), "authoring markers stripped at install");
351 let ts = installed_extension(&root);
353 assert!(ts.contains("@oh-my-pi/pi-coding-agent"), "extension must import the canonical resolvable package");
354 assert!(ts.contains("before_agent_start"), "extension must wire before_agent_start");
355 assert!(ts.contains("session_start"), "extension must wire session_start");
356
357 let mcp: serde_json::Value =
359 serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
360 assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc");
361 assert_eq!(mcp["mcpServers"]["scc"]["args"][0], "mcp");
362
363 assert!(root.join("AGENTS.md").exists(), "canonical instruction file is root AGENTS.md");
365 assert!(
366 !root.join(".omp/AGENTS.md").exists(),
367 "must not create shadowing .omp/AGENTS.md when root did not exist"
368 );
369 let agents = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
370 assert!(agents.contains("SCC (System Context Compiler)"), "AGENTS.md must carry SCC durable rules");
371 let skill = std::fs::read_to_string(root.join(".omp/skills/scc-system-context/SKILL.md")).unwrap();
372 assert!(skill.contains("`system_context`"), "skill must teach system_context");
373 assert!(skill.contains("`surface_map`"), "skill must teach surface_map");
374 assert!(skill.contains("`structural_source`"), "skill must teach structural_source");
375 assert!(!skill.contains("| `system_overview` |"), "skill table must not lead with the retired startup tool");
376 }
377
378 #[test]
379 fn mcp_merge_preserves_existing_servers() {
381 let _env = lock_scc_bin(None);
382 let dir = tempfile::TempDir::new().unwrap();
383 let root = dir.path().join("repo");
384 std::fs::create_dir_all(&root).unwrap();
385 let omp = root.join(".omp");
386 std::fs::create_dir_all(&omp).unwrap();
387 std::fs::write(
388 omp.join("mcp.json"),
389 r#"{"mcpServers":{"existing":{"command":"foo","args":["bar"]}}}"#,
390 )
391 .unwrap();
392 merge_mcp_json(&omp).unwrap();
393 let mcp: serde_json::Value =
394 serde_json::from_str(&std::fs::read_to_string(omp.join("mcp.json")).unwrap()).unwrap();
395 assert_eq!(mcp["mcpServers"]["existing"]["command"], "foo", "existing server must be preserved");
396 assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc", "scc server must be added");
397 }
398
399 #[test]
400 fn agents_idempotent_preserves_user_content() {
402 let dir = tempfile::TempDir::new().unwrap();
403 let root = dir.path().join("repo");
404 std::fs::create_dir_all(&root).unwrap();
405 let omp = root.join(".omp");
406 std::fs::create_dir_all(&omp).unwrap();
407 std::fs::write(omp.join("AGENTS.md"), "USER CONTENT\n").unwrap();
408 write_agents_rules(&root).unwrap();
409 let first = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
410 assert!(first.starts_with("USER CONTENT"), "user content must be preserved");
411 assert!(first.contains("SCC (System Context Compiler)"), "SCC rules must be appended");
412 write_agents_rules(&root).unwrap();
414 let second = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
415 assert_eq!(
416 first.matches("SCC (System Context Compiler)").count(),
417 second.matches("SCC (System Context Compiler)").count(),
418 "SCC rules must not duplicate on reinstall"
419 );
420 }
421
422 #[test]
423 fn agents_rewrite_preserves_text_after_closing_marker() {
425 let dir = tempfile::TempDir::new().unwrap();
426 let root = dir.path().join("repo");
427 std::fs::create_dir_all(root.join(".omp")).unwrap();
428 std::fs::write(
429 root.join(".omp/AGENTS.md"),
430 "BEFORE\n<!-- SCC-OMP-SECTION -->\nold managed\n<!-- /SCC-OMP-SECTION -->\nKEEP AFTER\n",
431 )
432 .unwrap();
433 write_agents_rules(&root).unwrap();
434 let text = std::fs::read_to_string(root.join(".omp/AGENTS.md")).unwrap();
435 assert!(text.contains("BEFORE"), "{text}");
436 assert!(text.contains("KEEP AFTER"), "text after the closing marker must survive: {text}");
437 assert!(!text.contains("old managed"), "{text}");
438 }
439
440 #[test]
441 fn agents_patches_existing_root_and_does_not_create_shadow() {
443 let dir = tempfile::TempDir::new().unwrap();
444 let root = dir.path().join("repo");
445 std::fs::create_dir_all(&root).unwrap();
446 std::fs::write(root.join("AGENTS.md"), "# project rules\nkeep me\n").unwrap();
447 let path = write_agents_rules(&root).unwrap();
448 assert_eq!(path, root.join("AGENTS.md"));
449 assert!(!root.join(".omp/AGENTS.md").exists(), "must not create shadowing .omp/AGENTS.md");
450 let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
451 assert!(text.contains("keep me"), "{text}");
452 assert!(text.contains("SCC (System Context Compiler)"), "{text}");
453 }
454
455 #[test]
456 fn omp_and_codex_sections_coexist_in_one_file() {
458 let dir = tempfile::TempDir::new().unwrap();
462 let root = dir.path().join("repo");
463 std::fs::create_dir_all(&root).unwrap();
464 std::fs::write(
465 root.join("AGENTS.md"),
466 "# user\n<!-- SCC-SECTION -->\ncodex capsule\n<!-- /SCC-SECTION -->\n",
467 )
468 .unwrap();
469 write_agents_rules(&root).unwrap();
470 let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
471 assert!(text.contains("codex capsule"), "codex section must survive OMP setup: {text}");
472 assert!(text.contains("SCC-OMP-SECTION"), "OMP section must be installed: {text}");
473 write_agents_rules(&root).unwrap();
475 let again = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
476 assert_eq!(again.matches("SCC-OMP-SECTION").count(), 2, "one OMP section: {again}");
477 assert!(again.contains("codex capsule"), "codex section survives reinstall: {again}");
478 }
479
480 #[test]
481 fn generated_extension_invokes_index_paths_and_does_not_treat_failure_as_success() {
483 let _env = lock_scc_bin(None);
484 let dir = tempfile::TempDir::new().unwrap();
485 let root = dir.path().join("repo");
486 std::fs::create_dir_all(&root).unwrap();
487 cmd_setup_omp(&root).unwrap();
488 let ts = installed_extension(&root);
489 assert!(
490 ts.contains("\"--paths\"") || ts.contains("[\"index\", \"--paths\""),
491 "generated extension must invoke `scc index --paths` (plural): {ts}"
492 );
493 assert!(
494 !ts.contains("\"--path\""),
495 "must not pass the rejected singular --path flag: {ts}"
496 );
497 assert!(
498 ts.contains("index --paths failed") || ts.contains("did not succeed"),
499 "failed index must be reported, not treated as success: {ts}"
500 );
501 assert!(
502 ts.contains("session.compacting") && ts.contains("session_before_compact"),
503 "compaction must save on session_before_compact and inject on session.compacting: {ts}"
504 );
505 assert!(
506 ts.contains("checkpoint") && ts.contains("load") && ts.contains("--inject"),
507 "compacting must load the checkpoint with --inject: {ts}"
508 );
509 assert!(
510 ts.contains("process.env.SCC_BIN") || ts.contains("SCC_BIN"),
511 "extension must honor SCC_BIN: {ts}"
512 );
513 assert!(
514 ts.contains("session_switch") && ts.contains("session_branch") && ts.contains("session_tree"),
515 "injection marker must reset on switch/branch/tree: {ts}"
516 );
517 assert!(
518 ts.contains("hash-object") || ts.contains("porcelain"),
519 "opaque mutations must snapshot dirty files: {ts}"
520 );
521 assert!(
522 ts.contains("--porcelain=v1") && ts.contains("-z"),
523 "porcelain must be NUL-delimited (-z): {ts}"
524 );
525 assert!(
526 ts.contains("\"--name-only\"") || ts.contains("diff\", \"--name-only\""),
527 "HEAD revision diffs must use --name-only: {ts}"
528 );
529 assert!(
530 ts.contains("split(\"\\0\")") || ts.contains("split('\\0')") || ts.contains(".split(\"\\0\")"),
531 "revision diffs must split on NUL, not newlines: {ts}"
532 );
533 assert!(
534 ts.contains("rev-parse") && ts.contains("HEAD"),
535 "snapshots must record HEAD so clean-to-clean mutations refresh: {ts}"
536 );
537 assert!(
538 !ts.contains("toolName}:${Date.now()") && !ts.contains("toolName}:${Date.now()}"),
539 "dirtySnapshots must not use a Date.now() fallback id: {ts}"
540 );
541 assert!(
542 ts.contains("if (!id) return"),
543 "snapshots without toolCallId must be skipped: {ts}"
544 );
545 assert!(
546 ts.contains("startupOk"),
547 "compaction must track startup injection separately from checkpoint: {ts}"
548 );
549 }
550
551 #[test]
552 fn mcp_json_honors_scc_bin_at_setup() {
554 let _env = lock_scc_bin(Some("/opt/custom/scc"));
555 let dir = tempfile::TempDir::new().unwrap();
556 let root = dir.path().join("repo");
557 std::fs::create_dir_all(&root).unwrap();
558 cmd_setup_omp(&root).unwrap();
559 let mcp: serde_json::Value =
560 serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
561 assert_eq!(mcp["mcpServers"]["scc"]["command"], "/opt/custom/scc");
562 let ts = installed_extension(&root);
563 assert!(ts.contains("process.env.SCC_BIN"), "{ts}");
564 }
565
566 #[test]
567 fn setup_instructions_do_not_claim_extensions_reloads() {
569 let src = include_str!("plugin_omp.rs");
572 assert!(
573 src.contains("Restart OMP, then run `/extensions` to verify"),
574 "instructions must say restart, then /extensions to verify"
575 );
576 assert!(
577 src.contains("`/extensions` is an inspector, not a reload"),
578 "must say /extensions is an inspector"
579 );
580 let installer = src.split("#[cfg(test)]").next().unwrap();
582 assert!(
583 !installer.contains("or run `/extensions` to reload"),
584 "/extensions is an inspector, not a reload"
585 );
586 }
587}