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
106fn strip_authoring_markers(src: &str) -> String {
112 const EXEMPT: &str = "// trace:exempt reason=scc-installed-tooling (authoring marker from the SCC source repo removed at install)";
113 let mut out = String::with_capacity(src.len());
114 for line in src.split('\n') {
115 if line.trim_start().starts_with("// trace:v1 ") {
116 let indent = &line[..line.len() - line.trim_start().len()];
117 out.push_str(indent);
118 out.push_str(EXEMPT);
119 } else {
120 out.push_str(line);
121 }
122 out.push('\n');
123 }
124 if src.ends_with('\n') {
125 out.pop();
126 }
127 out
128}
129
130fn installable_aux_ts(src: &str) -> String {
132 strip_authoring_markers(src)
133}
134
135pub fn cmd_setup_pi(root: &Path) -> crate::Result<()> {
140 let ext_dir = root.join(".pi").join("extensions").join("scc");
141 std::fs::create_dir_all(&ext_dir)?;
142 std::fs::write(ext_dir.join("index.ts"), installable_pi_ts(EXTENSION_TS))?;
143 std::fs::write(ext_dir.join("update-check.ts"), installable_aux_ts(UPDATE_CHECK_TS))?;
144 std::fs::write(ext_dir.join("package.json"), extension_package())?;
145 println!("wrote {}", ext_dir.join("index.ts").display());
146 println!();
147 println!("Pi integration installed (project-local .pi/extensions/scc).");
148 println!("Restart Pi; the extension hot-reloads with /reload.");
149 println!("The `scc` binary must be on PATH, or set SCC_BIN.");
150 Ok(())
151}
152
153fn installable_pi_ts(src: &str) -> String {
155 strip_authoring_markers(src).replace(
156 "@oh-my-pi/pi-coding-agent",
157 "@mariozechner/pi-coding-agent",
158 )
159}
160
161fn extension_package() -> &'static str {
163 EXTENSION_PACKAGE
164}
165
166fn skill_md() -> &'static str {
168 SKILL_MD
169}
170
171fn scc_bin() -> String {
173 if let Ok(v) = std::env::var("SCC_BIN") {
174 let v = v.trim().to_string();
175 if !v.is_empty() {
176 return v;
177 }
178 }
179 if let Ok(exe) = std::env::current_exe() {
183 if exe.file_stem().map(|n| n == "scc").unwrap_or(false) {
184 return exe.to_string_lossy().into_owned();
185 }
186 }
187 "scc".to_string()
188}
189
190fn merge_mcp_json(omp_dir: &Path) -> crate::Result<()> {
197 let path = omp_dir.join("mcp.json");
198 let mut v: serde_json::Value = if path.exists() {
199 let text = std::fs::read_to_string(&path)?;
200 serde_json::from_str(&text).unwrap_or(serde_json::json!({}))
201 } else {
202 serde_json::json!({})
203 };
204 let servers = v
205 .get_mut("mcpServers")
206 .and_then(|m| m.as_object_mut())
207 .cloned()
208 .unwrap_or_default();
209 let mut servers = servers;
210 let cmd = scc_bin();
211 match servers.get_mut("scc") {
212 Some(existing) => {
213 if std::env::var("SCC_BIN").map(|s| !s.trim().is_empty()).unwrap_or(false) {
214 existing["command"] = serde_json::Value::String(cmd);
215 }
216 }
217 None => {
218 servers.insert(
219 "scc".to_string(),
220 serde_json::json!({"command": cmd, "args": ["mcp"]}),
221 );
222 }
223 }
224 v["mcpServers"] = serde_json::Value::Object(servers);
225 std::fs::write(&path, serde_json::to_string_pretty(&v)?)?;
226 Ok(())
227}
228
229fn write_agents_rules(root: &Path) -> crate::Result<std::path::PathBuf> {
233 let resolution = resolve_omp_agents_path(root);
234 if resolution.both_exist {
235 eprintln!(
236 "warning: both .omp/AGENTS.md and AGENTS.md exist; OMP prefers .omp/AGENTS.md \
237 (higher-priority same-scope context) and will shadow the root file. \
238 Patching .omp/AGENTS.md. Remove one of the files if that precedence is unintended."
239 );
240 }
241 let path = match resolution.target {
242 AgentsTarget::Omp => root.join(".omp").join("AGENTS.md"),
243 AgentsTarget::Root => root.join("AGENTS.md"),
244 };
245 if let Some(parent) = path.parent() {
246 std::fs::create_dir_all(parent)?;
247 }
248 let existing = std::fs::read_to_string(&path).unwrap_or_default();
249 let out = replace_scc_section_in(
250 &existing,
251 AGENTS_RULES,
252 SCC_OMP_SECTION_OPEN,
253 SCC_OMP_SECTION_CLOSE,
254 );
255 std::fs::write(&path, out)?;
256 Ok(path)
257}
258
259#[cfg(test)]
260mod tests {
261 #[test]
262 fn pi_setup_installs_retargeted_extension() {
264 let dir = tempfile::TempDir::new().unwrap();
265 let root = dir.path().join("repo");
266 std::fs::create_dir_all(&root).unwrap();
267 cmd_setup_pi(&root).unwrap();
268 let ext = root.join(".pi").join("extensions").join("scc");
269 assert!(ext.join("index.ts").exists());
270 assert!(ext.join("update-check.ts").exists());
271 assert!(ext.join("package.json").exists());
272 let ts = std::fs::read_to_string(ext.join("index.ts")).unwrap();
273 assert!(ts.contains("@mariozechner/pi-coding-agent"), "pi scope retargeted");
274 assert!(!ts.contains("@oh-my-pi/"), "no fork scope remains");
275 assert!(ts.contains("maybeNotifyUpdate"), "notifier wired");
276 assert!(!ts.contains("trace:v1 id="), "authoring markers stripped");
277 }
278
279 use super::*;
280 use std::sync::{Mutex, MutexGuard};
281
282 static ENV_LOCK: Mutex<()> = Mutex::new(());
283
284 struct SccBinGuard {
290 _lock: MutexGuard<'static, ()>,
291 prev: Option<String>,
292 }
293
294 impl Drop for SccBinGuard {
296 fn drop(&mut self) {
298 match &self.prev {
299 Some(v) => std::env::set_var("SCC_BIN", v),
300 None => std::env::remove_var("SCC_BIN"),
301 }
302 }
303 }
304
305 fn lock_scc_bin(value: Option<&str>) -> SccBinGuard {
307 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
308 let prev = std::env::var("SCC_BIN").ok();
309 match value {
310 Some(v) => std::env::set_var("SCC_BIN", v),
311 None => std::env::remove_var("SCC_BIN"),
312 }
313 SccBinGuard { _lock: lock, prev }
314 }
315
316 fn installed_extension(root: &Path) -> String {
318 std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap()
319 }
320
321 #[test]
322 fn installs_extension_and_mcp() {
324 let _env = lock_scc_bin(None);
325 let dir = tempfile::TempDir::new().unwrap();
326 let root = dir.path().join("repo");
327 std::fs::create_dir_all(&root).unwrap();
328 cmd_setup_omp(&root).unwrap();
329
330 assert!(root.join(".omp/extensions/scc/index.ts").exists());
332 assert!(root.join(".omp/extensions/scc/update-check.ts").exists());
333 assert!(root.join(".omp/extensions/scc/package.json").exists());
334 let installed_ts = std::fs::read_to_string(root.join(".omp/extensions/scc/index.ts")).unwrap();
335 assert!(!installed_ts.contains("trace:v1 id="), "authoring markers stripped at install");
336 let ts = installed_extension(&root);
338 assert!(ts.contains("@oh-my-pi/pi-coding-agent"), "extension must import the canonical resolvable package");
339 assert!(ts.contains("before_agent_start"), "extension must wire before_agent_start");
340 assert!(ts.contains("session_start"), "extension must wire session_start");
341
342 let mcp: serde_json::Value =
344 serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
345 assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc");
346 assert_eq!(mcp["mcpServers"]["scc"]["args"][0], "mcp");
347
348 assert!(root.join("AGENTS.md").exists(), "canonical instruction file is root AGENTS.md");
350 assert!(
351 !root.join(".omp/AGENTS.md").exists(),
352 "must not create shadowing .omp/AGENTS.md when root did not exist"
353 );
354 let agents = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
355 assert!(agents.contains("SCC (System Context Compiler)"), "AGENTS.md must carry SCC durable rules");
356 let skill = std::fs::read_to_string(root.join(".omp/skills/scc-system-context/SKILL.md")).unwrap();
357 assert!(skill.contains("`system_context`"), "skill must teach system_context");
358 assert!(skill.contains("`surface_map`"), "skill must teach surface_map");
359 assert!(skill.contains("`structural_source`"), "skill must teach structural_source");
360 assert!(!skill.contains("| `system_overview` |"), "skill table must not lead with the retired startup tool");
361 }
362
363 #[test]
364 fn mcp_merge_preserves_existing_servers() {
366 let _env = lock_scc_bin(None);
367 let dir = tempfile::TempDir::new().unwrap();
368 let root = dir.path().join("repo");
369 std::fs::create_dir_all(&root).unwrap();
370 let omp = root.join(".omp");
371 std::fs::create_dir_all(&omp).unwrap();
372 std::fs::write(
373 omp.join("mcp.json"),
374 r#"{"mcpServers":{"existing":{"command":"foo","args":["bar"]}}}"#,
375 )
376 .unwrap();
377 merge_mcp_json(&omp).unwrap();
378 let mcp: serde_json::Value =
379 serde_json::from_str(&std::fs::read_to_string(omp.join("mcp.json")).unwrap()).unwrap();
380 assert_eq!(mcp["mcpServers"]["existing"]["command"], "foo", "existing server must be preserved");
381 assert_eq!(mcp["mcpServers"]["scc"]["command"], "scc", "scc server must be added");
382 }
383
384 #[test]
385 fn agents_idempotent_preserves_user_content() {
387 let dir = tempfile::TempDir::new().unwrap();
388 let root = dir.path().join("repo");
389 std::fs::create_dir_all(&root).unwrap();
390 let omp = root.join(".omp");
391 std::fs::create_dir_all(&omp).unwrap();
392 std::fs::write(omp.join("AGENTS.md"), "USER CONTENT\n").unwrap();
393 write_agents_rules(&root).unwrap();
394 let first = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
395 assert!(first.starts_with("USER CONTENT"), "user content must be preserved");
396 assert!(first.contains("SCC (System Context Compiler)"), "SCC rules must be appended");
397 write_agents_rules(&root).unwrap();
399 let second = std::fs::read_to_string(omp.join("AGENTS.md")).unwrap();
400 assert_eq!(
401 first.matches("SCC (System Context Compiler)").count(),
402 second.matches("SCC (System Context Compiler)").count(),
403 "SCC rules must not duplicate on reinstall"
404 );
405 }
406
407 #[test]
408 fn agents_rewrite_preserves_text_after_closing_marker() {
410 let dir = tempfile::TempDir::new().unwrap();
411 let root = dir.path().join("repo");
412 std::fs::create_dir_all(root.join(".omp")).unwrap();
413 std::fs::write(
414 root.join(".omp/AGENTS.md"),
415 "BEFORE\n<!-- SCC-OMP-SECTION -->\nold managed\n<!-- /SCC-OMP-SECTION -->\nKEEP AFTER\n",
416 )
417 .unwrap();
418 write_agents_rules(&root).unwrap();
419 let text = std::fs::read_to_string(root.join(".omp/AGENTS.md")).unwrap();
420 assert!(text.contains("BEFORE"), "{text}");
421 assert!(text.contains("KEEP AFTER"), "text after the closing marker must survive: {text}");
422 assert!(!text.contains("old managed"), "{text}");
423 }
424
425 #[test]
426 fn agents_patches_existing_root_and_does_not_create_shadow() {
428 let dir = tempfile::TempDir::new().unwrap();
429 let root = dir.path().join("repo");
430 std::fs::create_dir_all(&root).unwrap();
431 std::fs::write(root.join("AGENTS.md"), "# project rules\nkeep me\n").unwrap();
432 let path = write_agents_rules(&root).unwrap();
433 assert_eq!(path, root.join("AGENTS.md"));
434 assert!(!root.join(".omp/AGENTS.md").exists(), "must not create shadowing .omp/AGENTS.md");
435 let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
436 assert!(text.contains("keep me"), "{text}");
437 assert!(text.contains("SCC (System Context Compiler)"), "{text}");
438 }
439
440 #[test]
441 fn omp_and_codex_sections_coexist_in_one_file() {
443 let dir = tempfile::TempDir::new().unwrap();
447 let root = dir.path().join("repo");
448 std::fs::create_dir_all(&root).unwrap();
449 std::fs::write(
450 root.join("AGENTS.md"),
451 "# user\n<!-- SCC-SECTION -->\ncodex capsule\n<!-- /SCC-SECTION -->\n",
452 )
453 .unwrap();
454 write_agents_rules(&root).unwrap();
455 let text = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
456 assert!(text.contains("codex capsule"), "codex section must survive OMP setup: {text}");
457 assert!(text.contains("SCC-OMP-SECTION"), "OMP section must be installed: {text}");
458 write_agents_rules(&root).unwrap();
460 let again = std::fs::read_to_string(root.join("AGENTS.md")).unwrap();
461 assert_eq!(again.matches("SCC-OMP-SECTION").count(), 2, "one OMP section: {again}");
462 assert!(again.contains("codex capsule"), "codex section survives reinstall: {again}");
463 }
464
465 #[test]
466 fn generated_extension_invokes_index_paths_and_does_not_treat_failure_as_success() {
468 let _env = lock_scc_bin(None);
469 let dir = tempfile::TempDir::new().unwrap();
470 let root = dir.path().join("repo");
471 std::fs::create_dir_all(&root).unwrap();
472 cmd_setup_omp(&root).unwrap();
473 let ts = installed_extension(&root);
474 assert!(
475 ts.contains("\"--paths\"") || ts.contains("[\"index\", \"--paths\""),
476 "generated extension must invoke `scc index --paths` (plural): {ts}"
477 );
478 assert!(
479 !ts.contains("\"--path\""),
480 "must not pass the rejected singular --path flag: {ts}"
481 );
482 assert!(
483 ts.contains("index --paths failed") || ts.contains("did not succeed"),
484 "failed index must be reported, not treated as success: {ts}"
485 );
486 assert!(
487 ts.contains("session.compacting") && ts.contains("session_before_compact"),
488 "compaction must save on session_before_compact and inject on session.compacting: {ts}"
489 );
490 assert!(
491 ts.contains("checkpoint") && ts.contains("load") && ts.contains("--inject"),
492 "compacting must load the checkpoint with --inject: {ts}"
493 );
494 assert!(
495 ts.contains("process.env.SCC_BIN") || ts.contains("SCC_BIN"),
496 "extension must honor SCC_BIN: {ts}"
497 );
498 assert!(
499 ts.contains("session_switch") && ts.contains("session_branch") && ts.contains("session_tree"),
500 "injection marker must reset on switch/branch/tree: {ts}"
501 );
502 assert!(
503 ts.contains("hash-object") || ts.contains("porcelain"),
504 "opaque mutations must snapshot dirty files: {ts}"
505 );
506 assert!(
507 ts.contains("--porcelain=v1") && ts.contains("-z"),
508 "porcelain must be NUL-delimited (-z): {ts}"
509 );
510 assert!(
511 ts.contains("\"--name-only\"") || ts.contains("diff\", \"--name-only\""),
512 "HEAD revision diffs must use --name-only: {ts}"
513 );
514 assert!(
515 ts.contains("split(\"\\0\")") || ts.contains("split('\\0')") || ts.contains(".split(\"\\0\")"),
516 "revision diffs must split on NUL, not newlines: {ts}"
517 );
518 assert!(
519 ts.contains("rev-parse") && ts.contains("HEAD"),
520 "snapshots must record HEAD so clean-to-clean mutations refresh: {ts}"
521 );
522 assert!(
523 !ts.contains("toolName}:${Date.now()") && !ts.contains("toolName}:${Date.now()}"),
524 "dirtySnapshots must not use a Date.now() fallback id: {ts}"
525 );
526 assert!(
527 ts.contains("if (!id) return"),
528 "snapshots without toolCallId must be skipped: {ts}"
529 );
530 assert!(
531 ts.contains("startupOk"),
532 "compaction must track startup injection separately from checkpoint: {ts}"
533 );
534 }
535
536 #[test]
537 fn mcp_json_honors_scc_bin_at_setup() {
539 let _env = lock_scc_bin(Some("/opt/custom/scc"));
540 let dir = tempfile::TempDir::new().unwrap();
541 let root = dir.path().join("repo");
542 std::fs::create_dir_all(&root).unwrap();
543 cmd_setup_omp(&root).unwrap();
544 let mcp: serde_json::Value =
545 serde_json::from_str(&std::fs::read_to_string(root.join(".omp/mcp.json")).unwrap()).unwrap();
546 assert_eq!(mcp["mcpServers"]["scc"]["command"], "/opt/custom/scc");
547 let ts = installed_extension(&root);
548 assert!(ts.contains("process.env.SCC_BIN"), "{ts}");
549 }
550
551 #[test]
552 fn setup_instructions_do_not_claim_extensions_reloads() {
554 let src = include_str!("plugin_omp.rs");
557 assert!(
558 src.contains("Restart OMP, then run `/extensions` to verify"),
559 "instructions must say restart, then /extensions to verify"
560 );
561 assert!(
562 src.contains("`/extensions` is an inspector, not a reload"),
563 "must say /extensions is an inspector"
564 );
565 let installer = src.split("#[cfg(test)]").next().unwrap();
567 assert!(
568 !installer.contains("or run `/extensions` to reload"),
569 "/extensions is an inspector, not a reload"
570 );
571 }
572}