1use crate::{config_path, load_config, open_store, scc_dir};
4
5fn engine_err(e: scc_engine::EngineError) -> crate::CliError {
7 crate::CliError::Other(e.to_string())
8}
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12pub fn cmd_init(root: &Path) -> crate::Result<()> {
14 let dir = scc_dir(root);
15 std::fs::create_dir_all(&dir)?;
16 let cfg_path = config_path(root);
17 if !cfg_path.exists() {
18 std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
19 println!("created {}", cfg_path.display());
20 } else {
21 println!("config exists: {}", cfg_path.display());
22 }
23 let store = open_store(root)?;
25 crate::ensure_scc_ignored(root);
26 println!(
27 "initialized SCC workspace for repository '{}' at {}",
28 store.repo_name,
29 dir.display()
30 );
31 println!("next: scc index");
32 Ok(())
33}
34
35pub fn cmd_index(root: &Path, quiet: bool) -> crate::Result<()> {
37 let config = load_config(root)?;
38 let report = scc_engine::index::full(root, &config).map_err(engine_err)?;
39 if !quiet {
40 println!(
41 "indexed {} file(s) ({} changed, {} added, {} removed, {} failed) in {:.2}s",
42 report.indexed,
43 report.changed,
44 report.added,
45 report.removed,
46 report.failed,
47 report.duration_ms as f64 / 1000.0
48 );
49 println!("analysis_quality: {}", report.analysis_quality.compact_line());
50 let s = &report.scan_stats;
51 println!(
52 "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
53 s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
54 );
55 }
56 Ok(())
57}
58
59pub fn cmd_index_paths(root: &Path, paths: &[String], quiet: bool) -> crate::Result<()> {
61 let config = load_config(root)?;
62 let report = scc_engine::index::refresh_paths(root, &config, paths).map_err(engine_err)?;
63 if !quiet && report.indexed > 0 {
64 println!("refreshed {} file(s)", report.indexed);
65 }
66 Ok(())
67}
68
69pub fn cmd_status(root: &Path) -> crate::Result<()> {
71 let store = open_store(root)?;
72 let s = scc_engine::status::status(&store).map_err(engine_err)?;
73 println!("Repository: {} ({})", s.repository, s.repository_id);
74 if let Some(url) = &s.remote {
75 println!("Remote: {url}");
76 }
77 if s.indexed {
78 println!("Revision: {}", s.revision);
79 if let Some(b) = &s.branch {
80 println!("Branch: {b}");
81 }
82 println!("Indexed at: {}", s.indexed_at.as_deref().unwrap_or(""));
83 let mut keys: Vec<&String> = s.stats.keys().collect();
84 keys.sort();
85 for k in keys {
86 println!("{k}: {}", s.stats[k]);
87 }
88 if s.freshness == "CURRENT" {
89 println!("freshness: CURRENT — model matches working tree");
90 } else {
91 println!(
92 "freshness: STALE — {} file(s) changed since index (run `scc index`)",
93 s.stale_count
94 );
95 for f in &s.stale_files {
96 println!(" {f}");
97 }
98 }
99 if let Some(raw) = &s.analysis_quality {
100 match serde_json::from_str::<scc_core::AnalysisQuality>(raw) {
101 Ok(q) => println!("analysis_quality: {}", q.compact_line()),
102 Err(_) => println!("analysis_quality: {raw}"),
103 }
104 }
105 if let Some(v) = &s.scan_stats {
106 let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
109 println!(
110 "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
111 n("discovered"),
112 n("indexed"),
113 n("ignored"),
114 n("unsupported"),
115 n("oversized"),
116 n("unreadable")
117 );
118 }
119 } else {
120 println!("not indexed yet — run `scc index`");
121 }
122 Ok(())
123}
124
125pub fn cmd_languages() -> crate::Result<()> {
129 print!("{}", scc_core::support_matrix_markdown());
130 Ok(())
131}
132
133pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
135 let store = open_store(root)?;
136 let config = load_config(root)?;
137 let stale = crate::stale_paths(&store)?;
138 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
139 let pack = engine.context().overview().map_err(engine_err)?;
140 if json {
141 println!("{}", serde_json::to_string_pretty(&pack)?);
142 } else {
143 print!("{}", pack.content);
144 }
145 Ok(())
146}
147pub fn cmd_atlas(
152 root: &Path,
153 budget: Option<usize>,
154 json: bool,
155 full: bool,
156 unbounded: bool,
157) -> crate::Result<()> {
158 let store = open_store(root)?;
159 let config = load_config(root)?;
160 let stale = crate::stale_paths(&store)?;
161 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
162 let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
163 if json {
164 println!("{}", serde_json::to_string(&pack)?);
165 } else {
166 print!("{}", pack.content);
167 }
168 Ok(())
169}
170
171pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>) -> crate::Result<()> {
178 let store = open_store(root)?;
179 let config = load_config(root)?;
180 let stale = crate::stale_paths(&store)?;
181 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
182 let (_startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
183 print!("{text}");
184 Ok(())
185}
186
187pub fn cmd_surface(
196 root: &Path,
197 task: Option<&str>,
198 budget: Option<usize>,
199 explain: bool,
200) -> crate::Result<()> {
201 let store = open_store(root)?;
202 let config = load_config(root)?;
203 let stale = crate::stale_paths(&store)?;
204 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
205 let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
206 let scorer = match task {
212 Some(goal) => {
213 let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
214 scorer
215 }
216 None => None,
217 };
218 let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
219 scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
220 let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
221 let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
222 print!("{text}");
223 Ok(())
224}
225
226pub fn cmd_important(
234 root: &Path,
235 limit: usize,
236 component: Option<&str>,
237 task: Option<&str>,
238 json: bool,
239) -> crate::Result<()> {
240 let store = open_store(root)?;
241 let config = load_config(root)?;
242 let stale = crate::stale_paths(&store)?;
243 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
244 let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
245 if json {
246 println!("{}", serde_json::to_string_pretty(&entries)?);
247 return Ok(());
248 }
249 print!("{}", scc_context::surface::render_important(&entries, tasked));
250 Ok(())
251}
252
253pub fn cmd_context_structural(
261 root: &Path,
262 files: &[String],
263 task: Option<&str>,
264 budget: Option<usize>,
265) -> crate::Result<String> {
266 let out = scc_engine::invoke(
268 root,
269 "context.structural",
270 serde_json::json!({"files": files, "task": task, "budget": budget}),
271 )
272 .map_err(engine_err)?;
273 Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
274}
275
276#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
279pub struct TaskContextArtifact {
281 pub pack: scc_context::ContextPack,
282 pub delta: String,
285 #[serde(default)]
288 pub delta_ids: Vec<String>,
289 #[serde(default)]
294 pub token_count: usize,
295}
296
297pub fn build_task_context(
304 root: &Path,
305 goal: &str,
306 files: &[String],
307 symbols: &[String],
308 budget: Option<usize>,
309 hook: bool,
310) -> crate::Result<TaskContextArtifact> {
311 build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
312}
313
314fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
316 TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
317}
318
319fn build_task_context_engine(
321 root: &Path,
322 goal: &str,
323 files: &[String],
324 symbols: &[String],
325 budget: Option<usize>,
326 hook: bool,
327) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
328 let out = scc_engine::invoke(
331 root,
332 "context.task",
333 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
334 )?;
335 serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
336}
337
338pub fn build_enriched_task_pack(
347 root: &Path,
348 goal: &str,
349 files: &[String],
350 symbols: &[String],
351 budget: Option<usize>,
352 hook: bool,
353) -> crate::Result<scc_context::ContextPack> {
354 let out = scc_engine::invoke(
356 root,
357 "context.task_pack",
358 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
359 )
360 .map_err(engine_err)?;
361 serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
363}
364
365pub fn cmd_context_task(
372 root: &Path,
373 goal: &str,
374 files: &[String],
375 symbols: &[String],
376 budget: Option<usize>,
377 json: bool,
378 hook: bool,
379) -> crate::Result<()> {
380 if hook {
381 let config = load_config(root)?;
384 if !config.context.inject_task_focus {
385 return Ok(());
386 }
387 }
388 let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
389 if json {
390 println!("{}", serde_json::to_string_pretty(&artifact)?);
391 } else {
392 print!("{}", artifact.pack.content);
393 print!("\n{}", artifact.delta);
394 }
395 Ok(())
396}
397
398
399pub fn build_task_pack(
404 root: &Path,
405 goal: &str,
406 files: &[String],
407 symbols: &[String],
408 budget: Option<usize>,
409) -> crate::Result<scc_context::ContextPack> {
410 build_enriched_task_pack(root, goal, files, symbols, budget, false)
411}
412pub fn cmd_context_task_json(
416 root: &Path,
417 goal: &str,
418 files: &[String],
419 symbols: &[String],
420 budget: Option<usize>,
421) -> crate::Result<String> {
422 Ok(serde_json::to_string_pretty(&build_task_context(
423 root, goal, files, symbols, budget, false,
424 )?)?)
425}
426
427pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
431 print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
432 Ok(())
433}
434
435pub fn cmd_context_subagent(
440 root: &Path,
441 goal: &str,
442 files: &[String],
443 symbols: &[String],
444 budget: Option<usize>,
445 json: bool,
446) -> crate::Result<()> {
447 let store = open_store(root)?;
448 let config = load_config(root)?;
449 let stale = crate::stale_paths(&store)?;
450 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
451 let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
452 if json {
453 println!("{}", serde_json::to_string_pretty(&pack)?);
454 } else {
455 print!("{}", pack.content);
456 }
457 Ok(())
458}
459
460pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
462 let store = open_store(root)?;
463 let config = load_config(root)?;
464 let stale = crate::stale_paths(&store)?;
465 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
466 let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
467 if json {
468 println!("{}", serde_json::to_string_pretty(&pack)?);
469 } else {
470 print!("{}", pack.content);
471 }
472 Ok(())
473}
474
475pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
477 let store = open_store(root)?;
478 let config = load_config(root)?;
479 let stale = crate::stale_paths(&store)?;
480 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
481 let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
482 if json {
483 println!("{}", serde_json::to_string_pretty(&pack)?);
484 } else {
485 print!("{}", pack.content);
486 }
487 Ok(())
488}
489
490pub fn cmd_impact(
492 root: &Path,
493 files: &[String],
494 symbols: &[String],
495 diff: Option<&str>,
496 json: bool,
497 unbounded: bool,
498) -> crate::Result<()> {
499 let store = open_store(root)?;
500 let config = load_config(root)?;
501 let stale = crate::stale_paths(&store)?;
502 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
503 let pack = engine.context().impact(&scc_api::ImpactRequest { files: files.to_vec(), symbols: symbols.to_vec(), diff: diff.map(|s| s.to_string()), unbounded }).map_err(engine_err)?;
504 if json {
505 println!("{}", serde_json::to_string_pretty(&pack)?);
506 } else {
507 print!("{}", pack.content);
508 }
509 Ok(())
510}
511
512pub fn cmd_verify(
514 root: &Path,
515 warnings_only: bool,
516 json: bool,
517 unbounded: bool,
518) -> crate::Result<()> {
519 let store = open_store(root)?;
520 let config = load_config(root)?;
521 let stale = crate::stale_paths(&store)?;
522 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
523 let pack = engine.context().verify(unbounded).map_err(engine_err)?;
524 if warnings_only {
525 for w in &pack.warnings {
526 println!("⚠ {w}");
527 }
528 return Ok(());
529 }
530 if json {
531 println!("{}", serde_json::to_string_pretty(&pack)?);
532 return Ok(());
533 }
534 print!("{}", pack.content);
535 Ok(())
536}
537
538pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
540 let store = open_store(root)?;
541 let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
542 if json {
543 println!("{}", serde_json::to_string_pretty(&findings)?);
544 } else {
545 if findings.is_empty() {
546 println!("no drift findings");
547 }
548 for f in &findings {
549 println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
550 }
551 }
552 Ok(())
553}
554
555pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
557 let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
558 if json {
559 println!("{}", serde_json::to_string_pretty(&all)?);
560 return Ok(());
561 }
562 println!(
563 "system: {} members, {} stitches",
564 members.len(),
565 all.len()
566 );
567 for m in &members {
568 println!("member {} ({})", m.repo_id, m.root.display());
569 }
570 for s in &all {
571 println!(
572 "[{:?}/{:?}] {} ({} ends)",
573 s.kind,
574 s.match_kind,
575 s.key,
576 s.ends.len()
577 );
578 for e in &s.ends {
579 match e.role {
580 Some(role) => println!(" {} {} ({role:?})", e.repo_id, e.entity_id),
581 None => println!(" {} {}", e.repo_id, e.entity_id),
582 }
583 }
584 }
585 Ok(())
586}
587
588pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
590 let store = open_store(root)?;
591 let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
592 if json {
593 println!("{}", serde_json::to_string_pretty(&revs)?);
594 return Ok(());
595 }
596 if revs.is_empty() {
597 println!("no graph revisions recorded");
598 return Ok(());
599 }
600 for r in &revs {
601 println!(
602 "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
603 r.rev,
604 r.base_rev,
605 r.entity_count,
606 r.rel_count,
607 r.file_count,
608 &r.source_hash[..r.source_hash.len().min(12)],
609 r.extractor_version,
610 r.created_at,
611 );
612 }
613 Ok(())
614}
615
616pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
618 let store = open_store(root)?;
619 let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
620 if json {
621 println!("{}", serde_json::to_string_pretty(&d)?);
622 return Ok(());
623 }
624 println!("diff rev {from}..{to}:");
626 for (label, ids) in [
627 ("added entities", &d.added_entities),
628 ("removed entities", &d.removed_entities),
629 ("modified entities", &d.modified_entities),
630 ("added relationships", &d.added_relationships),
631 ("removed relationships", &d.removed_relationships),
632 ("modified relationships", &d.modified_relationships),
633 ] {
634 println!(" {label}: {}", ids.len());
635 for id in ids.iter().take(20) {
636 println!(" {id}");
637 }
638 if ids.len() > 20 {
639 println!(" … ({} more)", ids.len() - 20);
640 }
641 }
642 if !d.modified_kinds.is_empty() {
643 let kinds: Vec<String> = d
644 .modified_kinds
645 .iter()
646 .map(|(k, v)| format!("{k}:{v}"))
647 .collect();
648 println!(" modified kinds: {}", kinds.join(", "));
649 }
650 Ok(())
651}
652
653pub fn cmd_snapshot_save(
655 root: &Path,
656 task: &str,
657 budget: Option<usize>,
658 json: bool,
659) -> crate::Result<()> {
660 let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
661 if json {
662 println!("{}", serde_json::to_string_pretty(&snap)?);
663 } else {
664 println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
665 }
666 Ok(())
667}
668
669pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
671 let store = open_store(root)?;
672 match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
673 Some(s) => print!("{}", s.artifact),
674 None => println!("no snapshot {id}"),
675 }
676 Ok(())
677}
678
679pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
681 let store = open_store(root)?;
682 match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
683 Some(d) => {
684 if json {
685 println!("{}", serde_json::to_string_pretty(&d)?);
686 } else {
687 println!(
688 "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
689 d.snapshot_revision,
690 d.current_revision,
691 d.still_valid.len(),
692 d.invalidated.len(),
693 d.modified_entities.len(),
694 if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
695 );
696 for (label, ids) in [
697 ("invalidated", &d.invalidated),
698 ("modified entities", &d.modified_entities),
699 ("changed relationships", &d.changed_relationships),
700 ("changed contracts", &d.changed_contracts),
701 ("changed state", &d.changed_state),
702 ("changed flows", &d.changed_flows),
703 ] {
704 if ids.is_empty() {
705 continue;
706 }
707 println!(" {label} ({}):", ids.len());
708 for f in ids.iter().take(20) {
709 println!(" - {f}");
710 }
711 if ids.len() > 20 {
712 println!(" … ({} more)", ids.len() - 20);
713 }
714 }
715 }
716 }
717 None => println!("no snapshot {id}"),
718 }
719 Ok(())
720}
721
722pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
724 let store = open_store(root)?;
725 match format {
726 "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
727 "system-ir.jsonl" => {
728 let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
729 for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
730 println!("{line}");
731 }
732 }
733 "ccg" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::ccg(&scc_engine::exports::system_ir(&store).map_err(engine_err)?).map_err(engine_err)?)?),
734 "flow-graphs.json" => {
735 println!(
736 "{}",
737 serde_json::to_string_pretty(&store.flow_graphs()?)?
738 )
739 }
740 "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
741 other => {
742 return Err(crate::CliError::Other(format!(
743 "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
744 )))
745 }
746 }
747 Ok(())
748}
749
750pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
753 let store = open_store(root)?;
754 let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
755 println!("— entities —");
756 for e in &hit.entities {
757 println!("{} [{}]", e.name, e.kind);
758 }
759 println!("— symbols —");
760 for (name, sig, kind, file, line) in &hit.symbols {
761 if *line > 0 {
762 println!("{name} ({kind}) {file}:{line} {sig}");
763 } else {
764 println!("{name} ({kind}) {file} {sig}");
765 }
766 }
767 Ok(())
768}
769
770pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
772 let store = open_store(root)?;
773 for c in scc_engine::graph::components(&store).map_err(engine_err)? {
774 println!("{}", c.name);
775 }
776 Ok(())
777}
778
779pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
781 let store = open_store(root)?;
782 for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
783 println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
784 }
785 Ok(())
786}
787
788pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
793 let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
794 if pairs.is_empty() {
795 println!("no co-change pairs with >= {min_commits} shared commits");
796 } else {
797 println!("co-change pairs (>= {min_commits} shared commits):");
798 for p in &pairs {
799 println!(" {} <-> {} ×{}", p.a, p.b, p.commits);
800 }
801 }
802 if n > 0 {
803 println!("enriched {n} components with co-change signal");
804 }
805 Ok(())
806}
807
808pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
811 let store = open_store(root)?;
812 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
813 for v in &violations {
814 println!("{}", v.message);
815 }
816 Ok(violations.is_empty())
817}
818
819pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
823 let store = open_store(root)?;
824 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
825 for v in &violations {
826 println!("{}", v.message);
827 }
828 let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
829 for l in &lines {
830 if l.starts_with("[ci:") || *l == "ci check passed" {
832 println!("{l}");
833 }
834 }
835 Ok(ok)
836}
837
838pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
840 let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
841 if json {
842 println!("{}", serde_json::to_string(&data)?);
843 } else {
844 println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
845 }
846 Ok(())
847}
848
849pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
851 if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
852 print!("{content}");
853 } else if !inject {
854 println!("no checkpoint found");
855 }
856 Ok(())
857}
858
859pub fn cmd_watch(root: &Path) -> crate::Result<()> {
861 crate::httpd::watch_loop(root)
862}
863
864pub fn cmd_serve(root: &Path) -> crate::Result<()> {
866 crate::httpd::serve(root)
867}
868
869pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
871 crate::mcp::serve_stdio(root)
872}
873
874pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
876 crate::plugin::install(root)
877}
878
879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
883pub enum SetupHarness {
885 Claude,
886 Codex,
887 Opencode,
888 Omp,
889 Pi,
890}
891
892pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
898 let on_path = |bin: &str| {
899 path_dirs
900 .iter()
901 .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
902 };
903 let mut out = Vec::new();
904 if on_path("claude") || home.join(".claude").is_dir() {
905 out.push(SetupHarness::Claude);
906 }
907 if on_path("codex") || home.join(".codex").is_dir() {
908 out.push(SetupHarness::Codex);
909 }
910 if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
911 out.push(SetupHarness::Opencode);
912 }
913 if on_path("omp") || root.join(".omp").is_dir() {
914 out.push(SetupHarness::Omp);
915 }
916 if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
917 out.push(SetupHarness::Pi);
918 }
919 out
920}
921
922fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
924 match h {
925 SetupHarness::Claude => cmd_setup_claude(root),
926 SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
927 SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
928 SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
929 SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
930 }
931}
932
933pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
939 let targets: Vec<SetupHarness> = if all {
940 vec![
941 SetupHarness::Claude,
942 SetupHarness::Codex,
943 SetupHarness::Opencode,
944 SetupHarness::Omp,
945 SetupHarness::Pi,
946 ]
947 } else {
948 let home = std::env::var_os("HOME")
949 .map(PathBuf::from)
950 .unwrap_or_else(|| PathBuf::from("/"));
951 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
952 .map(|p| std::env::split_paths(&p).collect())
953 .unwrap_or_default();
954 detect_harnesses(&home, &path_dirs, root)
955 };
956 if targets.is_empty() {
957 println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
958 println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
959 println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
960 return Ok(());
961 }
962 for h in &targets {
963 println!("=== {:?} ===", h);
964 install_harness(root, *h)?;
965 println!();
966 }
967 println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
968 if targets.contains(&SetupHarness::Codex) {
969 println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
970 }
971 Ok(())
972}
973
974pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
976 scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
977 println!("accepted");
978 Ok(())
979}
980
981fn adapter_scope(name: &str) -> &'static str {
988 match name {
989 "context7" => "network+subprocess(npx)",
990 "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
991 _ => "filesystem",
992 }
993}
994
995fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1002 use scc_indexer::adapters::IntegrationCategory;
1003 let config = load_config(root)?;
1004 let enabled = |key: Option<&str>| -> bool {
1005 match key {
1006 None => true,
1007 Some("serena") => config.integrations.serena,
1008 Some("beads") => config.integrations.beads,
1009 Some("hindsight") => config.integrations.hindsight,
1010 Some("gitnexus") => config.integrations.gitnexus,
1011 Some("context7_command") => !config.integrations.context7_command.is_empty(),
1012 Some(_) => false,
1015 }
1016 };
1017 Ok(scc_indexer::adapters::integration_registry()
1018 .into_iter()
1019 .filter(|d| {
1020 d.category != IntegrationCategory::CompatibilityOnly
1021 && d.category != IntegrationCategory::InternalPass
1022 && d.category != IntegrationCategory::AgentIntegration
1023 && enabled(d.config_key)
1024 })
1025 .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1026 .collect())
1027}
1028
1029pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1034 let manifests = scc_indexer::adapters::adapter_manifests();
1035 if json {
1036 println!("{}", serde_json::to_string_pretty(&manifests)?);
1037 return Ok(());
1038 }
1039 for (name, scope) in configured_adapters(root)? {
1040 println!("adapter: {name} scope: {scope}");
1041 }
1042 Ok(())
1043}
1044
1045pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1060 let store = open_store(root)?;
1061 let config = load_config(root)?;
1062 let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1063 let rows = rep.integrations;
1064 let agent_rows = rep.agents;
1065 let mut warnings = rep.warnings;
1066 let (scc_version, rev, stale_len, dangling, stats) =
1067 (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1068 if json {
1069 println!(
1070 "{}",
1071 serde_json::to_string_pretty(&serde_json::json!({
1072 "scc": scc_version,
1073 "revision": rev,
1074 "stale_files": stale_len,
1075 "dangling_edges": dangling,
1076 "stats": stats,
1077 "integrations": rows,
1078 "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1079 "warnings": warnings,
1080 }))?
1081 );
1082 } else {
1083 println!("SCC Doctor");
1084 println!("==========");
1085 println!();
1086 println!("CORE");
1087 println!(" scc {scc_version}");
1088 println!(" revision {rev}");
1089 println!(" stale {} file(s)", stale_len);
1090 println!(" dangling {dangling} edge(s) (see `scc check-invariants`)");
1091 println!(" entities {}", stats.get("entities").copied().unwrap_or(0));
1092 println!();
1093 println!("INTEGRATIONS");
1094 for r in &rows {
1095 let mark = if r.contributing {
1096 "✓"
1097 } else if r.configured {
1098 "!"
1099 } else {
1100 "-"
1101 };
1102 let reach = match r.reachable {
1103 Some(true) => "reachable; ",
1104 Some(false) => "UNREACHABLE; ",
1105 None => "",
1106 };
1107 println!(" {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1108 }
1109 println!();
1110 println!("AGENTS");
1111 for a in &agent_rows {
1112 println!(" {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1113 }
1114 println!();
1115 if warnings == 0 {
1116 println!("SUMMARY PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1117 } else {
1118 println!("SUMMARY {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1119 }
1120 }
1121 if dangling > 0 {
1122 warnings += 1;
1123 }
1124 Ok(!(strict && warnings > 0))
1128}
1129
1130pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1135 let dir = scc_dir(root);
1136 std::fs::create_dir_all(&dir)?;
1137 let path = dir.join("lessons.jsonl");
1138 let n = std::fs::read_to_string(&path)
1139 .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1140 .unwrap_or(0);
1141 let id = format!("lesson-{}", n + 1);
1142 let record = serde_json::json!({
1143 "id": id,
1144 "text": text,
1145 "created_at": scc_core::now_rfc3339(),
1146 });
1147 let mut f = std::fs::OpenOptions::new()
1148 .create(true)
1149 .append(true)
1150 .open(&path)
1151 .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1152 writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1153 println!(
1154 "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1155 path.display()
1156 );
1157 Ok(())
1158}
1159
1160pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1164 let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1165 if lessons.is_empty() {
1166 println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1167 return Ok(());
1168 }
1169 for (content, tags) in lessons {
1170 let tag_str = if tags.is_empty() {
1171 String::new()
1172 } else {
1173 format!(" [{}]", tags.join(", "))
1174 };
1175 println!("- {content}{tag_str}");
1176 }
1177 Ok(())
1178}
1179
1180pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1184 let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1185 if active.is_empty() {
1186 println!("no active beads tasks (checked .beads/issues.jsonl)");
1187 return Ok(());
1188 }
1189 println!("active beads tasks:");
1190 for t in active {
1191 println!("- {t}");
1192 }
1193 Ok(())
1194}
1195
1196pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1198 let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1199 println!(
1202 "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1203 report.symbols, report.calls, report.imports, report.errors
1204 );
1205 Ok(())
1206}
1207
1208pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1210 let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1211 if json {
1212 println!("{}", serde_json::to_string_pretty(&edges)?);
1213 return Ok(());
1214 }
1215 if edges.is_empty() {
1216 println!("no runtime observations ingested");
1217 }
1218 let total: u64 = edges.iter().map(|e| e.count).sum();
1219 let errs: u64 = edges.iter().map(|e| e.errors).sum();
1220 println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1221 for e in &edges {
1222 println!(
1223 "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1224 e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1225 );
1226 }
1227 Ok(())
1228}
1229
1230pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1232 let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1233 if json {
1234 println!("{}", serde_json::to_string_pretty(&rec)?);
1235 return Ok(());
1236 }
1237 println!("static-vs-observed reconciliation");
1238 println!(" matched: {}", rec.matched.len());
1239 println!(" observed not static: {}", rec.observed_not_static.len());
1240 println!(" static not observed: {}", rec.static_not_observed.len());
1241 for e in &rec.matched {
1242 println!(" [matched] {e}");
1243 }
1244 for e in &rec.observed_not_static {
1245 println!(" [runtime-only] {e}");
1246 }
1247 for e in &rec.static_not_observed {
1248 println!(" [static-only] {e}");
1249 }
1250 Ok(())
1251}
1252
1253pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1258 let store = open_store(root)?;
1259 if store.snapshot_status()?.is_none() {
1260 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1261 }
1262 let v = scc_engine::invoke(root, "export.diagram", serde_json::json!({"format": format}))
1265 .map_err(engine_err)?;
1266 let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1267 if let Some(path) = out {
1268 std::fs::write(path, text)?;
1269 println!(
1270 "diagram: {} nodes, {} edges, {} flows -> {path}",
1271 v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1272 v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1273 v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1274 );
1275 } else {
1276 print!("{text}");
1277 }
1278 Ok(())
1279}
1280
1281pub fn cmd_snap(
1287 root: &Path,
1288 out: Option<&str>,
1289 max_chars: usize,
1290 png: Option<&str>,
1291 color: bool,
1292) -> crate::Result<()> {
1293 let store = open_store(root)?;
1294 if store.snapshot_status()?.is_none() {
1295 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1296 }
1297 let config = load_config(root)?;
1298 let map = crate::viewer::map_text(&store, max_chars)?;
1299 let rows = map.lines().count();
1300 let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1301 let render = png.is_some() || config.context.snap_enabled;
1302 if let Some(path) = out {
1303 std::fs::write(path, &map)?;
1304 } else {
1305 print!("{map}");
1306 if !map.ends_with('\n') {
1307 println!();
1308 }
1309 }
1310 if color {
1311 eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1312 }
1313 println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1314 if render {
1315 let dest = png.unwrap_or("scc-snap.png");
1316 render_snap_png(&map, dest)?;
1317 println!("snap png -> {dest}");
1318 } else {
1319 println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1320 println!("{}", crate::viewer::snap_recipe());
1321 }
1322 Ok(())
1323}
1324
1325fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1330 let probe = std::process::Command::new("python3")
1333 .arg("-c")
1334 .arg("import PIL")
1335 .output()
1336 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1337 if !probe.status.success() {
1338 return Err(crate::CliError::Other(
1339 "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1340 ));
1341 }
1342 let dir = tempfile::TempDir::new()?;
1343 let map_path = dir.path().join("map.txt");
1344 let recipe_path = dir.path().join("snap.py");
1345 std::fs::write(&map_path, map)?;
1346 std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1347 let out = std::process::Command::new("python3")
1348 .arg(&recipe_path)
1349 .arg(&map_path)
1350 .arg(dest)
1351 .output()
1352 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1353 if !out.status.success() {
1354 return Err(crate::CliError::Other(format!(
1355 "snap: Pillow render failed: {}",
1356 String::from_utf8_lossy(&out.stderr).trim()
1357 )));
1358 }
1359 Ok(())
1360}
1361
1362pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1367 let store = open_store(root)?;
1368 if store.snapshot_status()?.is_none() {
1369 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1370 }
1371 let addr = match port {
1372 Some(p) => format!("127.0.0.1:{p}"),
1373 None => "127.0.0.1:0".to_string(),
1374 };
1375 let server = tiny_http::Server::http(&addr)
1376 .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1377 let bound = server.server_addr();
1378 let url = format!("http://{bound}/");
1379 println!("scc view: {url} (root {})", root.display());
1380 if !no_open {
1381 #[cfg(target_os = "macos")]
1382 let _ = std::process::Command::new("open").arg(&url).spawn();
1383 #[cfg(target_os = "linux")]
1384 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1385 }
1386 for request in server.incoming_requests() {
1387 let url_path = request.url().to_string();
1388 let method = request.method().clone();
1389 if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1390 let store = match open_store(root) {
1391 Ok(s) => s,
1392 Err(e) => {
1393 let _ = request.respond(
1394 tiny_http::Response::from_string(format!("store error: {e}"))
1395 .with_status_code(500),
1396 );
1397 continue;
1398 }
1399 };
1400 let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1401 let response = tiny_http::Response::from_string(body)
1402 .with_status_code(status)
1403 .with_header(
1404 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1405 .unwrap(),
1406 );
1407 let _ = request.respond(response);
1408 continue;
1409 }
1410 let _ = request.respond(
1413 tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1414 .with_status_code(404),
1415 );
1416 }
1417 Ok(())
1418}
1419
1420pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1423 scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1424}
1425
1426pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1429 if let Some(id) = describe {
1430 match scc_engine::ops::describe(id) {
1431 Some(d) => println!("{}", serde_json::to_string_pretty(&d)?),
1432 None => println!("unknown operation '{id}' (see `scc operations`)"),
1433 }
1434 return Ok(());
1435 }
1436 println!("Operation Mutation Stream Description");
1437 println!("----------------------------------------------------------------");
1438 for d in scc_engine::ops::OPERATIONS {
1439 println!("{:<26} {:<9} {:<7} {}", d.id, format!("{:?}", d.mutation), d.streaming, d.description);
1440 }
1441 Ok(())
1442}
1443
1444pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1447 let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1448 println!("{}", serde_json::to_string_pretty(&out)?);
1449 Ok(())
1450}
1451
1452pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1455 let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1456 println!("{}", serde_json::to_string_pretty(&out)?);
1457 Ok(())
1458}
1459
1460pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1463 let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1464 println!("{}", serde_json::to_string_pretty(&out)?);
1465 Ok(())
1466}
1467
1468pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1471 let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1472 println!("{}", serde_json::to_string_pretty(&out)?);
1473 Ok(())
1474}
1475
1476pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1479 let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1480 println!("{}", serde_json::to_string_pretty(&out)?);
1481 if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1482 return Err(crate::CliError::Other(format!(
1483 "plugin lock drift: {}",
1484 out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1485 )));
1486 }
1487 Ok(())
1488}
1489
1490pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1493 let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1494 let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1495 println!("{}", serde_json::to_string_pretty(&out)?);
1496 Ok(())
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501
1502 #[test]
1503 fn ensure_scc_ignored_keeps_intent_committable() {
1505 let dir = tempfile::TempDir::new().unwrap();
1506 let root = dir.path();
1507 crate::ensure_scc_ignored(root);
1508 let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1509 assert!(gi.contains(".scc/*"), "{gi}");
1510 assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1511 crate::ensure_scc_ignored(root);
1512 let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1513 assert_eq!(gi, gi2, "idempotent");
1514 std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
1515 crate::ensure_scc_ignored(root);
1516 let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1517 assert!(gi3.contains("target/"), "{gi3}");
1518 assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
1519 assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
1520 }
1521
1522 #[test]
1523 fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
1525 let home = tempfile::TempDir::new().unwrap();
1526 let bindir = home.path().join("bin");
1527 std::fs::create_dir_all(&bindir).unwrap();
1528 std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
1529 std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
1530 let root = tempfile::TempDir::new().unwrap();
1531 std::fs::create_dir_all(root.path().join(".omp")).unwrap();
1532 let found = detect_harnesses(
1533 home.path(),
1534 &[bindir],
1535 root.path(),
1536 );
1537 assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
1538 assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
1539 assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
1540 assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
1541 assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
1542 }
1543
1544 #[test]
1545 fn detect_harnesses_empty_when_nothing_present() {
1547 let home = tempfile::TempDir::new().unwrap();
1548 let root = tempfile::TempDir::new().unwrap();
1549 let found = detect_harnesses(home.path(), &[], root.path());
1550 assert!(found.is_empty(), "{found:?}");
1551 }
1552
1553 use super::*;
1554
1555
1556 #[test]
1557fn lessons_add_appends_jsonl_lines() {
1559 let dir = tempfile::TempDir::new().unwrap();
1560 let root = dir.path().join("repo");
1561 std::fs::create_dir_all(&root).unwrap();
1562 cmd_lessons_add(&root, "first lesson").unwrap();
1563 cmd_lessons_add(&root, "second lesson").unwrap();
1564 let path = root.join(".scc/lessons.jsonl");
1565 let text = std::fs::read_to_string(&path).unwrap();
1566 let lines: Vec<&str> = text.lines().collect();
1567 assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
1568 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1569 assert_eq!(first["id"], "lesson-1");
1570 assert_eq!(first["text"], "first lesson");
1571 assert!(first["created_at"].is_string());
1572 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1573 assert_eq!(second["id"], "lesson-2");
1574 assert_eq!(second["text"], "second lesson");
1575 let lesson: scc_indexer::adapters::hindsight::Lesson =
1577 serde_json::from_str(lines[0]).unwrap();
1578 assert_eq!(lesson.body(), "first lesson");
1579 }
1580
1581 #[test]
1582fn adapters_lists_configured_scope() {
1584 let dir = tempfile::TempDir::new().unwrap();
1585 let root = dir.path().join("repo");
1586 std::fs::create_dir_all(root.join(".scc")).unwrap();
1587 std::fs::write(
1588 root.join(".scc/config.yaml"),
1589 "schema: 1\nintegrations:\n beads: true\n context7_command: \"npx -y @upstash/context7-mcp\"\n",
1590 )
1591 .unwrap();
1592 let listing = configured_adapters(&root).unwrap();
1593 assert!(
1594 listing.contains(&("beads".to_string(), "filesystem")),
1595 "{listing:?}"
1596 );
1597 assert!(
1598 listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
1599 "{listing:?}"
1600 );
1601 assert!(
1602 !listing.iter().any(|(n, _)| n == "hindsight"),
1603 "disabled integrations must not be listed: {listing:?}"
1604 );
1605 assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
1607 assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
1608 for (n, _) in &listing {
1611 assert!(
1612 scc_indexer::adapters::resolve_integration(n).is_some(),
1613 "listed adapter {n} must resolve in the registry: {listing:?}"
1614 );
1615 }
1616 assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
1617 assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
1619 assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
1620 cmd_adapters(&root, false).unwrap();
1622 assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
1624 assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
1625 }
1626}
1627