1use crate::{config_path, load_config, open_store, scc_dir};
4
5fn engine_err(e: scc_engine::EngineError) -> crate::CliError {
7 let msg = e.to_string();
13 if msg.contains("database is locked") || msg.contains("database table is locked") {
14 return crate::CliError::Other(format!(
15 "{msg} (another scc process holds the index — retry, or stop `scc watch`/`scc serve` first)"
16 ));
17 }
18 crate::CliError::Other(msg)
19}
20use std::io::Write;
21use std::path::{Path, PathBuf};
22
23pub fn cmd_init(root: &Path) -> crate::Result<()> {
25 let dir = scc_dir(root);
26 std::fs::create_dir_all(&dir)?;
27 let cfg_path = config_path(root);
28 if !cfg_path.exists() {
29 std::fs::write(&cfg_path, scc_indexer::Config::default_yaml())?;
30 println!("created {}", cfg_path.display());
31 } else {
32 println!("config exists: {}", cfg_path.display());
33 }
34 let store = open_store(root)?;
36 crate::ensure_scc_ignored(root);
37 println!(
38 "initialized SCC workspace for repository '{}' at {}",
39 store.repo_name,
40 dir.display()
41 );
42 println!("next: scc index");
43 Ok(())
44}
45
46pub fn cmd_index(root: &Path, quiet: bool) -> crate::Result<()> {
48 let config = load_config(root)?;
49 let report = scc_engine::index::full(root, &config).map_err(engine_err)?;
50 if !quiet {
51 println!(
52 "indexed {} file(s) ({} changed, {} added, {} removed, {} failed) in {:.2}s",
53 report.indexed,
54 report.changed,
55 report.added,
56 report.removed,
57 report.failed,
58 report.duration_ms as f64 / 1000.0
59 );
60 println!("analysis_quality: {}", report.analysis_quality.compact_line());
61 let s = &report.scan_stats;
62 println!(
63 "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
64 s.discovered, s.indexed, s.ignored, s.unsupported, s.oversized, s.unreadable
65 );
66 }
67 Ok(())
68}
69
70pub fn cmd_index_paths(root: &Path, paths: &[String], quiet: bool) -> crate::Result<()> {
72 let config = load_config(root)?;
73 let report = scc_engine::index::refresh_paths(root, &config, paths).map_err(engine_err)?;
74 if !quiet && report.indexed > 0 {
75 println!("refreshed {} file(s)", report.indexed);
76 }
77 Ok(())
78}
79
80pub fn cmd_status(root: &Path) -> crate::Result<()> {
82 let store = open_store(root)?;
83 let s = scc_engine::status::status(&store).map_err(engine_err)?;
84 println!("Repository: {} ({})", s.repository, s.repository_id);
85 if let Some(url) = &s.remote {
86 println!("Remote: {url}");
87 }
88 if s.indexed {
89 println!("Revision: {}", s.revision);
90 if let Some(b) = &s.branch {
91 println!("Branch: {b}");
92 }
93 println!("Indexed at: {}", s.indexed_at.as_deref().unwrap_or(""));
94 let mut keys: Vec<&String> = s.stats.keys().collect();
95 keys.sort();
96 for k in keys {
97 println!("{k}: {}", s.stats[k]);
98 }
99 if s.freshness == "CURRENT" {
100 println!("freshness: CURRENT — model matches working tree");
101 } else {
102 println!(
103 "freshness: STALE — {} file(s) changed since index (run `scc index`)",
104 s.stale_count
105 );
106 for f in &s.stale_files {
107 println!(" {f}");
108 }
109 }
110 if let Some(raw) = &s.analysis_quality {
111 match serde_json::from_str::<scc_core::AnalysisQuality>(raw) {
112 Ok(q) => println!("analysis_quality: {}", q.compact_line()),
113 Err(_) => println!("analysis_quality: {raw}"),
114 }
115 }
116 if let Some(v) = &s.scan_stats {
117 let n = |k: &str| v.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
120 println!(
121 "files: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={}",
122 n("discovered"),
123 n("indexed"),
124 n("ignored"),
125 n("unsupported"),
126 n("oversized"),
127 n("unreadable")
128 );
129 }
130 } else {
131 println!("not indexed yet — run `scc index`");
132 }
133 Ok(())
134}
135
136pub fn cmd_scan(root: &Path, path: Option<&str>, json: bool) -> crate::Result<()> {
138 let config = load_config(root)?;
139 let v = scc_engine::status::scan(root, &config, path).map_err(engine_err)?;
140 let indexed = v["indexed"].as_array().cloned().unwrap_or_default();
141 let skipped = v["skipped"].as_array().cloned().unwrap_or_default();
142 let strf = |o: &serde_json::Value, k: &str| o.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
143 if json {
144 println!("{v}");
145 return Ok(());
146 }
147 if let Some(q) = path {
149 let q = q.trim_start_matches("./");
150 if let Some(f) = indexed.iter().find(|f| f.get("path").and_then(|p| p.as_str()) == Some(q)) {
151 println!("{}: indexed ({} {})", strf(f, "path"), strf(f, "language"), strf(f, "kind"));
152 return Ok(());
153 }
154 if let Some(sk) = skipped.iter().find(|f| f.get("path").and_then(|p| p.as_str()) == Some(q)) {
155 match sk.get("rule").and_then(|r| r.as_str()) {
156 Some(rule) => println!("{q}: {} ({rule})", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("")),
157 None => println!("{q}: {}", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("")),
158 }
159 std::process::exit(1);
160 }
161 println!("{q}: not found (no such file in working tree)");
162 std::process::exit(2);
163 }
164 let st = &v["stats"];
165 let n = |k: &str| st.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
166 println!(
167 "scan: discovered={} indexed={} ignored={} unsupported={} oversized={} unreadable={} (live walk, not index-time counts)",
168 n("discovered"), n("indexed"), n("ignored"), n("unsupported"), n("oversized"), n("unreadable")
169 );
170 if !skipped.is_empty() {
171 use std::collections::BTreeMap;
172 let mut by_rule: BTreeMap<String, usize> = BTreeMap::new();
173 for sk in &skipped {
174 let key = format!("{}: {}", sk.get("reason").and_then(|r| r.as_str()).unwrap_or("?"), sk.get("rule").and_then(|r| r.as_str()).unwrap_or("?"));
175 *by_rule.entry(key).or_default() += 1;
176 }
177 println!("\nskips by rule:");
178 for (rule, n) in &by_rule {
179 println!(" {n:>6} {rule}");
180 }
181 }
182 if v.get("skipped_detail_capped").and_then(|x| x.as_u64()).unwrap_or(0) > 0 {
184 println!(
185 "\n(detail capped: showing {} of {} skipped paths; counts above are exact — rerun with a path (`scc scan <path>`) or `--json` for one verdict)",
186 skipped.len(),
187 skipped.len() + v.get("skipped_detail_capped").and_then(|x| x.as_u64()).unwrap_or(0) as usize
188 );
189 }
190 {
192 use std::collections::BTreeMap;
193 let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
194 for f in &indexed {
195 let p = f.get("path").and_then(|x| x.as_str()).unwrap_or("");
196 let top = p.split('/').next().unwrap_or(".").to_string();
197 let key = if p.contains('/') { top } else { "(root)".to_string() };
198 *by_dir.entry(key).or_default() += 1;
199 }
200 println!("\nindexed by top dir:");
201 let mut rows: Vec<_> = by_dir.into_iter().collect();
202 rows.sort_by_key(|r| std::cmp::Reverse(r.1));
203 for (dir, n) in rows.iter().take(15) {
204 println!(" {n:>6} {dir}/");
205 }
206 }
207 println!("\nrerun with a path (`scc scan <path>`) for one verdict, or `--json` for the full lists.");
208 Ok(())
209}
210
211pub fn cmd_languages() -> crate::Result<()> {
215 print!("{}", scc_core::support_matrix_markdown());
216 Ok(())
217}
218
219pub fn cmd_overview(root: &Path, json: bool) -> crate::Result<()> {
221 let store = open_store(root)?;
222 let config = load_config(root)?;
223 let stale = crate::stale_paths(&store)?;
224 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
225 let pack = engine.context().overview().map_err(engine_err)?;
226 if json {
227 println!("{}", serde_json::to_string_pretty(&pack)?);
228 } else {
229 print!("{}", pack.content);
230 }
231 Ok(())
232}
233pub fn cmd_atlas(
238 root: &Path,
239 budget: Option<usize>,
240 json: bool,
241 full: bool,
242 unbounded: bool,
243) -> crate::Result<()> {
244 let store = open_store(root)?;
245 let config = load_config(root)?;
246 let stale = crate::stale_paths(&store)?;
247 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
248 let pack = engine.context().atlas(budget, full, unbounded).map_err(engine_err)?;
249 if json {
250 println!("{}", serde_json::to_string(&pack)?);
251 } else {
252 print!("{}", pack.content);
253 }
254 Ok(())
255}
256
257pub fn cmd_context_startup(root: &Path, budget_tokens: Option<usize>) -> crate::Result<()> {
264 let store = open_store(root)?;
265 let config = load_config(root)?;
266 let stale = crate::stale_paths(&store)?;
267 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
268 let (_startup, text) = engine.context().startup(&scc_api::StartupRequest { budget: budget_tokens }).map_err(engine_err)?;
269 print!("{text}");
270 Ok(())
271}
272
273pub fn cmd_surface(
282 root: &Path,
283 task: Option<&str>,
284 budget: Option<usize>,
285 explain: bool,
286) -> crate::Result<()> {
287 let store = open_store(root)?;
288 let config = load_config(root)?;
289 let stale = crate::stale_paths(&store)?;
290 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
291 let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
292 let scorer = match task {
298 Some(goal) => {
299 let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
300 scorer
301 }
302 None => None,
303 };
304 let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
305 scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
306 let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
307 let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
308 print!("{text}");
309 Ok(())
310}
311
312pub fn cmd_important(
320 root: &Path,
321 limit: usize,
322 component: Option<&str>,
323 task: Option<&str>,
324 json: bool,
325) -> crate::Result<()> {
326 let store = open_store(root)?;
327 let config = load_config(root)?;
328 let stale = crate::stale_paths(&store)?;
329 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
330 let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
331 if json {
332 println!("{}", serde_json::to_string_pretty(&entries)?);
333 return Ok(());
334 }
335 print!("{}", scc_context::surface::render_important(&entries, tasked));
336 Ok(())
337}
338
339pub fn cmd_context_structural(
347 root: &Path,
348 files: &[String],
349 task: Option<&str>,
350 budget: Option<usize>,
351) -> crate::Result<String> {
352 let out = scc_engine::invoke(
354 root,
355 "context.structural",
356 serde_json::json!({"files": files, "task": task, "budget": budget}),
357 )
358 .map_err(engine_err)?;
359 Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
360}
361
362#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
365pub struct TaskContextArtifact {
367 pub pack: scc_context::ContextPack,
368 pub delta: String,
371 #[serde(default)]
374 pub delta_ids: Vec<String>,
375 #[serde(default)]
380 pub token_count: usize,
381}
382
383pub fn build_task_context(
390 root: &Path,
391 goal: &str,
392 files: &[String],
393 symbols: &[String],
394 budget: Option<usize>,
395 hook: bool,
396) -> crate::Result<TaskContextArtifact> {
397 build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
398}
399
400fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
402 TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
403}
404
405fn build_task_context_engine(
407 root: &Path,
408 goal: &str,
409 files: &[String],
410 symbols: &[String],
411 budget: Option<usize>,
412 hook: bool,
413) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
414 let out = scc_engine::invoke(
417 root,
418 "context.task",
419 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
420 )?;
421 serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
422}
423
424pub fn build_enriched_task_pack(
433 root: &Path,
434 goal: &str,
435 files: &[String],
436 symbols: &[String],
437 budget: Option<usize>,
438 hook: bool,
439) -> crate::Result<scc_context::ContextPack> {
440 let out = scc_engine::invoke(
442 root,
443 "context.task_pack",
444 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
445 )
446 .map_err(engine_err)?;
447 serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
449}
450
451pub fn cmd_context_task(
458 root: &Path,
459 goal: &str,
460 files: &[String],
461 symbols: &[String],
462 budget: Option<usize>,
463 json: bool,
464 hook: bool,
465) -> crate::Result<()> {
466 if hook {
467 let config = load_config(root)?;
470 if !config.context.inject_task_focus {
471 return Ok(());
472 }
473 }
474 let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
475 if json {
476 println!("{}", serde_json::to_string_pretty(&artifact)?);
477 } else {
478 print!("{}", artifact.pack.content);
479 print!("\n{}", artifact.delta);
480 }
481 Ok(())
482}
483
484
485pub fn build_task_pack(
490 root: &Path,
491 goal: &str,
492 files: &[String],
493 symbols: &[String],
494 budget: Option<usize>,
495) -> crate::Result<scc_context::ContextPack> {
496 build_enriched_task_pack(root, goal, files, symbols, budget, false)
497}
498pub fn cmd_context_task_json(
502 root: &Path,
503 goal: &str,
504 files: &[String],
505 symbols: &[String],
506 budget: Option<usize>,
507) -> crate::Result<String> {
508 Ok(serde_json::to_string_pretty(&build_task_context(
509 root, goal, files, symbols, budget, false,
510 )?)?)
511}
512
513pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
517 print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
518 Ok(())
519}
520
521pub fn cmd_context_subagent(
526 root: &Path,
527 goal: &str,
528 files: &[String],
529 symbols: &[String],
530 budget: Option<usize>,
531 json: bool,
532) -> crate::Result<()> {
533 let store = open_store(root)?;
534 let config = load_config(root)?;
535 let stale = crate::stale_paths(&store)?;
536 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
537 let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
538 if json {
539 println!("{}", serde_json::to_string_pretty(&pack)?);
540 } else {
541 print!("{}", pack.content);
542 }
543 Ok(())
544}
545
546pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
548 let store = open_store(root)?;
549 let config = load_config(root)?;
550 let stale = crate::stale_paths(&store)?;
551 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
552 let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
553 if json {
554 println!("{}", serde_json::to_string_pretty(&pack)?);
555 } else {
556 print!("{}", pack.content);
557 }
558 Ok(())
559}
560
561pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
563 let store = open_store(root)?;
564 let config = load_config(root)?;
565 let stale = crate::stale_paths(&store)?;
566 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
567 let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
568 if json {
569 println!("{}", serde_json::to_string_pretty(&pack)?);
570 } else {
571 print!("{}", pack.content);
572 }
573 Ok(())
574}
575
576pub fn cmd_impact(
578 root: &Path,
579 files: &[String],
580 symbols: &[String],
581 diff: Option<&str>,
582 json: bool,
583 unbounded: bool,
584) -> crate::Result<()> {
585 let store = open_store(root)?;
586 let config = load_config(root)?;
587 let stale = crate::stale_paths(&store)?;
588 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
589 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)?;
590 if json {
591 println!("{}", serde_json::to_string_pretty(&pack)?);
592 } else {
593 print!("{}", pack.content);
594 }
595 Ok(())
596}
597
598pub fn cmd_verify(
600 root: &Path,
601 warnings_only: bool,
602 json: bool,
603 unbounded: bool,
604) -> crate::Result<()> {
605 let store = open_store(root)?;
606 let config = load_config(root)?;
607 let stale = crate::stale_paths(&store)?;
608 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
609 let pack = engine.context().verify(unbounded).map_err(engine_err)?;
610 if warnings_only {
611 for w in &pack.warnings {
612 println!("⚠ {w}");
613 }
614 return Ok(());
615 }
616 if json {
617 println!("{}", serde_json::to_string_pretty(&pack)?);
618 return Ok(());
619 }
620 print!("{}", pack.content);
622 if !pack.content.ends_with('\n') {
623 println!();
624 }
625 Ok(())
626}
627
628pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
630 let store = open_store(root)?;
631 let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
632 if json {
633 println!("{}", serde_json::to_string_pretty(&findings)?);
634 } else {
635 if findings.is_empty() {
636 println!("no drift findings");
637 }
638 for f in &findings {
639 println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
640 }
641 }
642 Ok(())
643}
644
645pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
647 let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
648 if json {
649 println!("{}", serde_json::to_string_pretty(&all)?);
650 return Ok(());
651 }
652 println!(
653 "system: {} members, {} stitches",
654 members.len(),
655 all.len()
656 );
657 for m in &members {
658 println!("member {} ({})", m.repo_id, m.root.display());
659 }
660 for s in &all {
661 println!(
662 "[{:?}/{:?}] {} ({} ends)",
663 s.kind,
664 s.match_kind,
665 s.key,
666 s.ends.len()
667 );
668 for e in &s.ends {
669 match e.role {
670 Some(role) => println!(" {} {} ({role:?})", e.repo_id, e.entity_id),
671 None => println!(" {} {}", e.repo_id, e.entity_id),
672 }
673 }
674 }
675 Ok(())
676}
677
678pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
680 let store = open_store(root)?;
681 let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
682 if json {
683 println!("{}", serde_json::to_string_pretty(&revs)?);
684 return Ok(());
685 }
686 if revs.is_empty() {
687 println!("no graph revisions recorded");
688 return Ok(());
689 }
690 for r in &revs {
691 println!(
692 "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
693 r.rev,
694 r.base_rev,
695 r.entity_count,
696 r.rel_count,
697 r.file_count,
698 &r.source_hash[..r.source_hash.len().min(12)],
699 r.extractor_version,
700 r.created_at,
701 );
702 }
703 Ok(())
704}
705
706pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
708 let store = open_store(root)?;
709 let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
710 if json {
711 println!("{}", serde_json::to_string_pretty(&d)?);
712 return Ok(());
713 }
714 println!("diff rev {from}..{to}:");
716 for (label, ids) in [
717 ("added entities", &d.added_entities),
718 ("removed entities", &d.removed_entities),
719 ("modified entities", &d.modified_entities),
720 ("added relationships", &d.added_relationships),
721 ("removed relationships", &d.removed_relationships),
722 ("modified relationships", &d.modified_relationships),
723 ] {
724 println!(" {label}: {}", ids.len());
725 for id in ids.iter().take(20) {
726 println!(" {id}");
727 }
728 if ids.len() > 20 {
729 println!(" … ({} more)", ids.len() - 20);
730 }
731 }
732 if !d.modified_kinds.is_empty() {
733 let kinds: Vec<String> = d
734 .modified_kinds
735 .iter()
736 .map(|(k, v)| format!("{k}:{v}"))
737 .collect();
738 println!(" modified kinds: {}", kinds.join(", "));
739 }
740 Ok(())
741}
742
743pub fn cmd_snapshot_save(
745 root: &Path,
746 task: &str,
747 budget: Option<usize>,
748 json: bool,
749) -> crate::Result<()> {
750 let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
751 if json {
752 println!("{}", serde_json::to_string_pretty(&snap)?);
753 } else {
754 println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
755 }
756 Ok(())
757}
758
759pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
761 let store = open_store(root)?;
762 match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
763 Some(s) => print!("{}", s.artifact),
764 None => println!("no snapshot {id}"),
765 }
766 Ok(())
767}
768
769pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
771 let store = open_store(root)?;
772 match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
773 Some(d) => {
774 if json {
775 println!("{}", serde_json::to_string_pretty(&d)?);
776 } else {
777 println!(
778 "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
779 d.snapshot_revision,
780 d.current_revision,
781 d.still_valid.len(),
782 d.invalidated.len(),
783 d.modified_entities.len(),
784 if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
785 );
786 for (label, ids) in [
787 ("invalidated", &d.invalidated),
788 ("modified entities", &d.modified_entities),
789 ("changed relationships", &d.changed_relationships),
790 ("changed contracts", &d.changed_contracts),
791 ("changed state", &d.changed_state),
792 ("changed flows", &d.changed_flows),
793 ] {
794 if ids.is_empty() {
795 continue;
796 }
797 println!(" {label} ({}):", ids.len());
798 for f in ids.iter().take(20) {
799 println!(" - {f}");
800 }
801 if ids.len() > 20 {
802 println!(" … ({} more)", ids.len() - 20);
803 }
804 }
805 }
806 }
807 None => println!("no snapshot {id}"),
808 }
809 Ok(())
810}
811
812pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
814 let store = open_store(root)?;
815 match format {
816 "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
817 "system-ir.jsonl" => {
818 let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
819 for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
820 println!("{line}");
821 }
822 }
823 "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)?)?),
824 "flow-graphs.json" => {
825 println!(
826 "{}",
827 serde_json::to_string_pretty(&store.flow_graphs()?)?
828 )
829 }
830 "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
831 other => {
832 return Err(crate::CliError::Other(format!(
833 "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
834 )))
835 }
836 }
837 Ok(())
838}
839
840pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
844 let store = open_store(root)?;
845 let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
846 if !hit.symbols.is_empty() {
852 println!("— where —");
853 for (name, _sig, _kind, file, line) in &hit.symbols {
854 if *line > 0 {
855 println!("where: {name} {file}:{line}");
856 } else {
857 println!("where: {name} {file}");
858 }
859 }
860 }
861 println!("— entities —");
862 for e in &hit.entities {
863 println!("{} [{}]", e.name, e.kind);
864 }
865 println!("— symbols —");
866 for (name, sig, kind, file, line) in &hit.symbols {
867 if *line > 0 {
868 println!("{name} ({kind}) {file}:{line} {sig}");
869 } else {
870 println!("{name} ({kind}) {file} {sig}");
871 }
872 }
873 Ok(())
874}
875
876pub fn cmd_traverse(
879 root: &Path,
880 kind: Option<&str>,
881 name: Option<&str>,
882 from: &[String],
883 steps: &[String],
884 limit: usize,
885) -> crate::Result<()> {
886 let mut parsed = Vec::new();
887 for spec in steps {
888 let mut parts = spec.splitn(3, ':');
889 let dir = parts.next().unwrap_or("").to_string();
890 if !["out", "in", "both"].contains(&dir.as_str()) {
891 return Err(crate::CliError::Other(format!(
892 "bad --step '{spec}' (dir must be out|in|both, e.g. out:calls:symbol)"
893 )));
894 }
895 parsed.push(scc_api::TraverseStep {
896 dir,
897 predicate: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
898 where_kind: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
899 limit: 0,
900 });
901 }
902 let store = open_store(root)?;
903 let config = load_config(root)?;
904 let stale = crate::stale_paths(&store)?;
905 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
906 let cc = engine.ctx();
907 let (entities, rels) = scc_engine::graph::traverse(
908 &cc,
909 &scc_api::TraverseRequest {
910 kind: kind.map(str::to_string),
911 name: name.map(str::to_string),
912 from_ids: from.to_vec(),
913 steps: parsed,
914 limit,
915 trusted_only: true,
916 },
917 )
918 .map_err(engine_err)?;
919 println!("— entities ({}) —", entities.len());
920 for e in &entities {
921 println!("{} [{}] {}", e.name, e.kind, e.id);
922 }
923 println!("— relationships ({}) —", rels.len());
924 for r in &rels {
925 println!("{} -{}-> {}", r.subject, r.predicate, r.object);
926 }
927 Ok(())
928}
929
930pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
932 let store = open_store(root)?;
933 for c in scc_engine::graph::components(&store).map_err(engine_err)? {
934 println!("{}", c.name);
935 }
936 Ok(())
937}
938
939pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
941 let store = open_store(root)?;
942 for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
943 println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
944 }
945 Ok(())
946}
947
948pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
953 let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
954 if pairs.is_empty() {
955 println!("no co-change pairs with >= {min_commits} shared commits");
956 } else {
957 println!("co-change pairs (>= {min_commits} shared commits):");
958 for p in &pairs {
959 println!(" {} <-> {} ×{}", p.a, p.b, p.commits);
960 }
961 }
962 if n > 0 {
963 println!("enriched {n} components with co-change signal");
964 }
965 Ok(())
966}
967
968pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
971 let store = open_store(root)?;
972 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
973 for v in &violations {
974 println!("{}", v.message);
975 }
976 Ok(violations.is_empty())
977}
978
979pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
983 let store = open_store(root)?;
984 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
985 for v in &violations {
986 println!("{}", v.message);
987 }
988 let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
989 for l in &lines {
990 if l.starts_with("[ci:") || *l == "ci check passed" {
992 println!("{l}");
993 }
994 }
995 Ok(ok)
996}
997
998pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
1000 let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
1001 if json {
1002 println!("{}", serde_json::to_string(&data)?);
1003 } else {
1004 println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
1005 }
1006 Ok(())
1007}
1008
1009pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
1011 if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
1012 print!("{content}");
1013 } else if !inject {
1014 println!("no checkpoint found");
1015 }
1016 Ok(())
1017}
1018
1019pub fn cmd_watch(root: &Path) -> crate::Result<()> {
1021 crate::httpd::watch_loop(root)
1022}
1023
1024pub fn cmd_serve(root: &Path) -> crate::Result<()> {
1026 crate::httpd::serve(root)
1027}
1028
1029pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
1031 crate::mcp::serve_stdio(root)
1032}
1033
1034pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
1036 crate::plugin::install(root)
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043pub enum SetupHarness {
1045 Claude,
1046 Codex,
1047 Opencode,
1048 Omp,
1049 Pi,
1050}
1051
1052pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
1058 let on_path = |bin: &str| {
1059 path_dirs
1060 .iter()
1061 .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
1062 };
1063 let mut out = Vec::new();
1064 if on_path("claude") || home.join(".claude").is_dir() {
1065 out.push(SetupHarness::Claude);
1066 }
1067 if on_path("codex") || home.join(".codex").is_dir() {
1068 out.push(SetupHarness::Codex);
1069 }
1070 if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
1071 out.push(SetupHarness::Opencode);
1072 }
1073 if on_path("omp") || root.join(".omp").is_dir() {
1074 out.push(SetupHarness::Omp);
1075 }
1076 if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
1077 out.push(SetupHarness::Pi);
1078 }
1079 out
1080}
1081
1082fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
1084 match h {
1085 SetupHarness::Claude => cmd_setup_claude(root),
1086 SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
1087 SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
1088 SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
1089 SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
1090 }
1091}
1092
1093pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
1099 let targets: Vec<SetupHarness> = if all {
1100 vec![
1101 SetupHarness::Claude,
1102 SetupHarness::Codex,
1103 SetupHarness::Opencode,
1104 SetupHarness::Omp,
1105 SetupHarness::Pi,
1106 ]
1107 } else {
1108 let home = std::env::var_os("HOME")
1109 .map(PathBuf::from)
1110 .unwrap_or_else(|| PathBuf::from("/"));
1111 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1112 .map(|p| std::env::split_paths(&p).collect())
1113 .unwrap_or_default();
1114 detect_harnesses(&home, &path_dirs, root)
1115 };
1116 if targets.is_empty() {
1117 println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
1118 println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
1119 println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
1120 return Ok(());
1121 }
1122 for h in &targets {
1123 println!("=== {:?} ===", h);
1124 install_harness(root, *h)?;
1125 println!();
1126 }
1127 println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
1128 if targets.contains(&SetupHarness::Codex) {
1129 println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
1130 }
1131 Ok(())
1132}
1133
1134pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
1136 scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
1137 println!("accepted");
1138 Ok(())
1139}
1140
1141fn adapter_scope(name: &str) -> &'static str {
1148 match name {
1149 "context7" => "network+subprocess(npx)",
1150 "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
1151 _ => "filesystem",
1152 }
1153}
1154
1155fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1162 use scc_indexer::adapters::IntegrationCategory;
1163 let config = load_config(root)?;
1164 let enabled = |key: Option<&str>| -> bool {
1165 match key {
1166 None => true,
1167 Some("serena") => config.integrations.serena,
1168 Some("beads") => config.integrations.beads,
1169 Some("hindsight") => config.integrations.hindsight,
1170 Some("gitnexus") => config.integrations.gitnexus,
1171 Some("context7_command") => !config.integrations.context7_command.is_empty(),
1172 Some(_) => false,
1175 }
1176 };
1177 Ok(scc_indexer::adapters::integration_registry()
1178 .into_iter()
1179 .filter(|d| {
1180 d.category != IntegrationCategory::CompatibilityOnly
1181 && d.category != IntegrationCategory::InternalPass
1182 && d.category != IntegrationCategory::AgentIntegration
1183 && enabled(d.config_key)
1184 })
1185 .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1186 .collect())
1187}
1188
1189pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1194 let manifests = scc_indexer::adapters::adapter_manifests();
1195 if json {
1196 println!("{}", serde_json::to_string_pretty(&manifests)?);
1197 return Ok(());
1198 }
1199 for (name, scope) in configured_adapters(root)? {
1200 println!("adapter: {name} scope: {scope}");
1201 }
1202 Ok(())
1203}
1204
1205pub fn cmd_update(version: Option<&str>, dir: Option<&std::path::Path>, dry_run: bool) -> crate::Result<()> {
1219 let exe = std::env::current_exe().map_err(|e| crate::CliError::Other(format!("cannot locate running binary: {e}")))?;
1220 let default_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from("."));
1221 let target_dir = dir.map(|p| p.to_path_buf()).unwrap_or(default_dir);
1222 let resolved: String = match version {
1225 Some(v) => v.to_string(),
1226 None => latest_release_tag()?,
1227 };
1228 let base = std::env::var("SCC_DOWNLOAD_BASE").unwrap_or_else(|_| {
1233 "https://github.com/carterlasalle/scc/releases/download".to_string()
1234 });
1235 let trimmed = base.trim_end_matches('/');
1236 let release_dir = trimmed.strip_suffix("/releases/download").map(|prefix| {
1237 format!("{prefix}/releases/download/v{resolved}")
1238 });
1239 let installer_url = match release_dir {
1240 Some(dir) => format!("{dir}/install.sh"),
1241 None => format!("{trimmed}/install.sh"),
1242 };
1243 let mut cmd = std::process::Command::new("sh");
1244 cmd.arg("-c").arg(format!(
1245 "curl -fsSL {url:?} | sh -s -- --version {ver} {dir} {dry}",
1246 url = installer_url,
1247 ver = resolved,
1248 dir = format!("--dir {}", target_dir.display()),
1249 dry = if dry_run { "--dry-run" } else { "" },
1250 ));
1251 let status = cmd.status().map_err(|e| crate::CliError::Other(format!("update failed to launch installer: {e}")))?;
1252 if !status.success() {
1253 return Err(crate::CliError::Other(format!("installer exited with status {status}")));
1254 }
1255 Ok(())
1256}
1257
1258fn latest_release_tag() -> crate::Result<String> {
1260 let api_base = std::env::var("SCC_API_BASE")
1261 .unwrap_or_else(|_| "https://api.github.com/repos/carterlasalle/scc".to_string());
1262 let url = format!("{}/releases/latest", api_base.trim_end_matches('/'));
1263 let out = std::process::Command::new("curl")
1264 .args(["-fsSL", "--max-time", "15", &url])
1265 .output()
1266 .map_err(|e| crate::CliError::Other(format!("latest-release lookup failed: {e}")))?;
1267 if !out.status.success() {
1268 return Err(crate::CliError::Other(format!("latest-release lookup failed: {url}")));
1269 }
1270 let v: serde_json::Value = serde_json::from_slice(&out.stdout)
1271 .map_err(|e| crate::CliError::Other(format!("latest-release response not JSON: {e}")))?;
1272 let tag = v.get("tag_name").and_then(|t| t.as_str()).unwrap_or("").trim_start_matches('v');
1273 if tag.is_empty() {
1274 return Err(crate::CliError::Other("latest release has no tag_name".to_string()));
1275 }
1276 Ok(tag.to_string())
1277}
1278
1279pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1294 let store = open_store(root)?;
1295 let config = load_config(root)?;
1296 let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1297 let rows = rep.integrations;
1298 let agent_rows = rep.agents;
1299 let mut warnings = rep.warnings;
1300 let (scc_version, rev, stale_len, dangling, stats) =
1301 (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1302 if json {
1303 println!(
1304 "{}",
1305 serde_json::to_string_pretty(&serde_json::json!({
1306 "scc": scc_version,
1307 "revision": rev,
1308 "stale_files": stale_len,
1309 "dangling_edges": dangling,
1310 "stats": stats,
1311 "integrations": rows,
1312 "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1313 "warnings": warnings,
1314 }))?
1315 );
1316 } else {
1317 println!("SCC Doctor");
1318 println!("==========");
1319 println!();
1320 println!("CORE");
1321 println!(" scc {scc_version}");
1322 println!(" revision {rev}");
1323 println!(" stale {} file(s)", stale_len);
1324 println!(" dangling {dangling} edge(s) (see `scc check-invariants`)");
1325 println!(" entities {}", stats.get("entities").copied().unwrap_or(0));
1326 println!();
1327 println!("INTEGRATIONS");
1328 for r in &rows {
1329 let mark = if r.contributing {
1330 "✓"
1331 } else if r.configured {
1332 "!"
1333 } else {
1334 "-"
1335 };
1336 let reach = match r.reachable {
1337 Some(true) => "reachable; ",
1338 Some(false) => "UNREACHABLE; ",
1339 None => "",
1340 };
1341 println!(" {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1342 }
1343 println!();
1344 println!("AGENTS");
1345 for a in &agent_rows {
1346 println!(" {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1347 }
1348 println!();
1349 if warnings == 0 {
1350 println!("SUMMARY PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1351 } else {
1352 println!("SUMMARY {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1353 }
1354 }
1355 if dangling > 0 {
1356 warnings += 1;
1357 }
1358 Ok(!(strict && warnings > 0))
1362}
1363
1364pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1369 let dir = scc_dir(root);
1370 std::fs::create_dir_all(&dir)?;
1371 let path = dir.join("lessons.jsonl");
1372 let n = std::fs::read_to_string(&path)
1373 .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1374 .unwrap_or(0);
1375 let id = format!("lesson-{}", n + 1);
1376 let record = serde_json::json!({
1377 "id": id,
1378 "text": text,
1379 "created_at": scc_core::now_rfc3339(),
1380 });
1381 let mut f = std::fs::OpenOptions::new()
1382 .create(true)
1383 .append(true)
1384 .open(&path)
1385 .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1386 writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1387 println!(
1388 "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1389 path.display()
1390 );
1391 Ok(())
1392}
1393
1394pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1398 let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1399 if lessons.is_empty() {
1400 println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1401 return Ok(());
1402 }
1403 for (content, tags) in lessons {
1404 let tag_str = if tags.is_empty() {
1405 String::new()
1406 } else {
1407 format!(" [{}]", tags.join(", "))
1408 };
1409 println!("- {content}{tag_str}");
1410 }
1411 Ok(())
1412}
1413
1414pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1418 let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1419 if active.is_empty() {
1420 println!("no active beads tasks (checked .beads/issues.jsonl)");
1421 return Ok(());
1422 }
1423 println!("active beads tasks:");
1424 for t in active {
1425 println!("- {t}");
1426 }
1427 Ok(())
1428}
1429
1430pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1432 let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1433 println!(
1436 "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1437 report.symbols, report.calls, report.imports, report.errors
1438 );
1439 Ok(())
1440}
1441
1442pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1444 let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1445 if json {
1446 println!("{}", serde_json::to_string_pretty(&edges)?);
1447 return Ok(());
1448 }
1449 if edges.is_empty() {
1450 println!("no runtime observations ingested");
1451 }
1452 let total: u64 = edges.iter().map(|e| e.count).sum();
1453 let errs: u64 = edges.iter().map(|e| e.errors).sum();
1454 println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1455 for e in &edges {
1456 println!(
1457 "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1458 e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1459 );
1460 }
1461 Ok(())
1462}
1463
1464pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1466 let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1467 if json {
1468 println!("{}", serde_json::to_string_pretty(&rec)?);
1469 return Ok(());
1470 }
1471 println!("static-vs-observed reconciliation");
1472 println!(" matched: {}", rec.matched.len());
1473 println!(" observed not static: {}", rec.observed_not_static.len());
1474 println!(" static not observed: {}", rec.static_not_observed.len());
1475 const MAX_RECONCILE_DETAIL: usize = 20;
1477 let cap = |label: &str, items: &[String]| {
1478 for e in items.iter().take(MAX_RECONCILE_DETAIL) {
1479 println!(" [{label}] {e}");
1480 }
1481 if items.len() > MAX_RECONCILE_DETAIL {
1482 println!(
1483 " …and {} more [{label}] (counts exact; --json for the full list)",
1484 items.len() - MAX_RECONCILE_DETAIL
1485 );
1486 }
1487 };
1488 cap("matched", &rec.matched);
1489 cap("runtime-only", &rec.observed_not_static);
1490 cap("static-only", &rec.static_not_observed);
1491 Ok(())
1492}
1493
1494pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1499 let store = open_store(root)?;
1500 if store.snapshot_status()?.is_none() {
1501 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1502 }
1503 let v = scc_engine::exports::diagram(&store, format).map_err(engine_err)?;
1508 let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1509 if let Some(path) = out {
1510 std::fs::write(path, text)?;
1511 println!(
1512 "diagram: {} nodes, {} edges, {} flows -> {path}",
1513 v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1514 v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1515 v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1516 );
1517 } else {
1518 print!("{text}");
1519 }
1520 Ok(())
1521}
1522
1523pub fn cmd_snap(
1529 root: &Path,
1530 out: Option<&str>,
1531 max_chars: usize,
1532 png: Option<&str>,
1533 color: bool,
1534) -> crate::Result<()> {
1535 let store = open_store(root)?;
1536 if store.snapshot_status()?.is_none() {
1537 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1538 }
1539 let config = load_config(root)?;
1540 let map = crate::viewer::map_text(&store, max_chars)?;
1541 let rows = map.lines().count();
1542 let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1543 let render = png.is_some() || config.context.snap_enabled;
1544 if let Some(path) = out {
1545 std::fs::write(path, &map)?;
1546 } else {
1547 print!("{map}");
1548 if !map.ends_with('\n') {
1549 println!();
1550 }
1551 }
1552 if color {
1553 eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1554 }
1555 println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1556 if render {
1557 let dest = png.unwrap_or("scc-snap.png");
1558 render_snap_png(&map, dest)?;
1559 println!("snap png -> {dest}");
1560 } else {
1561 println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1562 println!("{}", crate::viewer::snap_recipe());
1563 }
1564 Ok(())
1565}
1566
1567fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1572 let probe = std::process::Command::new("python3")
1575 .arg("-c")
1576 .arg("import PIL")
1577 .output()
1578 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1579 if !probe.status.success() {
1580 return Err(crate::CliError::Other(
1581 "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1582 ));
1583 }
1584 let dir = tempfile::TempDir::new()?;
1585 let map_path = dir.path().join("map.txt");
1586 let recipe_path = dir.path().join("snap.py");
1587 std::fs::write(&map_path, map)?;
1588 std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1589 let out = std::process::Command::new("python3")
1590 .arg(&recipe_path)
1591 .arg(&map_path)
1592 .arg(dest)
1593 .output()
1594 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1595 if !out.status.success() {
1596 return Err(crate::CliError::Other(format!(
1597 "snap: Pillow render failed: {}",
1598 String::from_utf8_lossy(&out.stderr).trim()
1599 )));
1600 }
1601 Ok(())
1602}
1603
1604pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1609 let store = open_store(root)?;
1610 if store.snapshot_status()?.is_none() {
1611 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1612 }
1613 let addr = match port {
1614 Some(p) => format!("127.0.0.1:{p}"),
1615 None => "127.0.0.1:0".to_string(),
1616 };
1617 let server = tiny_http::Server::http(&addr)
1618 .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1619 let bound = server.server_addr();
1620 let url = format!("http://{bound}/");
1621 println!("scc view: {url} (root {})", root.display());
1622 if !no_open {
1623 #[cfg(target_os = "macos")]
1624 let _ = std::process::Command::new("open").arg(&url).spawn();
1625 #[cfg(target_os = "linux")]
1626 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1627 }
1628 for request in server.incoming_requests() {
1629 let url_path = request.url().to_string();
1630 let method = request.method().clone();
1631 if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1632 let store = match open_store(root) {
1633 Ok(s) => s,
1634 Err(e) => {
1635 let _ = request.respond(
1636 tiny_http::Response::from_string(format!("store error: {e}"))
1637 .with_status_code(500),
1638 );
1639 continue;
1640 }
1641 };
1642 let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1643 let response = tiny_http::Response::from_string(body)
1644 .with_status_code(status)
1645 .with_header(
1646 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1647 .unwrap(),
1648 );
1649 let _ = request.respond(response);
1650 continue;
1651 }
1652 let _ = request.respond(
1655 tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1656 .with_status_code(404),
1657 );
1658 }
1659 Ok(())
1660}
1661
1662pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1665 scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1666}
1667
1668pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1671 if let Some(id) = describe {
1672 match scc_engine::ops::describe(id) {
1673 Some(d) => {
1674 println!("{}", serde_json::to_string_pretty(&d)?);
1675 match scc_engine::ops::input_schema(id) {
1676 Some(s) => println!("{}", serde_json::to_string_pretty(&s)?),
1677 None => println!("(no typed input schema: free-form object)"),
1678 }
1679 }
1680 None => println!("unknown operation '{id}' (see `scc operations`)"),
1681 }
1682 return Ok(());
1683 }
1684 println!("Operation Mutation Stream Stability Description");
1685 println!("--------------------------------------------------------------------------------");
1686 for d in scc_engine::ops::OPERATIONS {
1687 println!("{:<26} {:<14} {:<7} {:<13} {}", d.id, format!("{:?}", d.mutation), d.streaming, format!("{:?}", d.stability), d.description);
1688 }
1689 Ok(())
1690}
1691
1692pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1695 let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1696 println!("{}", serde_json::to_string_pretty(&out)?);
1697 Ok(())
1698}
1699
1700pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1703 let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1704 println!("{}", serde_json::to_string_pretty(&out)?);
1705 Ok(())
1706}
1707
1708pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1711 let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1712 println!("{}", serde_json::to_string_pretty(&out)?);
1713 Ok(())
1714}
1715
1716pub fn cmd_plugin_graph(root: &Path) -> crate::Result<()> {
1720 let out = scc_engine::invoke(root, "plugins.graph", serde_json::json!({})).map_err(engine_err)?;
1721 let groups = out.get("groups").and_then(|g| g.as_object());
1722 match groups {
1723 Some(g) if !g.is_empty() => {
1724 for (ty, items) in g {
1725 println!("{ty}:");
1726 for it in items.as_array().cloned().unwrap_or_default() {
1727 println!(" {} (priority {})", it.get("key").and_then(|k| k.as_str()).unwrap_or("?"), it.get("priority").and_then(|p| p.as_i64()).unwrap_or(0));
1728 }
1729 }
1730 }
1731 _ => println!("(no extensions registered)"),
1732 }
1733 Ok(())
1734}
1735
1736pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1739 let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1740 println!("{}", serde_json::to_string_pretty(&out)?);
1741 Ok(())
1742}
1743
1744pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1747 let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1748 println!("{}", serde_json::to_string_pretty(&out)?);
1749 if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1750 return Err(crate::CliError::Other(format!(
1751 "plugin lock drift: {}",
1752 out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1753 )));
1754 }
1755 Ok(())
1756}
1757
1758pub fn cmd_plugin_enable(root: &Path, id: &str) -> crate::Result<()> {
1762 let out = scc_engine::invoke(root, "plugins.enable", serde_json::json!({"id": id})).map_err(engine_err)?;
1763 println!("{}", serde_json::to_string_pretty(&out)?);
1764 Ok(())
1765}
1766
1767pub fn cmd_plugin_disable(root: &Path, id: &str) -> crate::Result<()> {
1769 let out = scc_engine::invoke(root, "plugins.disable", serde_json::json!({"id": id})).map_err(engine_err)?;
1770 println!("{}", serde_json::to_string_pretty(&out)?);
1771 Ok(())
1772}
1773
1774pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1777 let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1778 let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1779 println!("{}", serde_json::to_string_pretty(&out)?);
1780 Ok(())
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785
1786 #[test]
1787 fn ensure_scc_ignored_keeps_intent_committable() {
1789 let dir = tempfile::TempDir::new().unwrap();
1790 let root = dir.path();
1791 crate::ensure_scc_ignored(root);
1792 let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1793 assert!(gi.contains(".scc/*"), "{gi}");
1794 assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1795 assert!(gi.contains("!.scc/plugins.toml"), "{gi}");
1796 assert!(gi.contains("!.scc/plugins.lock"), "{gi}");
1797 crate::ensure_scc_ignored(root);
1798 let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1799 assert_eq!(gi, gi2, "idempotent");
1800 std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
1801 crate::ensure_scc_ignored(root);
1802 let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1803 assert!(gi3.contains("target/"), "{gi3}");
1804 assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
1805 assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
1806 }
1807
1808 #[test]
1809 fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
1811 let home = tempfile::TempDir::new().unwrap();
1812 let bindir = home.path().join("bin");
1813 std::fs::create_dir_all(&bindir).unwrap();
1814 std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
1815 std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
1816 let root = tempfile::TempDir::new().unwrap();
1817 std::fs::create_dir_all(root.path().join(".omp")).unwrap();
1818 let found = detect_harnesses(
1819 home.path(),
1820 &[bindir],
1821 root.path(),
1822 );
1823 assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
1824 assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
1825 assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
1826 assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
1827 assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
1828 }
1829
1830 #[test]
1831 fn detect_harnesses_empty_when_nothing_present() {
1833 let home = tempfile::TempDir::new().unwrap();
1834 let root = tempfile::TempDir::new().unwrap();
1835 let found = detect_harnesses(home.path(), &[], root.path());
1836 assert!(found.is_empty(), "{found:?}");
1837 }
1838
1839 use super::*;
1840
1841
1842 #[test]
1843fn lessons_add_appends_jsonl_lines() {
1845 let dir = tempfile::TempDir::new().unwrap();
1846 let root = dir.path().join("repo");
1847 std::fs::create_dir_all(&root).unwrap();
1848 cmd_lessons_add(&root, "first lesson").unwrap();
1849 cmd_lessons_add(&root, "second lesson").unwrap();
1850 let path = root.join(".scc/lessons.jsonl");
1851 let text = std::fs::read_to_string(&path).unwrap();
1852 let lines: Vec<&str> = text.lines().collect();
1853 assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
1854 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1855 assert_eq!(first["id"], "lesson-1");
1856 assert_eq!(first["text"], "first lesson");
1857 assert!(first["created_at"].is_string());
1858 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1859 assert_eq!(second["id"], "lesson-2");
1860 assert_eq!(second["text"], "second lesson");
1861 let lesson: scc_indexer::adapters::hindsight::Lesson =
1863 serde_json::from_str(lines[0]).unwrap();
1864 assert_eq!(lesson.body(), "first lesson");
1865 }
1866
1867 #[test]
1868fn adapters_lists_configured_scope() {
1870 let dir = tempfile::TempDir::new().unwrap();
1871 let root = dir.path().join("repo");
1872 std::fs::create_dir_all(root.join(".scc")).unwrap();
1873 std::fs::write(
1874 root.join(".scc/config.yaml"),
1875 "schema: 1\nintegrations:\n beads: true\n context7_command: \"npx -y @upstash/context7-mcp\"\n",
1876 )
1877 .unwrap();
1878 let listing = configured_adapters(&root).unwrap();
1879 assert!(
1880 listing.contains(&("beads".to_string(), "filesystem")),
1881 "{listing:?}"
1882 );
1883 assert!(
1884 listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
1885 "{listing:?}"
1886 );
1887 assert!(
1888 !listing.iter().any(|(n, _)| n == "hindsight"),
1889 "disabled integrations must not be listed: {listing:?}"
1890 );
1891 assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
1893 assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
1894 for (n, _) in &listing {
1897 assert!(
1898 scc_indexer::adapters::resolve_integration(n).is_some(),
1899 "listed adapter {n} must resolve in the registry: {listing:?}"
1900 );
1901 }
1902 assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
1903 assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
1905 assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
1906 cmd_adapters(&root, false).unwrap();
1908 assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
1910 assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
1911 }
1912}
1913