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>, full_size: bool) -> 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 if full_size {
270 let delivered: usize = scc_core::estimate_tokens(&text);
274 println!("startup full-size accounting");
275 println!(" delivered_tokens: {delivered}");
276 println!(" surface_tokens: {}", startup.surface_render.token_count);
277 println!(" atlas_budget_used: {}", startup.atlas_budget_used);
278 println!(" coverage_notes: {}", startup.coverage.len());
279 if startup.omissions.iter().any(|o| o != "none") {
280 println!("dropped/truncated sections:");
281 for o in &startup.omissions {
282 if o == "none" {
283 continue;
284 }
285 println!(" - {o}");
286 }
287 println!("recover with: scc surface --explain | scc context task <goal> | scc context structural --files <paths>");
288 } else {
289 println!("dropped/truncated sections: none (pack is complete)");
290 }
291 return Ok(());
292 }
293 print!("{text}");
294 Ok(())
295}
296
297pub fn cmd_surface(
306 root: &Path,
307 task: Option<&str>,
308 budget: Option<usize>,
309 explain: bool,
310) -> crate::Result<()> {
311 let store = open_store(root)?;
312 let config = load_config(root)?;
313 let stale = crate::stale_paths(&store)?;
314 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
315 let tokens = budget.unwrap_or(scc_core::ContextBudget::default().surface);
316 let scorer = match task {
322 Some(goal) => {
323 let (scorer, _reranker) = scc_engine::inference::rankers(&store, &config, goal);
324 scorer
325 }
326 None => None,
327 };
328 let semantic: Option<&dyn scc_context::rank::SemanticScorer> =
329 scorer.as_ref().map(|s| s as &dyn scc_context::rank::SemanticScorer);
330 let req = scc_api::SurfaceRequest { task: task.map(|s| s.to_string()), budget: Some(tokens), explain, stages: None };
331 let (_result, text) = engine.context().surface(&req, semantic).map_err(engine_err)?;
332 print!("{text}");
333 Ok(())
334}
335
336pub fn cmd_important(
344 root: &Path,
345 limit: usize,
346 component: Option<&str>,
347 task: Option<&str>,
348 json: bool,
349) -> crate::Result<()> {
350 let store = open_store(root)?;
351 let config = load_config(root)?;
352 let stale = crate::stale_paths(&store)?;
353 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
354 let (entries, tasked) = engine.context().important(limit, component, task).map_err(engine_err)?;
355 if json {
356 println!("{}", serde_json::to_string_pretty(&entries)?);
357 return Ok(());
358 }
359 print!("{}", scc_context::surface::render_important(&entries, tasked));
360 Ok(())
361}
362
363pub fn cmd_context_structural(
371 root: &Path,
372 files: &[String],
373 task: Option<&str>,
374 budget: Option<usize>,
375) -> crate::Result<String> {
376 let out = scc_engine::invoke(
378 root,
379 "context.structural",
380 serde_json::json!({"files": files, "task": task, "budget": budget}),
381 )
382 .map_err(engine_err)?;
383 Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
384}
385
386#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
389pub struct TaskContextArtifact {
391 pub pack: scc_context::ContextPack,
392 pub delta: String,
395 #[serde(default)]
398 pub delta_ids: Vec<String>,
399 #[serde(default)]
404 pub token_count: usize,
405}
406
407pub fn build_task_context(
414 root: &Path,
415 goal: &str,
416 files: &[String],
417 symbols: &[String],
418 budget: Option<usize>,
419 hook: bool,
420) -> crate::Result<TaskContextArtifact> {
421 build_task_context_engine(root, goal, files, symbols, budget, hook).map_err(engine_err).map(from_engine_artifact)
422}
423
424fn from_engine_artifact(a: scc_engine::TaskContextArtifact) -> TaskContextArtifact {
426 TaskContextArtifact { pack: a.pack, delta: a.delta, delta_ids: a.delta_ids, token_count: a.token_count }
427}
428
429fn build_task_context_engine(
431 root: &Path,
432 goal: &str,
433 files: &[String],
434 symbols: &[String],
435 budget: Option<usize>,
436 hook: bool,
437) -> scc_engine::Result<scc_engine::TaskContextArtifact> {
438 let out = scc_engine::invoke(
441 root,
442 "context.task",
443 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
444 )?;
445 serde_json::from_value(out).map_err(|e| scc_engine::EngineError::Other(e.to_string()))
446}
447
448pub fn build_enriched_task_pack(
457 root: &Path,
458 goal: &str,
459 files: &[String],
460 symbols: &[String],
461 budget: Option<usize>,
462 hook: bool,
463) -> crate::Result<scc_context::ContextPack> {
464 let out = scc_engine::invoke(
466 root,
467 "context.task_pack",
468 serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "budget": budget, "hook": hook}),
469 )
470 .map_err(engine_err)?;
471 serde_json::from_value(out).map_err(|e: serde_json::Error| crate::CliError::Other(e.to_string()))
473}
474
475pub fn cmd_context_task(
482 root: &Path,
483 goal: &str,
484 files: &[String],
485 symbols: &[String],
486 budget: Option<usize>,
487 json: bool,
488 hook: bool,
489) -> crate::Result<()> {
490 if hook {
491 let config = load_config(root)?;
494 if !config.context.inject_task_focus {
495 return Ok(());
496 }
497 }
498 let artifact = build_task_context(root, goal, files, symbols, budget, hook)?;
499 if json {
500 println!("{}", serde_json::to_string_pretty(&artifact)?);
501 } else {
502 print!("{}", artifact.pack.content);
503 print!("\n{}", artifact.delta);
504 }
505 Ok(())
506}
507
508
509pub fn build_task_pack(
514 root: &Path,
515 goal: &str,
516 files: &[String],
517 symbols: &[String],
518 budget: Option<usize>,
519) -> crate::Result<scc_context::ContextPack> {
520 build_enriched_task_pack(root, goal, files, symbols, budget, false)
521}
522pub fn cmd_context_task_json(
526 root: &Path,
527 goal: &str,
528 files: &[String],
529 symbols: &[String],
530 budget: Option<usize>,
531) -> crate::Result<String> {
532 Ok(serde_json::to_string_pretty(&build_task_context(
533 root, goal, files, symbols, budget, false,
534 )?)?)
535}
536
537pub fn cmd_context_docs(root: &Path, dependency: &str) -> crate::Result<()> {
541 print!("{}", scc_engine::invoke(root, "context.external_docs", serde_json::json!({"dependency": dependency})).map_err(engine_err)?);
542 Ok(())
543}
544
545pub fn cmd_context_subagent(
550 root: &Path,
551 goal: &str,
552 files: &[String],
553 symbols: &[String],
554 budget: Option<usize>,
555 json: bool,
556) -> crate::Result<()> {
557 let store = open_store(root)?;
558 let config = load_config(root)?;
559 let stale = crate::stale_paths(&store)?;
560 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
561 let pack = engine.context().subagent(goal, files, symbols, budget).map_err(engine_err)?;
562 if json {
563 println!("{}", serde_json::to_string_pretty(&pack)?);
564 } else {
565 print!("{}", pack.content);
566 }
567 Ok(())
568}
569
570pub fn cmd_context_component(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
572 let store = open_store(root)?;
573 let config = load_config(root)?;
574 let stale = crate::stale_paths(&store)?;
575 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
576 let pack = engine.context().component(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
577 if json {
578 println!("{}", serde_json::to_string_pretty(&pack)?);
579 } else {
580 print!("{}", pack.content);
581 }
582 Ok(())
583}
584
585pub fn cmd_context_flow(root: &Path, id: &str, json: bool, unbounded: bool) -> crate::Result<()> {
587 let store = open_store(root)?;
588 let config = load_config(root)?;
589 let stale = crate::stale_paths(&store)?;
590 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
591 let pack = engine.context().flow(&scc_api::DetailRequest { id: id.into(), unbounded }).map_err(engine_err)?;
592 if json {
593 println!("{}", serde_json::to_string_pretty(&pack)?);
594 } else {
595 print!("{}", pack.content);
596 }
597 Ok(())
598}
599
600pub fn cmd_impact(
602 root: &Path,
603 files: &[String],
604 symbols: &[String],
605 diff: Option<&str>,
606 json: bool,
607 unbounded: bool,
608) -> crate::Result<()> {
609 let store = open_store(root)?;
610 let config = load_config(root)?;
611 let stale = crate::stale_paths(&store)?;
612 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
613 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)?;
614 if json {
615 println!("{}", serde_json::to_string_pretty(&pack)?);
616 } else {
617 print!("{}", pack.content);
618 }
619 Ok(())
620}
621
622pub fn cmd_verify(
624 root: &Path,
625 warnings_only: bool,
626 json: bool,
627 unbounded: bool,
628) -> crate::Result<()> {
629 let store = open_store(root)?;
630 let config = load_config(root)?;
631 let stale = crate::stale_paths(&store)?;
632 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
633 let pack = engine.context().verify(unbounded).map_err(engine_err)?;
634 if warnings_only {
635 for w in &pack.warnings {
636 println!("⚠ {w}");
637 }
638 return Ok(());
639 }
640 if json {
641 println!("{}", serde_json::to_string_pretty(&pack)?);
642 return Ok(());
643 }
644 print!("{}", pack.content);
646 if !pack.content.ends_with('\n') {
647 println!();
648 }
649 Ok(())
650}
651
652pub fn cmd_drift(root: &Path, json: bool) -> crate::Result<()> {
654 let store = open_store(root)?;
655 let findings = scc_engine::misc::drift(&store).map_err(engine_err)?;
656 if json {
657 println!("{}", serde_json::to_string_pretty(&findings)?);
658 } else {
659 if findings.is_empty() {
660 println!("no drift findings");
661 }
662 for f in &findings {
663 println!("[{0}] {1} (#{2}, {3}): {4}", f.severity, f.kind, f.id, f.created_at, f.message);
664 }
665 }
666 Ok(())
667}
668
669pub fn cmd_system(_root: &Path, members: &[std::path::PathBuf], json: bool) -> crate::Result<()> {
671 let (members, all) = scc_engine::systems::stitch(members).map_err(engine_err)?;
672 if json {
673 println!("{}", serde_json::to_string_pretty(&all)?);
674 return Ok(());
675 }
676 println!(
677 "system: {} members, {} stitches",
678 members.len(),
679 all.len()
680 );
681 for m in &members {
682 println!("member {} ({})", m.repo_id, m.root.display());
683 }
684 for s in &all {
685 println!(
686 "[{:?}/{:?}] {} ({} ends)",
687 s.kind,
688 s.match_kind,
689 s.key,
690 s.ends.len()
691 );
692 for e in &s.ends {
693 match e.role {
694 Some(role) => println!(" {} {} ({role:?})", e.repo_id, e.entity_id),
695 None => println!(" {} {}", e.repo_id, e.entity_id),
696 }
697 }
698 }
699 Ok(())
700}
701
702pub fn cmd_history(root: &Path, json: bool) -> crate::Result<()> {
704 let store = open_store(root)?;
705 let revs = scc_engine::history::revisions(&store).map_err(engine_err)?;
706 if json {
707 println!("{}", serde_json::to_string_pretty(&revs)?);
708 return Ok(());
709 }
710 if revs.is_empty() {
711 println!("no graph revisions recorded");
712 return Ok(());
713 }
714 for r in &revs {
715 println!(
716 "rev {} (base {}): {} entities, {} rels, {} files | src={} ext={} | {}",
717 r.rev,
718 r.base_rev,
719 r.entity_count,
720 r.rel_count,
721 r.file_count,
722 &r.source_hash[..r.source_hash.len().min(12)],
723 r.extractor_version,
724 r.created_at,
725 );
726 }
727 Ok(())
728}
729
730pub fn cmd_diff(root: &Path, from: i64, to: i64, json: bool) -> crate::Result<()> {
732 let store = open_store(root)?;
733 let d = scc_engine::history::diff(&store, from, to).map_err(engine_err)?;
734 if json {
735 println!("{}", serde_json::to_string_pretty(&d)?);
736 return Ok(());
737 }
738 println!("diff rev {from}..{to}:");
740 for (label, ids) in [
741 ("added entities", &d.added_entities),
742 ("removed entities", &d.removed_entities),
743 ("modified entities", &d.modified_entities),
744 ("added relationships", &d.added_relationships),
745 ("removed relationships", &d.removed_relationships),
746 ("modified relationships", &d.modified_relationships),
747 ] {
748 println!(" {label}: {}", ids.len());
749 for id in ids.iter().take(20) {
750 println!(" {id}");
751 }
752 if ids.len() > 20 {
753 println!(" … ({} more)", ids.len() - 20);
754 }
755 }
756 if !d.modified_kinds.is_empty() {
757 let kinds: Vec<String> = d
758 .modified_kinds
759 .iter()
760 .map(|(k, v)| format!("{k}:{v}"))
761 .collect();
762 println!(" modified kinds: {}", kinds.join(", "));
763 }
764 Ok(())
765}
766
767pub fn cmd_snapshot_save(
769 root: &Path,
770 task: &str,
771 budget: Option<usize>,
772 json: bool,
773) -> crate::Result<()> {
774 let snap = scc_engine::misc::snapshot_save(root, task, budget).map_err(engine_err)?;
775 if json {
776 println!("{}", serde_json::to_string_pretty(&snap)?);
777 } else {
778 println!("snapshot {} ({} visible ids)", snap.id, snap.entity_ids.len());
779 }
780 Ok(())
781}
782
783pub fn cmd_snapshot_show(root: &Path, id: &str) -> crate::Result<()> {
785 let store = open_store(root)?;
786 match scc_engine::snapshots::get(&store, id).map_err(engine_err)? {
787 Some(s) => print!("{}", s.artifact),
788 None => println!("no snapshot {id}"),
789 }
790 Ok(())
791}
792
793pub fn cmd_snapshot_diff(root: &Path, id: &str, json: bool) -> crate::Result<()> {
795 let store = open_store(root)?;
796 match scc_engine::snapshots::diff(&store, id).map_err(engine_err)? {
797 Some(d) => {
798 if json {
799 println!("{}", serde_json::to_string_pretty(&d)?);
800 } else {
801 println!(
802 "snapshot rev {} vs current rev {}: {} still valid, {} invalidated, {} modified{}",
803 d.snapshot_revision,
804 d.current_revision,
805 d.still_valid.len(),
806 d.invalidated.len(),
807 d.modified_entities.len(),
808 if d.artifact_changed { " (artifact would re-render differently)" } else { "" },
809 );
810 for (label, ids) in [
811 ("invalidated", &d.invalidated),
812 ("modified entities", &d.modified_entities),
813 ("changed relationships", &d.changed_relationships),
814 ("changed contracts", &d.changed_contracts),
815 ("changed state", &d.changed_state),
816 ("changed flows", &d.changed_flows),
817 ] {
818 if ids.is_empty() {
819 continue;
820 }
821 println!(" {label} ({}):", ids.len());
822 for f in ids.iter().take(20) {
823 println!(" - {f}");
824 }
825 if ids.len() > 20 {
826 println!(" … ({} more)", ids.len() - 20);
827 }
828 }
829 }
830 }
831 None => println!("no snapshot {id}"),
832 }
833 Ok(())
834}
835
836pub fn cmd_export(root: &Path, format: &str) -> crate::Result<()> {
838 let store = open_store(root)?;
839 match format {
840 "system-ir.json" => println!("{}", serde_json::to_string_pretty(&scc_engine::exports::system_ir(&store).map_err(engine_err)?)?),
841 "system-ir.jsonl" => {
842 let ir = scc_engine::exports::system_ir(&store).map_err(engine_err)?;
843 for line in scc_engine::exports::jsonl(&ir).map_err(engine_err)? {
844 println!("{line}");
845 }
846 }
847 "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)?)?),
848 "flow-graphs.json" => {
849 println!(
850 "{}",
851 serde_json::to_string_pretty(&store.flow_graphs()?)?
852 )
853 }
854 "capsule.md" => print!("{}", scc_engine::exports::capsule(root).map_err(engine_err)?),
855 other => {
856 return Err(crate::CliError::Other(format!(
857 "unknown export format '{other}' (use system-ir.json, system-ir.jsonl, ccg, or capsule.md)"
858 )))
859 }
860 }
861 Ok(())
862}
863
864pub fn cmd_query(root: &Path, query: &str, limit: usize) -> crate::Result<()> {
868 let store = open_store(root)?;
869 let hit = scc_engine::graph::query(&store, &scc_api::QueryRequest { query: query.into(), limit }).map_err(engine_err)?;
870 if !hit.symbols.is_empty() {
876 println!("— where —");
877 for (name, _sig, _kind, file, line) in &hit.symbols {
878 if *line > 0 {
879 println!("where: {name} {file}:{line}");
880 } else {
881 println!("where: {name} {file}");
882 }
883 }
884 }
885 println!("— entities —");
886 for e in &hit.entities {
887 println!("{} [{}]", e.name, e.kind);
888 }
889 println!("— symbols —");
890 for (name, sig, kind, file, line) in &hit.symbols {
891 if *line > 0 {
892 println!("{name} ({kind}) {file}:{line} {sig}");
893 } else {
894 println!("{name} ({kind}) {file} {sig}");
895 }
896 }
897 Ok(())
898}
899
900pub fn cmd_traverse(
903 root: &Path,
904 kind: Option<&str>,
905 name: Option<&str>,
906 from: &[String],
907 steps: &[String],
908 limit: usize,
909) -> crate::Result<()> {
910 let mut parsed = Vec::new();
911 for spec in steps {
912 let mut parts = spec.splitn(3, ':');
913 let dir = parts.next().unwrap_or("").to_string();
914 if !["out", "in", "both"].contains(&dir.as_str()) {
915 return Err(crate::CliError::Other(format!(
916 "bad --step '{spec}' (dir must be out|in|both, e.g. out:calls:symbol)"
917 )));
918 }
919 parsed.push(scc_api::TraverseStep {
920 dir,
921 predicate: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
922 where_kind: parts.next().filter(|s| !s.is_empty()).map(str::to_string),
923 limit: 0,
924 });
925 }
926 let store = open_store(root)?;
927 let config = load_config(root)?;
928 let stale = crate::stale_paths(&store)?;
929 let engine = scc_engine::workspace::open_engine(&store, &config, stale).map_err(engine_err)?;
930 let cc = engine.ctx();
931 let (entities, rels) = scc_engine::graph::traverse(
932 &cc,
933 &scc_api::TraverseRequest {
934 kind: kind.map(str::to_string),
935 name: name.map(str::to_string),
936 from_ids: from.to_vec(),
937 steps: parsed,
938 limit,
939 trusted_only: true,
940 },
941 )
942 .map_err(engine_err)?;
943 println!("— entities ({}) —", entities.len());
944 for e in &entities {
945 println!("{} [{}] {}", e.name, e.kind, e.id);
946 }
947 println!("— relationships ({}) —", rels.len());
948 for r in &rels {
949 println!("{} -{}-> {}", r.subject, r.predicate, r.object);
950 }
951 Ok(())
952}
953
954pub fn cmd_list_components(root: &Path) -> crate::Result<()> {
956 let store = open_store(root)?;
957 for c in scc_engine::graph::components(&store).map_err(engine_err)? {
958 println!("{}", c.name);
959 }
960 Ok(())
961}
962
963pub fn cmd_list_flows(root: &Path) -> crate::Result<()> {
965 let store = open_store(root)?;
966 for f in scc_engine::graph::flows(&store).map_err(engine_err)? {
967 println!("{} [{}] {}", f.name, crate::flow_kind_str(&f.kind), f.trigger.unwrap_or_default());
968 }
969 Ok(())
970}
971
972pub fn cmd_cochange(root: &Path, min_commits: u32) -> crate::Result<()> {
977 let (pairs, n) = scc_engine::misc::cochange(root, min_commits).map_err(engine_err)?;
978 if pairs.is_empty() {
979 println!("no co-change pairs with >= {min_commits} shared commits");
980 } else {
981 println!("co-change pairs (>= {min_commits} shared commits):");
982 for p in &pairs {
983 println!(" {} <-> {} ×{}", p.a, p.b, p.commits);
984 }
985 }
986 if n > 0 {
987 println!("enriched {n} components with co-change signal");
988 }
989 Ok(())
990}
991
992pub fn cmd_check_invariants(root: &Path) -> crate::Result<bool> {
995 let store = open_store(root)?;
996 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
997 for v in &violations {
998 println!("{}", v.message);
999 }
1000 Ok(violations.is_empty())
1001}
1002
1003pub fn cmd_ci_check(root: &Path, max_severity: &str) -> crate::Result<bool> {
1007 let store = open_store(root)?;
1008 let violations = scc_engine::misc::check_invariants(&store).map_err(engine_err)?;
1009 for v in &violations {
1010 println!("{}", v.message);
1011 }
1012 let (ok, lines) = scc_engine::misc::ci_check(&store, &violations, max_severity).map_err(engine_err)?;
1013 for l in &lines {
1014 if l.starts_with("[ci:") || *l == "ci check passed" {
1016 println!("{l}");
1017 }
1018 }
1019 Ok(ok)
1020}
1021
1022pub fn cmd_checkpoint_save(root: &Path, json: bool) -> crate::Result<()> {
1024 let data = scc_engine::checkpoint::capture(root).map_err(engine_err)?;
1025 if json {
1026 println!("{}", serde_json::to_string(&data)?);
1027 } else {
1028 println!("checkpoint saved to {}", crate::checkpoint_path(root).display());
1029 }
1030 Ok(())
1031}
1032
1033pub fn cmd_checkpoint_load(root: &Path, inject: bool) -> crate::Result<()> {
1035 if let Some(content) = scc_engine::checkpoint::load(root).map_err(engine_err)? {
1036 print!("{content}");
1037 } else if !inject {
1038 println!("no checkpoint found");
1039 }
1040 Ok(())
1041}
1042
1043pub fn cmd_watch(root: &Path) -> crate::Result<()> {
1045 crate::httpd::watch_loop(root)
1046}
1047
1048pub fn cmd_serve(root: &Path) -> crate::Result<()> {
1050 crate::httpd::serve(root)
1051}
1052
1053pub fn cmd_mcp(root: &Path) -> crate::Result<()> {
1055 crate::mcp::serve_stdio(root)
1056}
1057
1058pub fn cmd_setup_claude(root: &Path) -> crate::Result<()> {
1060 crate::plugin::install(root)
1061}
1062
1063#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1067pub enum SetupHarness {
1069 Claude,
1070 Codex,
1071 Opencode,
1072 Omp,
1073 Pi,
1074}
1075
1076pub fn detect_harnesses(home: &Path, path_dirs: &[PathBuf], root: &Path) -> Vec<SetupHarness> {
1082 let on_path = |bin: &str| {
1083 path_dirs
1084 .iter()
1085 .any(|d| d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file())
1086 };
1087 let mut out = Vec::new();
1088 if on_path("claude") || home.join(".claude").is_dir() {
1089 out.push(SetupHarness::Claude);
1090 }
1091 if on_path("codex") || home.join(".codex").is_dir() {
1092 out.push(SetupHarness::Codex);
1093 }
1094 if on_path("opencode") || home.join(".config").join("opencode").is_dir() {
1095 out.push(SetupHarness::Opencode);
1096 }
1097 if on_path("omp") || root.join(".omp").is_dir() {
1098 out.push(SetupHarness::Omp);
1099 }
1100 if on_path("pi") || root.join(".pi").is_dir() || home.join(".pi").is_dir() {
1101 out.push(SetupHarness::Pi);
1102 }
1103 out
1104}
1105
1106fn install_harness(root: &Path, h: SetupHarness) -> crate::Result<()> {
1108 match h {
1109 SetupHarness::Claude => cmd_setup_claude(root),
1110 SetupHarness::Codex => crate::compress::cmd_setup_codex(root),
1111 SetupHarness::Opencode => crate::compress::cmd_setup_opencode(root),
1112 SetupHarness::Omp => crate::plugin_omp::cmd_setup_omp(root),
1113 SetupHarness::Pi => crate::plugin_omp::cmd_setup_pi(root),
1114 }
1115}
1116
1117pub fn cmd_setup_detected(root: &Path, all: bool) -> crate::Result<()> {
1123 let targets: Vec<SetupHarness> = if all {
1124 vec![
1125 SetupHarness::Claude,
1126 SetupHarness::Codex,
1127 SetupHarness::Opencode,
1128 SetupHarness::Omp,
1129 SetupHarness::Pi,
1130 ]
1131 } else {
1132 let home = std::env::var_os("HOME")
1133 .map(PathBuf::from)
1134 .unwrap_or_else(|| PathBuf::from("/"));
1135 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
1136 .map(|p| std::env::split_paths(&p).collect())
1137 .unwrap_or_default();
1138 detect_harnesses(&home, &path_dirs, root)
1139 };
1140 if targets.is_empty() {
1141 println!("No supported harness detected (looked for claude/codex/opencode/omp/pi");
1142 println!("binaries, ~/.claude, ~/.codex, ~/.config/opencode, ~/.pi, and .omp//.pi/ dirs).");
1143 println!("Run `scc setup all` to install for every harness, or `scc setup <harness>`.");
1144 return Ok(());
1145 }
1146 for h in &targets {
1147 println!("=== {:?} ===", h);
1148 install_harness(root, *h)?;
1149 println!();
1150 }
1151 println!("Installed for: {}", targets.iter().map(|h| format!("{h:?}")).collect::<Vec<_>>().join(", "));
1152 if targets.contains(&SetupHarness::Codex) {
1153 println!("Remaining manual step: add the printed entry to ~/.codex/hooks.json (user scope).");
1154 }
1155 Ok(())
1156}
1157
1158pub fn cmd_ingest_runtime(root: &Path, body: &str) -> crate::Result<()> {
1160 scc_engine::state::ingest_runtime(root, body).map_err(engine_err)?;
1161 println!("accepted");
1162 Ok(())
1163}
1164
1165fn adapter_scope(name: &str) -> &'static str {
1172 match name {
1173 "context7" => "network+subprocess(npx)",
1174 "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
1175 _ => "filesystem",
1176 }
1177}
1178
1179fn configured_adapters(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
1186 use scc_indexer::adapters::IntegrationCategory;
1187 let config = load_config(root)?;
1188 let enabled = |key: Option<&str>| -> bool {
1189 match key {
1190 None => true,
1191 Some("serena") => config.integrations.serena,
1192 Some("beads") => config.integrations.beads,
1193 Some("hindsight") => config.integrations.hindsight,
1194 Some("gitnexus") => config.integrations.gitnexus,
1195 Some("context7_command") => !config.integrations.context7_command.is_empty(),
1196 Some(_) => false,
1199 }
1200 };
1201 Ok(scc_indexer::adapters::integration_registry()
1202 .into_iter()
1203 .filter(|d| {
1204 d.category != IntegrationCategory::CompatibilityOnly
1205 && d.category != IntegrationCategory::InternalPass
1206 && d.category != IntegrationCategory::AgentIntegration
1207 && enabled(d.config_key)
1208 })
1209 .map(|d| (d.id.to_string(), adapter_scope(d.id)))
1210 .collect())
1211}
1212
1213pub fn cmd_adapters(root: &Path, json: bool) -> crate::Result<()> {
1218 let manifests = scc_indexer::adapters::adapter_manifests();
1219 if json {
1220 println!("{}", serde_json::to_string_pretty(&manifests)?);
1221 return Ok(());
1222 }
1223 for (name, scope) in configured_adapters(root)? {
1224 println!("adapter: {name} scope: {scope}");
1225 }
1226 Ok(())
1227}
1228
1229pub fn cmd_update(version: Option<&str>, dir: Option<&std::path::Path>, dry_run: bool) -> crate::Result<()> {
1243 let exe = std::env::current_exe().map_err(|e| crate::CliError::Other(format!("cannot locate running binary: {e}")))?;
1244 let default_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from("."));
1245 let target_dir = dir.map(|p| p.to_path_buf()).unwrap_or(default_dir);
1246 let resolved: String = match version {
1249 Some(v) => v.to_string(),
1250 None => latest_release_tag()?,
1251 };
1252 let base = std::env::var("SCC_DOWNLOAD_BASE").unwrap_or_else(|_| {
1257 "https://github.com/carterlasalle/scc/releases/download".to_string()
1258 });
1259 let trimmed = base.trim_end_matches('/');
1260 let release_dir = trimmed.strip_suffix("/releases/download").map(|prefix| {
1261 format!("{prefix}/releases/download/v{resolved}")
1262 });
1263 let installer_url = match release_dir {
1264 Some(dir) => format!("{dir}/install.sh"),
1265 None => format!("{trimmed}/install.sh"),
1266 };
1267 let mut cmd = std::process::Command::new("sh");
1268 cmd.arg("-c").arg(format!(
1269 "curl -fsSL {url:?} | sh -s -- --version {ver} --dir {dir} {dry}",
1270 url = installer_url,
1271 ver = resolved,
1272 dir = target_dir.display(),
1273 dry = if dry_run { "--dry-run" } else { "" },
1274 ));
1275 let status = cmd.status().map_err(|e| crate::CliError::Other(format!("update failed to launch installer: {e}")))?;
1276 if !status.success() {
1277 return Err(crate::CliError::Other(format!("installer exited with status {status}")));
1278 }
1279 Ok(())
1280}
1281
1282fn latest_release_tag() -> crate::Result<String> {
1284 let api_base = std::env::var("SCC_API_BASE")
1285 .unwrap_or_else(|_| "https://api.github.com/repos/carterlasalle/scc".to_string());
1286 let url = format!("{}/releases/latest", api_base.trim_end_matches('/'));
1287 let out = std::process::Command::new("curl")
1288 .args(["-fsSL", "--max-time", "15", &url])
1289 .output()
1290 .map_err(|e| crate::CliError::Other(format!("latest-release lookup failed: {e}")))?;
1291 if !out.status.success() {
1292 return Err(crate::CliError::Other(format!("latest-release lookup failed: {url}")));
1293 }
1294 let v: serde_json::Value = serde_json::from_slice(&out.stdout)
1295 .map_err(|e| crate::CliError::Other(format!("latest-release response not JSON: {e}")))?;
1296 let tag = v.get("tag_name").and_then(|t| t.as_str()).unwrap_or("").trim_start_matches('v');
1297 if tag.is_empty() {
1298 return Err(crate::CliError::Other("latest release has no tag_name".to_string()));
1299 }
1300 Ok(tag.to_string())
1301}
1302
1303pub fn cmd_timing(root: &Path, all: bool, last: usize, json: bool) -> crate::Result<()> {
1313 let home = std::env::var("HOME").map_err(|e| crate::CliError::Other(format!("cannot locate HOME: {e}")))?;
1314 let log = std::path::PathBuf::from(home).join(".cache").join("scc").join("extension.log");
1315 let raw = std::fs::read_to_string(&log).unwrap_or_default();
1316 let mut rows: Vec<(String, String, f64, bool)> = Vec::new();
1317 for line in raw.lines() {
1318 let v: serde_json::Value = match serde_json::from_str(line) {
1319 Ok(v) => v,
1320 Err(_) => continue,
1321 };
1322 let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("");
1323 if event == "inject" {
1324 continue;
1327 }
1328 if event != "spawn" {
1329 continue;
1330 }
1331 let ms = match v.get("ms").and_then(|m| m.as_f64()) {
1332 Some(m) => m,
1333 None => continue,
1334 };
1335 let cmd = v.get("cmd").and_then(|c| c.as_str()).unwrap_or("?").to_string();
1336 let repo = v.get("repo").and_then(|r| r.as_str()).unwrap_or("?").to_string();
1337 let ok = v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true);
1338 rows.push((cmd, repo, ms, ok));
1339 }
1340 let root_str = root.to_string_lossy().to_string();
1341 if last > 0 && rows.len() > last {
1342 rows = rows.split_off(rows.len() - last);
1343 }
1344 if !all {
1345 rows.retain(|(_, repo, _, _)| repo == &root_str || root_str.starts_with(&format!("{repo}/")));
1346 }
1347 let mut groups: std::collections::BTreeMap<String, Vec<f64>> = std::collections::BTreeMap::new();
1348 let mut fails: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1349 for (cmd, _, ms, ok) in &rows {
1350 let short = cmd.strip_prefix("scc ").unwrap_or(cmd).split_whitespace().next().unwrap_or(cmd);
1353 let key = format!("scc {short}");
1354 groups.entry(key.clone()).or_default().push(*ms);
1355 if !ok {
1356 *fails.entry(key).or_default() += 1;
1357 }
1358 }
1359 let mut out: Vec<serde_json::Value> = Vec::new();
1360 for (cmd, mut ms) in groups {
1361 ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1362 let n = ms.len();
1363 let total: f64 = ms.iter().sum();
1364 let pct = |p: f64| ms[((p * n as f64).ceil() as usize).saturating_sub(1).min(n - 1)];
1365 out.push(serde_json::json!({
1366 "cmd": cmd, "n": n, "total_ms": (total * 10.0).round() / 10.0,
1367 "p50_ms": (pct(0.5) * 10.0).round() / 10.0,
1368 "p95_ms": (pct(0.95) * 10.0).round() / 10.0,
1369 "max_ms": ms[n - 1],
1370 "failed": fails.get(&cmd).cloned().unwrap_or(0),
1371 }));
1372 }
1373 out.sort_by(|a, b| {
1374 b.get("total_ms").and_then(|t| t.as_f64()).unwrap_or(0.0)
1375 .partial_cmp(&a.get("total_ms").and_then(|t| t.as_f64()).unwrap_or(0.0))
1376 .unwrap_or(std::cmp::Ordering::Equal)
1377 });
1378 if json {
1379 println!("{}", serde_json::to_string_pretty(&serde_json::json!({
1380 "log": log.display().to_string(), "repo": if all { None } else { Some(root_str) }, "commands": out,
1381 }))?);
1382 return Ok(());
1383 }
1384 if out.is_empty() {
1385 println!("no timing records ({}; use --all for every repository)", log.display());
1386 return Ok(());
1387 }
1388 println!("{:<28} {:>5} {:>10} {:>8} {:>8} {:>8} {:>6}", "command", "n", "total_ms", "p50", "p95", "max", "fail");
1389 for c in &out {
1390 println!("{:<28} {:>5} {:>10} {:>8} {:>8} {:>8} {:>6}",
1391 c.get("cmd").and_then(|v| v.as_str()).unwrap_or("?"),
1392 c.get("n").and_then(|v| v.as_u64()).unwrap_or(0),
1393 c.get("total_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1394 c.get("p50_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1395 c.get("p95_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1396 c.get("max_ms").and_then(|v| v.as_f64()).unwrap_or(0.0),
1397 c.get("failed").and_then(|v| v.as_u64()).unwrap_or(0));
1398 }
1399 let raw2 = std::fs::read_to_string(&log).unwrap_or_default();
1402 let mut injections = 0usize;
1403 let mut startup_tok = 0u64;
1404 let mut task_tok = 0u64;
1405 let all_rows: Vec<serde_json::Value> = raw2.lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
1407 let scoped: Vec<&serde_json::Value> = if last > 0 && all_rows.len() > last {
1408 all_rows[all_rows.len() - last..].iter().collect()
1409 } else {
1410 all_rows.iter().collect()
1411 };
1412 for v in scoped {
1413 if v.get("event").and_then(|e| e.as_str()) != Some("inject") {
1414 continue;
1415 }
1416 let repo = v.get("repo").and_then(|r| r.as_str()).unwrap_or("?");
1417 if !all && !(repo == root_str.as_str() || root_str.starts_with(&format!("{repo}/"))) {
1418 continue;
1419 }
1420 injections += 1;
1421 startup_tok += v.get("startup_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
1422 task_tok += v.get("task_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
1423 }
1424 println!();
1425 if injections == 0 {
1426 println!("tokens injected: no hook firings recorded (update the extension, then prompt once)");
1427 } else {
1428 println!("tokens injected: {injections} hook firing(s), startup {startup_tok}, task {task_tok}, total {}",
1429 startup_tok + task_tok);
1430 }
1431 Ok(())
1432}
1433
1434pub fn cmd_clean(root: &Path, force: bool) -> crate::Result<()> {
1440 let db = root.join(".scc").join("scc.db");
1441 let sidecars = [".scc/scc.db-wal", ".scc/scc.db-shm"];
1442 let mut targets: Vec<std::path::PathBuf> = Vec::new();
1443 if db.exists() {
1444 targets.push(db.clone());
1445 }
1446 for s in sidecars {
1447 let p = root.join(s);
1448 if p.exists() {
1449 targets.push(p);
1450 }
1451 }
1452 if targets.is_empty() {
1453 println!("nothing to clean (no .scc/scc.db at {})", root.display());
1454 return Ok(());
1455 }
1456 let bytes: u64 = targets.iter().map(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)).sum();
1457 if !force {
1458 println!("will remove {} file(s), freeing {} bytes:", targets.len(), bytes);
1459 for t in &targets {
1460 println!(" {}", t.display());
1461 }
1462 print!("proceed? [y/N] ");
1463 use std::io::Write;
1464 std::io::stdout().flush().map_err(|e| crate::CliError::Other(e.to_string()))?;
1465 let mut answer = String::new();
1466 std::io::stdin().read_line(&mut answer).map_err(|e| crate::CliError::Other(e.to_string()))?;
1467 if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
1468 println!("aborted");
1469 return Ok(());
1470 }
1471 }
1472 for t in &targets {
1473 std::fs::remove_file(t).map_err(|e| crate::CliError::Other(format!("cannot remove {}: {e}", t.display())))?;
1474 }
1475 println!("removed {} file(s), freed {} bytes; next `scc index` is a cold build", targets.len(), bytes);
1476 Ok(())
1477}
1478
1479pub fn cmd_doctor(root: &Path, json: bool, deep: bool, network: bool, strict: bool) -> crate::Result<bool> {
1494 let store = open_store(root)?;
1495 let config = load_config(root)?;
1496 let rep = scc_engine::integrations::doctor_report(&store, &config, root, deep, network).map_err(engine_err)?;
1497 let rows = rep.integrations;
1498 let agent_rows = rep.agents;
1499 let mut warnings = rep.warnings;
1500 let (scc_version, rev, stale_len, dangling, stats) =
1501 (rep.scc_version, rep.revision, rep.stale_files, rep.dangling_edges, rep.stats);
1502 if json {
1503 println!(
1504 "{}",
1505 serde_json::to_string_pretty(&serde_json::json!({
1506 "scc": scc_version,
1507 "revision": rev,
1508 "stale_files": stale_len,
1509 "dangling_edges": dangling,
1510 "stats": stats,
1511 "integrations": rows,
1512 "agents": agent_rows.iter().map(|a| serde_json::json!({"id": a.id, "present": a.present, "detail": a.detail})).collect::<Vec<_>>(),
1513 "warnings": warnings,
1514 }))?
1515 );
1516 } else {
1517 println!("SCC Doctor");
1518 println!("==========");
1519 println!();
1520 println!("CORE");
1521 println!(" scc {scc_version}");
1522 println!(" revision {rev}");
1523 println!(" stale {} file(s)", stale_len);
1524 println!(" dangling {dangling} edge(s) (see `scc check-invariants`)");
1525 println!(" entities {}", stats.get("entities").copied().unwrap_or(0));
1526 println!();
1527 println!("INTEGRATIONS");
1528 for r in &rows {
1529 let mark = if r.contributing {
1530 "✓"
1531 } else if r.configured {
1532 "!"
1533 } else {
1534 "-"
1535 };
1536 let reach = match r.reachable {
1537 Some(true) => "reachable; ",
1538 Some(false) => "UNREACHABLE; ",
1539 None => "",
1540 };
1541 println!(" {mark} {:<12} [{:<18}] {}{}", r.id, r.category, reach, r.detail);
1542 }
1543 println!();
1544 println!("AGENTS");
1545 for a in &agent_rows {
1546 println!(" {} {:<12} {}", if a.present { "✓" } else { "-" }, a.id, a.detail);
1547 }
1548 println!();
1549 if warnings == 0 {
1550 println!("SUMMARY PASS ({} integrations contributing evidence)", rows.iter().filter(|r| r.contributing).count());
1551 } else {
1552 println!("SUMMARY {warnings} warning(s); run `scc doctor --deep` / `--network` for handshake detail");
1553 }
1554 }
1555 if dangling > 0 {
1556 warnings += 1;
1557 }
1558 Ok(!(strict && warnings > 0))
1562}
1563
1564pub fn cmd_lessons_add(root: &Path, text: &str) -> crate::Result<()> {
1569 let dir = scc_dir(root);
1570 std::fs::create_dir_all(&dir)?;
1571 let path = dir.join("lessons.jsonl");
1572 let n = std::fs::read_to_string(&path)
1573 .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
1574 .unwrap_or(0);
1575 let id = format!("lesson-{}", n + 1);
1576 let record = serde_json::json!({
1577 "id": id,
1578 "text": text,
1579 "created_at": scc_core::now_rfc3339(),
1580 });
1581 let mut f = std::fs::OpenOptions::new()
1582 .create(true)
1583 .append(true)
1584 .open(&path)
1585 .map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1586 writeln!(f, "{record}").map_err(|e| crate::CliError::Other(format!("lessons: {e}")))?;
1587 println!(
1588 "appended {id} to {} (ingest with `scc import hindsight .scc/lessons.jsonl`)",
1589 path.display()
1590 );
1591 Ok(())
1592}
1593
1594pub fn cmd_lessons_list(root: &Path, limit: usize) -> crate::Result<()> {
1598 let lessons = scc_engine::state::lessons_list(root, limit).map_err(engine_err)?;
1599 if lessons.is_empty() {
1600 println!("no lessons in the store — run `scc lessons add \"...\"` then `scc import hindsight .scc/lessons.jsonl`");
1601 return Ok(());
1602 }
1603 for (content, tags) in lessons {
1604 let tag_str = if tags.is_empty() {
1605 String::new()
1606 } else {
1607 format!(" [{}]", tags.join(", "))
1608 };
1609 println!("- {content}{tag_str}");
1610 }
1611 Ok(())
1612}
1613
1614pub fn cmd_beads(root: &Path) -> crate::Result<()> {
1618 let active = scc_engine::state::beads(root, 20).map_err(engine_err)?;
1619 if active.is_empty() {
1620 println!("no active beads tasks (checked .beads/issues.jsonl)");
1621 return Ok(());
1622 }
1623 println!("active beads tasks:");
1624 for t in active {
1625 println!("- {t}");
1626 }
1627 Ok(())
1628}
1629
1630pub fn cmd_import(root: &Path, format: &str, file: &str) -> crate::Result<()> {
1632 let report = scc_engine::state::import_evidence(root, format, file).map_err(engine_err)?;
1633 println!(
1636 "imported {} symbols, {} calls, {} imports ({} errors); evidence epoch bumped, derived layer recompiled",
1637 report.symbols, report.calls, report.imports, report.errors
1638 );
1639 Ok(())
1640}
1641
1642pub fn cmd_runtime_status(root: &Path, json: bool) -> crate::Result<()> {
1644 let edges = scc_engine::state::runtime_edges(root).map_err(engine_err)?;
1645 if json {
1646 println!("{}", serde_json::to_string_pretty(&edges)?);
1647 return Ok(());
1648 }
1649 if edges.is_empty() {
1650 println!("no runtime observations ingested");
1651 }
1652 let total: u64 = edges.iter().map(|e| e.count).sum();
1653 let errs: u64 = edges.iter().map(|e| e.errors).sum();
1654 println!("{} observed edge(s), {} observations, {} error(s)", edges.len(), total, errs);
1655 for e in &edges {
1656 println!(
1657 "- {} → {} ×{} (avg {:.1} ms, {} err, last {})",
1658 e.source, e.target, e.count, e.latency_ms, e.errors, e.last_observed
1659 );
1660 }
1661 Ok(())
1662}
1663
1664pub fn cmd_runtime_reconcile(root: &Path, json: bool) -> crate::Result<()> {
1666 let rec = scc_engine::state::reconcile(root).map_err(engine_err)?;
1667 if json {
1668 println!("{}", serde_json::to_string_pretty(&rec)?);
1669 return Ok(());
1670 }
1671 println!("static-vs-observed reconciliation");
1672 println!(" matched: {}", rec.matched.len());
1673 println!(" observed not static: {}", rec.observed_not_static.len());
1674 println!(" static not observed: {}", rec.static_not_observed.len());
1675 const MAX_RECONCILE_DETAIL: usize = 20;
1677 let cap = |label: &str, items: &[String]| {
1678 for e in items.iter().take(MAX_RECONCILE_DETAIL) {
1679 println!(" [{label}] {e}");
1680 }
1681 if items.len() > MAX_RECONCILE_DETAIL {
1682 println!(
1683 " …and {} more [{label}] (counts exact; --json for the full list)",
1684 items.len() - MAX_RECONCILE_DETAIL
1685 );
1686 }
1687 };
1688 cap("matched", &rec.matched);
1689 cap("runtime-only", &rec.observed_not_static);
1690 cap("static-only", &rec.static_not_observed);
1691 Ok(())
1692}
1693
1694pub fn cmd_diagram(root: &Path, format: &str, out: Option<&str>) -> crate::Result<()> {
1699 let store = open_store(root)?;
1700 if store.snapshot_status()?.is_none() {
1701 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1702 }
1703 let v = scc_engine::exports::diagram(&store, format).map_err(engine_err)?;
1708 let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
1709 if let Some(path) = out {
1710 std::fs::write(path, text)?;
1711 println!(
1712 "diagram: {} nodes, {} edges, {} flows -> {path}",
1713 v.get("nodes").and_then(|n| n.as_u64()).unwrap_or(0),
1714 v.get("edges").and_then(|n| n.as_u64()).unwrap_or(0),
1715 v.get("flows").and_then(|n| n.as_u64()).unwrap_or(0),
1716 );
1717 } else {
1718 print!("{text}");
1719 }
1720 Ok(())
1721}
1722
1723pub fn cmd_snap(
1729 root: &Path,
1730 out: Option<&str>,
1731 max_chars: usize,
1732 png: Option<&str>,
1733 color: bool,
1734) -> crate::Result<()> {
1735 let store = open_store(root)?;
1736 if store.snapshot_status()?.is_none() {
1737 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1738 }
1739 let config = load_config(root)?;
1740 let map = crate::viewer::map_text(&store, max_chars)?;
1741 let rows = map.lines().count();
1742 let (text_tokens, image_tokens) = crate::viewer::token_estimate(map.len(), rows);
1743 let render = png.is_some() || config.context.snap_enabled;
1744 if let Some(path) = out {
1745 std::fs::write(path, &map)?;
1746 } else {
1747 print!("{map}");
1748 if !map.ends_with('\n') {
1749 println!();
1750 }
1751 }
1752 if color {
1753 eprintln!("note: --color is accepted for recipe parity; the pinned recipe renders monochrome unless edited");
1754 }
1755 println!("snap: {rows} rows, {} chars — text ~{text_tokens} tokens vs PNG ~{image_tokens} image tokens", map.len());
1756 if render {
1757 let dest = png.unwrap_or("scc-snap.png");
1758 render_snap_png(&map, dest)?;
1759 println!("snap png -> {dest}");
1760 } else {
1761 println!("render OFF by default (set context.snap_enabled=true or pass --png <file>); recipe:");
1762 println!("{}", crate::viewer::snap_recipe());
1763 }
1764 Ok(())
1765}
1766
1767fn render_snap_png(map: &str, dest: &str) -> crate::Result<()> {
1772 let probe = std::process::Command::new("python3")
1775 .arg("-c")
1776 .arg("import PIL")
1777 .output()
1778 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e})")))?;
1779 if !probe.status.success() {
1780 return Err(crate::CliError::Other(
1781 "snap: python3 has no Pillow (pip install pillow); map text written, recipe printed above".into(),
1782 ));
1783 }
1784 let dir = tempfile::TempDir::new()?;
1785 let map_path = dir.path().join("map.txt");
1786 let recipe_path = dir.path().join("snap.py");
1787 std::fs::write(&map_path, map)?;
1788 std::fs::write(&recipe_path, crate::viewer::snap_recipe())?;
1789 let out = std::process::Command::new("python3")
1790 .arg(&recipe_path)
1791 .arg(&map_path)
1792 .arg(dest)
1793 .output()
1794 .map_err(|e| crate::CliError::Other(format!("snap: cannot run python3 ({e}); recipe printed above")))?;
1795 if !out.status.success() {
1796 return Err(crate::CliError::Other(format!(
1797 "snap: Pillow render failed: {}",
1798 String::from_utf8_lossy(&out.stderr).trim()
1799 )));
1800 }
1801 Ok(())
1802}
1803
1804pub fn cmd_view(root: &Path, port: Option<u16>, no_open: bool) -> crate::Result<()> {
1809 let store = open_store(root)?;
1810 if store.snapshot_status()?.is_none() {
1811 return Err(crate::CliError::Other("not indexed yet — run `scc index`".into()));
1812 }
1813 let addr = match port {
1814 Some(p) => format!("127.0.0.1:{p}"),
1815 None => "127.0.0.1:0".to_string(),
1816 };
1817 let server = tiny_http::Server::http(&addr)
1818 .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
1819 let bound = server.server_addr();
1820 let url = format!("http://{bound}/");
1821 println!("scc view: {url} (root {})", root.display());
1822 if !no_open {
1823 #[cfg(target_os = "macos")]
1824 let _ = std::process::Command::new("open").arg(&url).spawn();
1825 #[cfg(target_os = "linux")]
1826 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
1827 }
1828 for request in server.incoming_requests() {
1829 let url_path = request.url().to_string();
1830 let method = request.method().clone();
1831 if method == tiny_http::Method::Get && crate::viewer::is_viewer_path(&url_path) {
1832 let store = match open_store(root) {
1833 Ok(s) => s,
1834 Err(e) => {
1835 let _ = request.respond(
1836 tiny_http::Response::from_string(format!("store error: {e}"))
1837 .with_status_code(500),
1838 );
1839 continue;
1840 }
1841 };
1842 let (status, body) = crate::viewer::serve_viewer(&store, &url_path);
1843 let response = tiny_http::Response::from_string(body)
1844 .with_status_code(status)
1845 .with_header(
1846 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
1847 .unwrap(),
1848 );
1849 let _ = request.respond(response);
1850 continue;
1851 }
1852 let _ = request.respond(
1855 tiny_http::Response::from_string("no such viewer route (scc serve owns /v1/*)")
1856 .with_status_code(404),
1857 );
1858 }
1859 Ok(())
1860}
1861
1862pub fn cmd_rpc(root: &Path, _stdio: bool) -> crate::Result<()> {
1865 scc_engine::rpc::serve_stdio(root).map_err(engine_err)
1866}
1867
1868pub fn cmd_operations(describe: Option<&str>) -> crate::Result<()> {
1871 if let Some(id) = describe {
1872 match scc_engine::ops::describe(id) {
1873 Some(d) => {
1874 println!("{}", serde_json::to_string_pretty(&d)?);
1875 match scc_engine::ops::input_schema(id) {
1876 Some(s) => println!("{}", serde_json::to_string_pretty(&s)?),
1877 None => println!("(no typed input schema: free-form object)"),
1878 }
1879 }
1880 None => println!("unknown operation '{id}' (see `scc operations`)"),
1881 }
1882 return Ok(());
1883 }
1884 println!("Operation Mutation Stream Stability Description");
1885 println!("--------------------------------------------------------------------------------");
1886 for d in scc_engine::ops::OPERATIONS {
1887 println!("{:<26} {:<14} {:<7} {:<13} {}", d.id, format!("{:?}", d.mutation), d.streaming, format!("{:?}", d.stability), d.description);
1888 }
1889 Ok(())
1890}
1891
1892pub fn cmd_plugin_list(root: &Path) -> crate::Result<()> {
1895 let out = scc_engine::invoke(root, "plugins.list", serde_json::json!({})).map_err(engine_err)?;
1896 println!("{}", serde_json::to_string_pretty(&out)?);
1897 Ok(())
1898}
1899
1900pub fn cmd_plugin_describe(root: &Path, id: &str) -> crate::Result<()> {
1903 let out = scc_engine::invoke(root, "plugins.describe", serde_json::json!({"id": id})).map_err(engine_err)?;
1904 println!("{}", serde_json::to_string_pretty(&out)?);
1905 Ok(())
1906}
1907
1908pub fn cmd_plugin_doctor(root: &Path) -> crate::Result<()> {
1911 let out = scc_engine::invoke(root, "plugins.doctor", serde_json::json!({})).map_err(engine_err)?;
1912 println!("{}", serde_json::to_string_pretty(&out)?);
1913 Ok(())
1914}
1915
1916pub fn cmd_plugin_graph(root: &Path) -> crate::Result<()> {
1920 let out = scc_engine::invoke(root, "plugins.graph", serde_json::json!({})).map_err(engine_err)?;
1921 let groups = out.get("groups").and_then(|g| g.as_object());
1922 match groups {
1923 Some(g) if !g.is_empty() => {
1924 for (ty, items) in g {
1925 println!("{ty}:");
1926 for it in items.as_array().cloned().unwrap_or_default() {
1927 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));
1928 }
1929 }
1930 }
1931 _ => println!("(no extensions registered)"),
1932 }
1933 Ok(())
1934}
1935
1936pub fn cmd_plugin_lock(root: &Path) -> crate::Result<()> {
1939 let out = scc_engine::invoke(root, "plugins.lock", serde_json::json!({})).map_err(engine_err)?;
1940 println!("{}", serde_json::to_string_pretty(&out)?);
1941 Ok(())
1942}
1943
1944pub fn cmd_plugin_check(root: &Path) -> crate::Result<()> {
1947 let out = scc_engine::invoke(root, "plugins.check", serde_json::json!({})).map_err(engine_err)?;
1948 println!("{}", serde_json::to_string_pretty(&out)?);
1949 if out.get("ok").and_then(|v| v.as_bool()) != Some(true) {
1950 return Err(crate::CliError::Other(format!(
1951 "plugin lock drift: {}",
1952 out.get("drift").map(|v| v.to_string()).unwrap_or_default()
1953 )));
1954 }
1955 Ok(())
1956}
1957
1958pub fn cmd_plugin_enable(root: &Path, id: &str) -> crate::Result<()> {
1962 let out = scc_engine::invoke(root, "plugins.enable", serde_json::json!({"id": id})).map_err(engine_err)?;
1963 println!("{}", serde_json::to_string_pretty(&out)?);
1964 Ok(())
1965}
1966
1967pub fn cmd_plugin_disable(root: &Path, id: &str) -> crate::Result<()> {
1969 let out = scc_engine::invoke(root, "plugins.disable", serde_json::json!({"id": id})).map_err(engine_err)?;
1970 println!("{}", serde_json::to_string_pretty(&out)?);
1971 Ok(())
1972}
1973
1974pub fn cmd_plugin_invoke(root: &Path, operation: &str, input: &str) -> crate::Result<()> {
1977 let input: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::json!({}));
1978 let out = scc_engine::invoke(root, operation, input).map_err(engine_err)?;
1979 println!("{}", serde_json::to_string_pretty(&out)?);
1980 Ok(())
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985
1986 #[test]
1987 fn ensure_scc_ignored_keeps_intent_committable() {
1989 let dir = tempfile::TempDir::new().unwrap();
1990 let root = dir.path();
1991 crate::ensure_scc_ignored(root);
1992 let gi = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1993 assert!(gi.contains(".scc/*"), "{gi}");
1994 assert!(gi.contains("!.scc/intent.yaml"), "{gi}");
1995 assert!(gi.contains("!.scc/plugins.toml"), "{gi}");
1996 assert!(gi.contains("!.scc/plugins.lock"), "{gi}");
1997 crate::ensure_scc_ignored(root);
1998 let gi2 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
1999 assert_eq!(gi, gi2, "idempotent");
2000 std::fs::write(root.join(".gitignore"), "target/\n.scc/\n").unwrap();
2001 crate::ensure_scc_ignored(root);
2002 let gi3 = std::fs::read_to_string(root.join(".gitignore")).unwrap();
2003 assert!(gi3.contains("target/"), "{gi3}");
2004 assert!(!gi3.lines().any(|l| l.trim() == ".scc/"), "{gi3}");
2005 assert!(gi3.contains("!.scc/intent.yaml"), "{gi3}");
2006 }
2007
2008 #[test]
2009 fn detect_harnesses_finds_bins_dirs_and_project_dirs() {
2011 let home = tempfile::TempDir::new().unwrap();
2012 let bindir = home.path().join("bin");
2013 std::fs::create_dir_all(&bindir).unwrap();
2014 std::fs::write(bindir.join("codex"), "#!/bin/sh\n").unwrap();
2015 std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
2016 let root = tempfile::TempDir::new().unwrap();
2017 std::fs::create_dir_all(root.path().join(".omp")).unwrap();
2018 let found = detect_harnesses(
2019 home.path(),
2020 &[bindir],
2021 root.path(),
2022 );
2023 assert!(found.contains(&SetupHarness::Codex), "codex via PATH: {found:?}");
2024 assert!(found.contains(&SetupHarness::Opencode), "opencode via config dir: {found:?}");
2025 assert!(found.contains(&SetupHarness::Omp), "omp via project dir: {found:?}");
2026 assert!(!found.contains(&SetupHarness::Claude), "no claude present: {found:?}");
2027 assert!(!found.contains(&SetupHarness::Pi), "no pi present: {found:?}");
2028 }
2029
2030 #[test]
2031 fn detect_harnesses_empty_when_nothing_present() {
2033 let home = tempfile::TempDir::new().unwrap();
2034 let root = tempfile::TempDir::new().unwrap();
2035 let found = detect_harnesses(home.path(), &[], root.path());
2036 assert!(found.is_empty(), "{found:?}");
2037 }
2038
2039 use super::*;
2040
2041
2042 #[test]
2043fn lessons_add_appends_jsonl_lines() {
2045 let dir = tempfile::TempDir::new().unwrap();
2046 let root = dir.path().join("repo");
2047 std::fs::create_dir_all(&root).unwrap();
2048 cmd_lessons_add(&root, "first lesson").unwrap();
2049 cmd_lessons_add(&root, "second lesson").unwrap();
2050 let path = root.join(".scc/lessons.jsonl");
2051 let text = std::fs::read_to_string(&path).unwrap();
2052 let lines: Vec<&str> = text.lines().collect();
2053 assert_eq!(lines.len(), 2, "a second add must append, not overwrite");
2054 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
2055 assert_eq!(first["id"], "lesson-1");
2056 assert_eq!(first["text"], "first lesson");
2057 assert!(first["created_at"].is_string());
2058 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
2059 assert_eq!(second["id"], "lesson-2");
2060 assert_eq!(second["text"], "second lesson");
2061 let lesson: scc_indexer::adapters::hindsight::Lesson =
2063 serde_json::from_str(lines[0]).unwrap();
2064 assert_eq!(lesson.body(), "first lesson");
2065 }
2066
2067 #[test]
2068fn adapters_lists_configured_scope() {
2070 let dir = tempfile::TempDir::new().unwrap();
2071 let root = dir.path().join("repo");
2072 std::fs::create_dir_all(root.join(".scc")).unwrap();
2073 std::fs::write(
2074 root.join(".scc/config.yaml"),
2075 "schema: 1\nintegrations:\n beads: true\n context7_command: \"npx -y @upstash/context7-mcp\"\n",
2076 )
2077 .unwrap();
2078 let listing = configured_adapters(&root).unwrap();
2079 assert!(
2080 listing.contains(&("beads".to_string(), "filesystem")),
2081 "{listing:?}"
2082 );
2083 assert!(
2084 listing.contains(&("context7".to_string(), "network+subprocess(npx)")),
2085 "{listing:?}"
2086 );
2087 assert!(
2088 !listing.iter().any(|(n, _)| n == "hindsight"),
2089 "disabled integrations must not be listed: {listing:?}"
2090 );
2091 assert!(listing.iter().any(|(n, _)| n == "scip"), "{listing:?}");
2093 assert!(listing.iter().any(|(n, _)| n == "cbm"), "{listing:?}");
2094 for (n, _) in &listing {
2097 assert!(
2098 scc_indexer::adapters::resolve_integration(n).is_some(),
2099 "listed adapter {n} must resolve in the registry: {listing:?}"
2100 );
2101 }
2102 assert!(listing.iter().any(|(n, _)| n == "tracelayer"), "{listing:?}");
2103 assert!(!listing.iter().any(|(n, _)| n == "narsil"), "{listing:?}");
2105 assert!(listing.iter().any(|(n, _)| n == "ccg"), "{listing:?}");
2106 cmd_adapters(&root, false).unwrap();
2108 assert!(cmd_doctor(&root, false, false, false, false).unwrap(), "doctor must pass clean");
2110 assert!(cmd_doctor(&root, true, false, false, false).unwrap(), "doctor --json must pass clean");
2111 }
2112}
2113