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_scan(root: &Path, path: Option<&str>, json: bool) -> crate::Result<()> {
127 let config = load_config(root)?;
128 let exp = scc_indexer::scan::explain_scan(root, &config.index)
129 .map_err(|e| crate::CliError::Index(scc_indexer::IndexError::Scan(e)))?;
130 if json {
131 let mut out = String::from("{\"indexed\":[");
132 for (i, f) in exp.indexed.iter().enumerate() {
133 if i > 0 {
134 out.push(',');
135 }
136 out.push_str(&format!(
137 "{{\"path\":{},\"language\":{},\"kind\":{},\"bytes\":{}}}",
138 serde_json::to_string(&f.path).unwrap_or_default(),
139 serde_json::to_string(f.language.as_str()).unwrap_or_default(),
140 serde_json::to_string(f.kind.as_str()).unwrap_or_default(),
141 f.size
142 ));
143 }
144 out.push_str("],\"skipped\":[");
145 for (i, s) in exp.skipped.iter().enumerate() {
146 if i > 0 {
147 out.push(',');
148 }
149 out.push_str(&format!(
150 "{{\"path\":{},\"reason\":{},\"rule\":{}}}",
151 serde_json::to_string(&s.path).unwrap_or_default(),
152 serde_json::to_string(s.reason).unwrap_or_default(),
153 serde_json::to_string(&s.rule).unwrap_or_default()
154 ));
155 }
156 out.push_str("]}");
157 println!("{out}");
158 return Ok(());
159 }
160 if let Some(q) = path {
162 let q = q.trim_start_matches("./");
163 if let Some(f) = exp.indexed.iter().find(|f| f.path == q) {
164 println!("{}: indexed ({} {})", f.path, f.language.as_str(), f.kind.as_str());
165 return Ok(());
166 }
167 if let Some(s) = exp.skipped.iter().find(|s| s.path == q) {
168 match &s.rule {
169 Some(rule) => println!("{q}: {} ({rule})", s.reason),
170 None => println!("{q}: {}", s.reason),
171 }
172 std::process::exit(1);
173 }
174 println!("{q}: not found (no such file in working tree)");
175 std::process::exit(2);
176 }
177 let s = &exp.stats;
178 println!(
179 "scan: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={} (live walk, not index-time counts)",
180 s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
181 );
182 if !exp.skipped.is_empty() {
183 use std::collections::BTreeMap;
184 let mut by_rule: BTreeMap<String, usize> = BTreeMap::new();
185 for sk in &exp.skipped {
186 let key = format!("{}: {}", sk.reason, sk.rule.as_deref().unwrap_or("?"));
187 *by_rule.entry(key).or_default() += 1;
188 }
189 println!("\nskips by rule:");
190 for (rule, n) in &by_rule {
191 println!(" {n:>6} {rule}");
192 }
193 }
194 {
196 use std::collections::BTreeMap;
197 let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
198 for f in &exp.indexed {
199 let top = f.path.split('/').next().unwrap_or(".").to_string();
200 let key = if f.path.contains('/') { top } else { "(root)".to_string() };
201 *by_dir.entry(key).or_default() += 1;
202 }
203 println!("\nindexed by top dir:");
204 let mut rows: Vec<_> = by_dir.into_iter().collect();
205 rows.sort_by_key(|r| std::cmp::Reverse(r.1));
206 for (dir, n) in rows.iter().take(15) {
207 println!(" {n:>6} {dir}/");
208 }
209 }
210 println!("\nrerun with a path (`scc scan <path>`) for one verdict, or `--json` for the full lists.");
211 Ok(())
212}
213
214pub fn cmd_languages() -> crate::Result<()> {
218 print!("{}", scc_core::support_matrix_markdown());
219 Ok(())
220}
221
222pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
224 let store = open_store(root)?;
225 let config = load_config(root)?;
226 let stale = crate::stale_paths(&store)?;
227 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
228 let pack = engine.context().overview().map_err(engine_err)?;
229 if json {
230 println!("{}", serde_json::to_string_pretty(&pack)?);
231 } else {
232 print!("{}", pack.content);
233 }
234 Ok(())
235}
236pub fn cmd_atlas(
241 root: &Path,
242 budget: Option<usize>,
243 json: bool,
244 full: bool,
245 unbounded: bool,
246) -> crate::Result<()> {
247 let store = open_store(root)?;
248 let config = load_config(root)?;
249 let stale = crate::stale_paths(&store)?;
250 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
251 let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
252 if json {
253 println!("{}", serde_json::to_string(&pack)?);
254 } else {
255 print!("{}", pack.content);
256 }
257 Ok(())
258}
259
260pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>) -> crate::Result<()> {
267 let store = open_store(root)?;
268 let config = load_config(root)?;
269 let stale = crate::stale_paths(&store)?;
270 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
271 let (_startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
272 print!("{text}");
273 Ok(())
274}
275
276pub fn cmd_surface(
285 root: &Path,
286 task: Option<&str>,
287 budget: Option<usize>,
288 explain: bool,
289) -> crate::Result<()> {
290 let store = open_store(root)?;
291 let config = load_config(root)?;
292 let stale = crate::stale_paths(&store)?;
293 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
294 let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
295 let scorer = match task {
301 Some(goal) => {
302 let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
303 scorer
304 }
305 None => None,
306 };
307 let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
308 scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
309 let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
310 let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
311 print!("{text}");
312 Ok(())
313}
314
315pub fn cmd_important(
323 root: &Path,
324 limit: usize,
325 component: Option<&str>,
326 task: Option<&str>,
327 json: bool,
328) -> crate::Result<()> {
329 let store = open_store(root)?;
330 let config = load_config(root)?;
331 let stale = crate::stale_paths(&store)?;
332 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
333 let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
334 if json {
335 println!("{}", serde_json::to_string_pretty(&entries)?);
336 return Ok(());
337 }
338 print!("{}", scc_context::surface::render_important(&entries, tasked));
339 Ok(())
340}
341
342pub fn cmd_context_structural(
350 root: &Path,
351 files: &[String],
352 task: Option<&str>,
353 budget: Option<usize>,
354) -> crate::Result<String> {
355 let out = scc_engine::invoke(
357 root,
358 "context.structural",
359 serde_json::json!({"files": files, "task": task, "budget": budget}),
360 )
361 .map_err(engine_err)?;
362 Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
363}
364
365#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
368pub struct TaskContextArtifact {
370 pub pack: scc_context::ContextPack,
371 pub delta: String,
374 #[serde(default)]
377 pub delta_ids: Vec<String>,
378 #[serde(default)]
383 pub token_count: usize,
384}
385
386pub fn build_task_context(
393 root: &Path,
394 goal: &str,
395 files: &[String],
396 symbols: &[String],
397 budget: Option<usize>,
398 hook: bool,
399) -> crate::Result<TaskContextArtifact> {
400 build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
401}
402
403fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
405 TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
406}
407
408fn build_task_context_engine(
410 root: &Path,
411 goal: &str,
412 files: &[String],
413 symbols: &[String],
414 budget: Option<usize>,
415 hook: bool,
416) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
417 let out = scc_engine::invoke(
420 root,
421 "context.task",
422 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
423 )?;
424 serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
425}
426
427pub fn build_enriched_task_pack(
436 root: &Path,
437 goal: &str,
438 files: &[String],
439 symbols: &[String],
440 budget: Option<usize>,
441 hook: bool,
442) -> crate::Result<scc_context::ContextPack> {
443 let out = scc_engine::invoke(
445 root,
446 "context.task_pack",
447 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
448 )
449 .map_err(engine_err)?;
450 serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
452}
453
454pub fn cmd_context_task(
461 root: &Path,
462 goal: &str,
463 files: &[String],
464 symbols: &[String],
465 budget: Option<usize>,
466 json: bool,
467 hook: bool,
468) -> crate::Result<()> {
469 if hook {
470 let config = load_config(root)?;
473 if !config.context.inject_task_focus {
474 return Ok(());
475 }
476 }
477 let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
478 if json {
479 println!("{}", serde_json::to_string_pretty(&artifact)?);
480 } else {
481 print!("{}", artifact.pack.content);
482 print!("\n{}", artifact.delta);
483 }
484 Ok(())
485}
486
487
488pub fn build_task_pack(
493 root: &Path,
494 goal: &str,
495 files: &[String],
496 symbols: &[String],
497 budget: Option<usize>,
498) -> crate::Result<scc_context::ContextPack> {
499 build_enriched_task_pack(root, goal, files, symbols, budget, false)
500}
501pub fn cmd_context_task_json(
505 root: &Path,
506 goal: &str,
507 files: &[String],
508 symbols: &[String],
509 budget: Option<usize>,
510) -> crate::Result<String> {
511 Ok(serde_json::to_string_pretty(&build_task_context(
512 root, goal, files, symbols, budget, false,
513 )?)?)
514}
515
516pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
520 print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
521 Ok(())
522}
523
524pub fn cmd_context_subagent(
529 root: &Path,
530 goal: &str,
531 files: &[String],
532 symbols: &[String],
533 budget: Option<usize>,
534 json: bool,
535) -> crate::Result<()> {
536 let store = open_store(root)?;
537 let config = load_config(root)?;
538 let stale = crate::stale_paths(&store)?;
539 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
540 let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
541 if json {
542 println!("{}", serde_json::to_string_pretty(&pack)?);
543 } else {
544 print!("{}", pack.content);
545 }
546 Ok(())
547}
548
549pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
551 let store = open_store(root)?;
552 let config = load_config(root)?;
553 let stale = crate::stale_paths(&store)?;
554 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
555 let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
556 if json {
557 println!("{}", serde_json::to_string_pretty(&pack)?);
558 } else {
559 print!("{}", pack.content);
560 }
561 Ok(())
562}
563
564pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
566 let store = open_store(root)?;
567 let config = load_config(root)?;
568 let stale = crate::stale_paths(&store)?;
569 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
570 let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
571 if json {
572 println!("{}", serde_json::to_string_pretty(&pack)?);
573 } else {
574 print!("{}", pack.content);
575 }
576 Ok(())
577}
578
579pub fn cmd_impact(
581 root: &Path,
582 files: &[String],
583 symbols: &[String],
584 diff: Option<&str>,
585 json: bool,
586 unbounded: bool,
587) -> crate::Result<()> {
588 let store = open_store(root)?;
589 let config = load_config(root)?;
590 let stale = crate::stale_paths(&store)?;
591 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
592 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)?;
593 if json {
594 println!("{}", serde_json::to_string_pretty(&pack)?);
595 } else {
596 print!("{}", pack.content);
597 }
598 Ok(())
599}
600
601pub fn cmd_verify(
603 root: &Path,
604 warnings_only: bool,
605 json: bool,
606 unbounded: bool,
607) -> crate::Result<()> {
608 let store = open_store(root)?;
609 let config = load_config(root)?;
610 let stale = crate::stale_paths(&store)?;
611 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
612 let pack = engine.context().verify(unbounded).map_err(engine_err)?;
613 if warnings_only {
614 for w in &pack.warnings {
615 println!("⚠ {w}");
616 }
617 return Ok(());
618 }
619 if json {
620 println!("{}", serde_json::to_string_pretty(&pack)?);
621 return Ok(());
622 }
623 print!("{}", pack.content);
624 Ok(())
625}
626
627pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
629 let store = open_store(root)?;
630 let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
631 if json {
632 println!("{}", serde_json::to_string_pretty(&findings)?);
633 } else {
634 if findings.is_empty() {
635 println!("no drift findings");
636 }
637 for f in &findings {
638 println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
639 }
640 }
641 Ok(())
642}
643
644pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
646 let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
647 if json {
648 println!("{}", serde_json::to_string_pretty(&all)?);
649 return Ok(());
650 }
651 println!(
652 "system: {} members, {} stitches",
653 members.len(),
654 all.len()
655 );
656 for m in &members {
657 println!("member {} ({})", m.repo_id, m.root.display());
658 }
659 for s in &all {
660 println!(
661 "[{:?}/{:?}] {} ({} ends)",
662 s.kind,
663 s.match_kind,
664 s.key,
665 s.ends.len()
666 );
667 for e in &s.ends {
668 match e.role {
669 Some(role) => println!(" {} {} ({role:?})", e.repo_id, e.entity_id),
670 None => println!(" {} {}", e.repo_id, e.entity_id),
671 }
672 }
673 }
674 Ok(())
675}
676
677pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
679 let store = open_store(root)?;
680 let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
681 if json {
682 println!("{}", serde_json::to_string_pretty(&revs)?);
683 return Ok(());
684 }
685 if revs.is_empty() {
686 println!("no graph revisions recorded");
687 return Ok(());
688 }
689 for r in &revs {
690 println!(
691 "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
692 r.rev,
693 r.base_rev,
694 r.entity_count,
695 r.rel_count,
696 r.file_count,
697 &r.source_hash[..r.source_hash.len().min(12)],
698 r.extractor_version,
699 r.created_at,
700 );
701 }
702 Ok(())
703}
704
705pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
707 let store = open_store(root)?;
708 let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
709 if json {
710 println!("{}", serde_json::to_string_pretty(&d)?);
711 return Ok(());
712 }
713 println!("diff rev {from}..{to}:");
715 for (label, ids) in [
716 ("added entities", &d.added_entities),
717 ("removed entities", &d.removed_entities),
718 ("modified entities", &d.modified_entities),
719 ("added relationships", &d.added_relationships),
720 ("removed relationships", &d.removed_relationships),
721 ("modified relationships", &d.modified_relationships),
722 ] {
723 println!(" {label}: {}", ids.len());
724 for id in ids.iter().take(20) {
725 println!(" {id}");
726 }
727 if ids.len() > 20 {
728 println!(" … ({} more)", ids.len() - 20);
729 }
730 }
731 if !d.modified_kinds.is_empty() {
732 let kinds: Vec<String> = d
733 .modified_kinds
734 .iter()
735 .map(|(k, v)| format!("{k}:{v}"))
736 .collect();
737 println!(" modified kinds: {}", kinds.join(", "));
738 }
739 Ok(())
740}
741
742pub fn cmd_snapshot_save(
744 root: &Path,
745 task: &str,
746 budget: Option<usize>,
747 json: bool,
748) -> crate::Result<()> {
749 let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
750 if json {
751 println!("{}", serde_json::to_string_pretty(&snap)?);
752 } else {
753 println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
754 }
755 Ok(())
756}
757
758pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
760 let store = open_store(root)?;
761 match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
762 Some(s) => print!("{}", s.artifact),
763 None => println!("no snapshot {id}"),
764 }
765 Ok(())
766}
767
768pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
770 let store = open_store(root)?;
771 match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
772 Some(d) => {
773 if json {
774 println!("{}", serde_json::to_string_pretty(&d)?);
775 } else {
776 println!(
777 "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
778 d.snapshot_revision,
779 d.current_revision,
780 d.still_valid.len(),
781 d.invalidated.len(),
782 d.modified_entities.len(),
783 if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
784 );
785 for (label, ids) in [
786 ("invalidated", &d.invalidated),
787 ("modified entities", &d.modified_entities),
788 ("changed relationships", &d.changed_relationships),
789 ("changed contracts", &d.changed_contracts),
790 ("changed state", &d.changed_state),
791 ("changed flows", &d.changed_flows),
792 ] {
793 if ids.is_empty() {
794 continue;
795 }
796 println!(" {label} ({}):", ids.len());
797 for f in ids.iter().take(20) {
798 println!(" - {f}");
799 }
800 if ids.len() > 20 {
801 println!(" … ({} more)", ids.len() - 20);
802 }
803 }
804 }
805 }
806 None => println!("no snapshot {id}"),
807 }
808 Ok(())
809}
810
811pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
813 let store = open_store(root)?;
814 match format {
815 "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
816 "system-ir.jsonl" => {
817 let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
818 for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
819 println!("{line}");
820 }
821 }
822 "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)?)?),
823 "flow-graphs.json" => {
824 println!(
825 "{}",
826 serde_json::to_string_pretty(&store.flow_graphs()?)?
827 )
828 }
829 "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
830 other => {
831 return Err(crate::CliError::Other(format!(
832 "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
833 )))
834 }
835 }
836 Ok(())
837}
838
839pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
842 let store = open_store(root)?;
843 let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
844 println!("— entities —");
845 for e in &hit.entities {
846 println!("{} [{}]", e.name, e.kind);
847 }
848 println!("— symbols —");
849 for (name, sig, kind, file, line) in &hit.symbols {
850 if *line > 0 {
851 println!("{name} ({kind}) {file}:{line} {sig}");
852 } else {
853 println!("{name} ({kind}) {file} {sig}");
854 }
855 }
856 Ok(())
857}
858
859pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
861 let store = open_store(root)?;
862 for c in scc_engine::graph::components(&store).map_err(engine_err)? {
863 println!("{}", c.name);
864 }
865 Ok(())
866}
867
868pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
870 let store = open_store(root)?;
871 for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
872 println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
873 }
874 Ok(())
875}
876
877pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
882 let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
883 if pairs.is_empty() {
884 println!("no co-change pairs with >= {min_commits} shared commits");
885 } else {
886 println!("co-change pairs (>= {min_commits} shared commits):");
887 for p in &pairs {
888 println!(" {} <-> {} ×{}", p.a, p.b, p.commits);
889 }
890 }
891 if n > 0 {
892 println!("enriched {n} components with co-change signal");
893 }
894 Ok(())
895}
896
897pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
900 let store = open_store(root)?;
901 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
902 for v in &violations {
903 println!("{}", v.message);
904 }
905 Ok(violations.is_empty())
906}
907
908pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
912 let store = open_store(root)?;
913 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
914 for v in &violations {
915 println!("{}", v.message);
916 }
917 let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
918 for l in &lines {
919 if l.starts_with("[ci:") || *l == "ci check passed" {
921 println!("{l}");
922 }
923 }
924 Ok(ok)
925}
926
927pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
929 let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
930 if json {
931 println!("{}", serde_json::to_string(&data)?);
932 } else {
933 println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
934 }
935 Ok(())
936}
937
938pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
940 if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
941 print!("{content}");
942 } else if !inject {
943 println!("no checkpoint found");
944 }
945 Ok(())
946}
947
948pub fn cmd_watch(root: &Path) -> crate::Result<()> {
950 crate::httpd::watch_loop(root)
951}
952
953pub fn cmd_serve(root: &Path) -> crate::Result<()> {
955 crate::httpd::serve(root)
956}
957
958pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
960 crate::mcp::serve_stdio(root)
961}
962
963pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
965 crate::plugin::install(root)
966}
967
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
972pub enum SetupHarness {
974 Claude,
975 Codex,
976 Opencode,
977 Omp,
978 Pi,
979}
980
981pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
987 let on_path = |bin: &str| {
988 path_dirs
989 .iter()
990 .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
991 };
992 let mut out = Vec::new();
993 if on_path("claude") || home.join(".claude").is_dir() {
994 out.push(SetupHarness::Claude);
995 }
996 if on_path("codex") || home.join(".codex").is_dir() {
997 out.push(SetupHarness::Codex);
998 }
999 if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
1000 out.push(SetupHarness::Opencode);
1001 }
1002 if on_path("omp") || root.join(".omp").is_dir() {
1003 out.push(SetupHarness::Omp);
1004 }
1005 if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
1006 out.push(SetupHarness::Pi);
1007 }
1008 out
1009}
1010
1011fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
1013 match h {
1014 SetupHarness::Claude => cmd_setup_claude(root),
1015 SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
1016 SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
1017 SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
1018 SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
1019 }
1020}
1021
1022pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
1028 let targets: Vec<SetupHarness> = if all {
1029 vec![
1030 SetupHarness::Claude,
1031 SetupHarness::Codex,
1032 SetupHarness::Opencode,
1033 SetupHarness::Omp,
1034 SetupHarness::Pi,
1035 ]
1036 } else {
1037 let home = std::env::var_os("HOME")
1038 .map(PathBuf::from)
1039 .unwrap_or_else(|| PathBuf::from("/"));
1040 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1041 .map(|p| std::env::split_paths(&p).collect())
1042 .unwrap_or_default();
1043 detect_harnesses(&home, &path_dirs, root)
1044 };
1045 if targets.is_empty() {
1046 println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
1047 println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
1048 println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
1049 return Ok(());
1050 }
1051 for h in &targets {
1052 println!("=== {:?} ===", h);
1053 install_harness(root, *h)?;
1054 println!();
1055 }
1056 println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
1057 if targets.contains(&SetupHarness::Codex) {
1058 println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
1059 }
1060 Ok(())
1061}
1062
1063pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
1065 scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
1066 println!("accepted");
1067 Ok(())
1068}
1069
1070fn adapter_scope(name: &str) -> &'static str {
1077 match name {
1078 "context7" => "network+subprocess(npx)",
1079 "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
1080 _ => "filesystem",
1081 }
1082}
1083
1084fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1091 use scc_indexer::adapters::IntegrationCategory;
1092 let config = load_config(root)?;
1093 let enabled = |key: Option<&str>| -> bool {
1094 match key {
1095 None => true,
1096 Some("serena") => config.integrations.serena,
1097 Some("beads") => config.integrations.beads,
1098 Some("hindsight") => config.integrations.hindsight,
1099 Some("gitnexus") => config.integrations.gitnexus,
1100 Some("context7_command") => !config.integrations.context7_command.is_empty(),
1101 Some(_) => false,
1104 }
1105 };
1106 Ok(scc_indexer::adapters::integration_registry()
1107 .into_iter()
1108 .filter(|d| {
1109 d.category != IntegrationCategory::CompatibilityOnly
1110 && d.category != IntegrationCategory::InternalPass
1111 && d.category != IntegrationCategory::AgentIntegration
1112 && enabled(d.config_key)
1113 })
1114 .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1115 .collect())
1116}
1117
1118pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1123 let manifests = scc_indexer::adapters::adapter_manifests();
1124 if json {
1125 println!("{}", serde_json::to_string_pretty(&manifests)?);
1126 return Ok(());
1127 }
1128 for (name, scope) in configured_adapters(root)? {
1129 println!("adapter: {name} scope: {scope}");
1130 }
1131 Ok(())
1132}
1133
1134pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1149 let store = open_store(root)?;
1150 let config = load_config(root)?;
1151 let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1152 let rows = rep.integrations;
1153 let agent_rows = rep.agents;
1154 let mut warnings = rep.warnings;
1155 let (scc_version, rev, stale_len, dangling, stats) =
1156 (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1157 if json {
1158 println!(
1159 "{}",
1160 serde_json::to_string_pretty(&serde_json::json!({
1161 "scc": scc_version,
1162 "revision": rev,
1163 "stale_files": stale_len,
1164 "dangling_edges": dangling,
1165 "stats": stats,
1166 "integrations": rows,
1167 "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1168 "warnings": warnings,
1169 }))?
1170 );
1171 } else {
1172 println!("SCC Doctor");
1173 println!("==========");
1174 println!();
1175 println!("CORE");
1176 println!(" scc {scc_version}");
1177 println!(" revision {rev}");
1178 println!(" stale {} file(s)", stale_len);
1179 println!(" dangling {dangling} edge(s) (see `scc check-invariants`)");
1180 println!(" entities {}", stats.get("entities").copied().unwrap_or(0));
1181 println!();
1182 println!("INTEGRATIONS");
1183 for r in &rows {
1184 let mark = if r.contributing {
1185 "✓"
1186 } else if r.configured {
1187 "!"
1188 } else {
1189 "-"
1190 };
1191 let reach = match r.reachable {
1192 Some(true) => "reachable; ",
1193 Some(false) => "UNREACHABLE; ",
1194 None => "",
1195 };
1196 println!(" {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1197 }
1198 println!();
1199 println!("AGENTS");
1200 for a in &agent_rows {
1201 println!(" {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1202 }
1203 println!();
1204 if warnings == 0 {
1205 println!("SUMMARY PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1206 } else {
1207 println!("SUMMARY {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1208 }
1209 }
1210 if dangling > 0 {
1211 warnings += 1;
1212 }
1213 Ok(!(strict && warnings > 0))
1217}
1218
1219pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1224 let dir = scc_dir(root);
1225 std::fs::create_dir_all(&dir)?;
1226 let path = dir.join("lessons.jsonl");
1227 let n = std::fs::read_to_string(&path)
1228 .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1229 .unwrap_or(0);
1230 let id = format!("lesson-{}", n + 1);
1231 let record = serde_json::json!({
1232 "id": id,
1233 "text": text,
1234 "created_at": scc_core::now_rfc3339(),
1235 });
1236 let mut f = std::fs::OpenOptions::new()
1237 .create(true)
1238 .append(true)
1239 .open(&path)
1240 .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1241 writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1242 println!(
1243 "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1244 path.display()
1245 );
1246 Ok(())
1247}
1248
1249pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1253 let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1254 if lessons.is_empty() {
1255 println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1256 return Ok(());
1257 }
1258 for (content, tags) in lessons {
1259 let tag_str = if tags.is_empty() {
1260 String::new()
1261 } else {
1262 format!(" [{}]", tags.join(", "))
1263 };
1264 println!("- {content}{tag_str}");
1265 }
1266 Ok(())
1267}
1268
1269pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1273 let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1274 if active.is_empty() {
1275 println!("no active beads tasks (checked .beads/issues.jsonl)");
1276 return Ok(());
1277 }
1278 println!("active beads tasks:");
1279 for t in active {
1280 println!("- {t}");
1281 }
1282 Ok(())
1283}
1284
1285pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1287 let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1288 println!(
1291 "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1292 report.symbols, report.calls, report.imports, report.errors
1293 );
1294 Ok(())
1295}
1296
1297pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1299 let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1300 if json {
1301 println!("{}", serde_json::to_string_pretty(&edges)?);
1302 return Ok(());
1303 }
1304 if edges.is_empty() {
1305 println!("no runtime observations ingested");
1306 }
1307 let total: u64 = edges.iter().map(|e| e.count).sum();
1308 let errs: u64 = edges.iter().map(|e| e.errors).sum();
1309 println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1310 for e in &edges {
1311 println!(
1312 "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1313 e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1314 );
1315 }
1316 Ok(())
1317}
1318
1319pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1321 let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1322 if json {
1323 println!("{}", serde_json::to_string_pretty(&rec)?);
1324 return Ok(());
1325 }
1326 println!("static-vs-observed reconciliation");
1327 println!(" matched: {}", rec.matched.len());
1328 println!(" observed not static: {}", rec.observed_not_static.len());
1329 println!(" static not observed: {}", rec.static_not_observed.len());
1330 for e in &rec.matched {
1331 println!(" [matched] {e}");
1332 }
1333 for e in &rec.observed_not_static {
1334 println!(" [runtime-only] {e}");
1335 }
1336 for e in &rec.static_not_observed {
1337 println!(" [static-only] {e}");
1338 }
1339 Ok(())
1340}
1341
1342pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1347 let store = open_store(root)?;
1348 if store.snapshot_status()?.is_none() {
1349 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1350 }
1351 let v = scc_engine::exports::diagram(&store, format).map_err(engine_err)?;
1356 let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1357 if let Some(path) = out {
1358 std::fs::write(path, text)?;
1359 println!(
1360 "diagram: {} nodes, {} edges, {} flows -> {path}",
1361 v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1362 v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1363 v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1364 );
1365 } else {
1366 print!("{text}");
1367 }
1368 Ok(())
1369}
1370
1371pub fn cmd_snap(
1377 root: &Path,
1378 out: Option<&str>,
1379 max_chars: usize,
1380 png: Option<&str>,
1381 color: bool,
1382) -> crate::Result<()> {
1383 let store = open_store(root)?;
1384 if store.snapshot_status()?.is_none() {
1385 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1386 }
1387 let config = load_config(root)?;
1388 let map = crate::viewer::map_text(&store, max_chars)?;
1389 let rows = map.lines().count();
1390 let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1391 let render = png.is_some() || config.context.snap_enabled;
1392 if let Some(path) = out {
1393 std::fs::write(path, &map)?;
1394 } else {
1395 print!("{map}");
1396 if !map.ends_with('\n') {
1397 println!();
1398 }
1399 }
1400 if color {
1401 eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1402 }
1403 println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1404 if render {
1405 let dest = png.unwrap_or("scc-snap.png");
1406 render_snap_png(&map, dest)?;
1407 println!("snap png -> {dest}");
1408 } else {
1409 println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1410 println!("{}", crate::viewer::snap_recipe());
1411 }
1412 Ok(())
1413}
1414
1415fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1420 let probe = std::process::Command::new("python3")
1423 .arg("-c")
1424 .arg("import PIL")
1425 .output()
1426 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1427 if !probe.status.success() {
1428 return Err(crate::CliError::Other(
1429 "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1430 ));
1431 }
1432 let dir = tempfile::TempDir::new()?;
1433 let map_path = dir.path().join("map.txt");
1434 let recipe_path = dir.path().join("snap.py");
1435 std::fs::write(&map_path, map)?;
1436 std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1437 let out = std::process::Command::new("python3")
1438 .arg(&recipe_path)
1439 .arg(&map_path)
1440 .arg(dest)
1441 .output()
1442 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1443 if !out.status.success() {
1444 return Err(crate::CliError::Other(format!(
1445 "snap: Pillow render failed: {}",
1446 String::from_utf8_lossy(&out.stderr).trim()
1447 )));
1448 }
1449 Ok(())
1450}
1451
1452pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1457 let store = open_store(root)?;
1458 if store.snapshot_status()?.is_none() {
1459 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1460 }
1461 let addr = match port {
1462 Some(p) => format!("127.0.0.1:{p}"),
1463 None => "127.0.0.1:0".to_string(),
1464 };
1465 let server = tiny_http::Server::http(&addr)
1466 .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1467 let bound = server.server_addr();
1468 let url = format!("http://{bound}/");
1469 println!("scc view: {url} (root {})", root.display());
1470 if !no_open {
1471 #[cfg(target_os = "macos")]
1472 let _ = std::process::Command::new("open").arg(&url).spawn();
1473 #[cfg(target_os = "linux")]
1474 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1475 }
1476 for request in server.incoming_requests() {
1477 let url_path = request.url().to_string();
1478 let method = request.method().clone();
1479 if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1480 let store = match open_store(root) {
1481 Ok(s) => s,
1482 Err(e) => {
1483 let _ = request.respond(
1484 tiny_http::Response::from_string(format!("store error: {e}"))
1485 .with_status_code(500),
1486 );
1487 continue;
1488 }
1489 };
1490 let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1491 let response = tiny_http::Response::from_string(body)
1492 .with_status_code(status)
1493 .with_header(
1494 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1495 .unwrap(),
1496 );
1497 let _ = request.respond(response);
1498 continue;
1499 }
1500 let _ = request.respond(
1503 tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1504 .with_status_code(404),
1505 );
1506 }
1507 Ok(())
1508}
1509
1510pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1513 scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1514}
1515
1516pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1519 if let Some(id) = describe {
1520 match scc_engine::ops::describe(id) {
1521 Some(d) => println!("{}", serde_json::to_string_pretty(&d)?),
1522 None => println!("unknown operation '{id}' (see `scc operations`)"),
1523 }
1524 return Ok(());
1525 }
1526 println!("Operation Mutation Stream Description");
1527 println!("----------------------------------------------------------------");
1528 for d in scc_engine::ops::OPERATIONS {
1529 println!("{:<26} {:<9} {:<7} {}", d.id, format!("{:?}", d.mutation), d.streaming, d.description);
1530 }
1531 Ok(())
1532}
1533
1534pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1537 let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1538 println!("{}", serde_json::to_string_pretty(&out)?);
1539 Ok(())
1540}
1541
1542pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1545 let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1546 println!("{}", serde_json::to_string_pretty(&out)?);
1547 Ok(())
1548}
1549
1550pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1553 let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1554 println!("{}", serde_json::to_string_pretty(&out)?);
1555 Ok(())
1556}
1557
1558pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1561 let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1562 println!("{}", serde_json::to_string_pretty(&out)?);
1563 Ok(())
1564}
1565
1566pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1569 let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1570 println!("{}", serde_json::to_string_pretty(&out)?);
1571 if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1572 return Err(crate::CliError::Other(format!(
1573 "plugin lock drift: {}",
1574 out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1575 )));
1576 }
1577 Ok(())
1578}
1579
1580pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1583 let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1584 let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1585 println!("{}", serde_json::to_string_pretty(&out)?);
1586 Ok(())
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591
1592 #[test]
1593 fn ensure_scc_ignored_keeps_intent_committable() {
1595 let dir = tempfile::TempDir::new().unwrap();
1596 let root = dir.path();
1597 crate::ensure_scc_ignored(root);
1598 let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1599 assert!(gi.contains(".scc/*"), "{gi}");
1600 assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1601 crate::ensure_scc_ignored(root);
1602 let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1603 assert_eq!(gi, gi2, "idempotent");
1604 std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
1605 crate::ensure_scc_ignored(root);
1606 let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1607 assert!(gi3.contains("target/"), "{gi3}");
1608 assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
1609 assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
1610 }
1611
1612 #[test]
1613 fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
1615 let home = tempfile::TempDir::new().unwrap();
1616 let bindir = home.path().join("bin");
1617 std::fs::create_dir_all(&bindir).unwrap();
1618 std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
1619 std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
1620 let root = tempfile::TempDir::new().unwrap();
1621 std::fs::create_dir_all(root.path().join(".omp")).unwrap();
1622 let found = detect_harnesses(
1623 home.path(),
1624 &[bindir],
1625 root.path(),
1626 );
1627 assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
1628 assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
1629 assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
1630 assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
1631 assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
1632 }
1633
1634 #[test]
1635 fn detect_harnesses_empty_when_nothing_present() {
1637 let home = tempfile::TempDir::new().unwrap();
1638 let root = tempfile::TempDir::new().unwrap();
1639 let found = detect_harnesses(home.path(), &[], root.path());
1640 assert!(found.is_empty(), "{found:?}");
1641 }
1642
1643 use super::*;
1644
1645
1646 #[test]
1647fn lessons_add_appends_jsonl_lines() {
1649 let dir = tempfile::TempDir::new().unwrap();
1650 let root = dir.path().join("repo");
1651 std::fs::create_dir_all(&root).unwrap();
1652 cmd_lessons_add(&root, "first lesson").unwrap();
1653 cmd_lessons_add(&root, "second lesson").unwrap();
1654 let path = root.join(".scc/lessons.jsonl");
1655 let text = std::fs::read_to_string(&path).unwrap();
1656 let lines: Vec<&str> = text.lines().collect();
1657 assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
1658 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1659 assert_eq!(first["id"], "lesson-1");
1660 assert_eq!(first["text"], "first lesson");
1661 assert!(first["created_at"].is_string());
1662 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1663 assert_eq!(second["id"], "lesson-2");
1664 assert_eq!(second["text"], "second lesson");
1665 let lesson: scc_indexer::adapters::hindsight::Lesson =
1667 serde_json::from_str(lines[0]).unwrap();
1668 assert_eq!(lesson.body(), "first lesson");
1669 }
1670
1671 #[test]
1672fn adapters_lists_configured_scope() {
1674 let dir = tempfile::TempDir::new().unwrap();
1675 let root = dir.path().join("repo");
1676 std::fs::create_dir_all(root.join(".scc")).unwrap();
1677 std::fs::write(
1678 root.join(".scc/config.yaml"),
1679 "schema: 1\nintegrations:\n beads: true\n context7_command: \"npx -y @upstash/context7-mcp\"\n",
1680 )
1681 .unwrap();
1682 let listing = configured_adapters(&root).unwrap();
1683 assert!(
1684 listing.contains(&("beads".to_string(), "filesystem")),
1685 "{listing:?}"
1686 );
1687 assert!(
1688 listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
1689 "{listing:?}"
1690 );
1691 assert!(
1692 !listing.iter().any(|(n, _)| n == "hindsight"),
1693 "disabled integrations must not be listed: {listing:?}"
1694 );
1695 assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
1697 assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
1698 for (n, _) in &listing {
1701 assert!(
1702 scc_indexer::adapters::resolve_integration(n).is_some(),
1703 "listed adapter {n} must resolve in the registry: {listing:?}"
1704 );
1705 }
1706 assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
1707 assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
1709 assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
1710 cmd_adapters(&root, false).unwrap();
1712 assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
1714 assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
1715 }
1716}
1717